diff --git a/README.md b/README.md index e621142..fe370fa 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,40 @@ myBase.myMethod(); myBase.myProperty; ``` +### TypeScript for a customized Base class + +If you write your `d.ts` files by hand instead of generating them from TypeScript source code, you can use the `ExtendBaseWith` Generic to create a class with custom defaults and plugins. It can even inherit from another customized class. + +```ts +import { Base, ExtendBaseWith } from "../../index.js"; + +import { myPlugin } from "./my-plugin.js"; + +export const MyBase: ExtendBaseWith< + Base, + { + defaults: { + myPluginOption: string; + }; + plugins: [typeof myPlugin]; + } +>; + +// support using the `MyBase` import to be used as a class instance type +export type MyBase = typeof MyBase; +``` + +The last line is important in order to make `MyBase` behave like a class type, making the following code possible: + +```ts +import { MyBase } from "./index.js"; + +export async function testInstanceType(client: MyBase) { + // types set correctly on `client` + client.myPlugin({ myPluginOption: "foo" }); +} +``` + ### Defaults TypeScript will not complain when chaining `.withDefaults()` calls endlessly: the static `.defaults` property will be set correctly. However, when instantiating from a class with 4+ chained `.withDefaults()` calls, then only the defaults from the first 3 calls are supported. See [#57](https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/pull/57) for details. diff --git a/examples/rest-api-client-dts/README.md b/examples/rest-api-client-dts/README.md new file mode 100644 index 0000000..13b6d0b --- /dev/null +++ b/examples/rest-api-client-dts/README.md @@ -0,0 +1,20 @@ +# Custom class with defaults and plugins (TypeScript Declaration example) + +This example does not implement any code, it's meant as a reference for types only. + +Usage example: + +```js +import { RestApiClient } from "javascript-plugin-architecture-with-typescript-definitions/examples/rest-api-client-dts"; + +const client = new RestApiClient({ + baseUrl: "https://api.github.com", + userAgent: "my-app/1.0.0", + headers: { + authorization: "token ghp_aB3...", + }, +}); + +const { data } = await client.request("GET /user"); +console.log("You are logged in as %s", data.login); +``` diff --git a/examples/rest-api-client-dts/index.d.ts b/examples/rest-api-client-dts/index.d.ts new file mode 100644 index 0000000..d2c67d5 --- /dev/null +++ b/examples/rest-api-client-dts/index.d.ts @@ -0,0 +1,15 @@ +import { Base, ExtendBaseWith } from "../../index.js"; + +import { requestPlugin } from "./request-plugin.js"; + +export const RestApiClient: ExtendBaseWith< + Base, + { + defaults: { + userAgent: string; + }; + plugins: [typeof requestPlugin]; + } +>; + +export type RestApiClient = typeof RestApiClient; diff --git a/examples/rest-api-client-dts/index.js b/examples/rest-api-client-dts/index.js new file mode 100644 index 0000000..8932b42 --- /dev/null +++ b/examples/rest-api-client-dts/index.js @@ -0,0 +1,5 @@ +import { Base } from "../../index.js"; + +export const RestApiClient = Base.withPlugins([requestPlugin]).withDefaults({ + userAgent: "rest-api-client/1.0.0", +}); diff --git a/examples/rest-api-client-dts/index.test-d.ts b/examples/rest-api-client-dts/index.test-d.ts new file mode 100644 index 0000000..8bffe51 --- /dev/null +++ b/examples/rest-api-client-dts/index.test-d.ts @@ -0,0 +1,54 @@ +import { expectType } from "tsd"; + +import { RestApiClient } from "./index.js"; + +// @ts-expect-error - An argument for 'options' was not provided +let value: typeof RestApiClient = new RestApiClient(); + +expectType<{ userAgent: string }>(value.defaults); + +expectType<{ userAgent: string }>(RestApiClient.defaults); + +// @ts-expect-error - Type '{}' is missing the following properties from type 'Options': myRequiredUserOption +new RestApiClient({}); + +new RestApiClient({ + baseUrl: "https://api.github.com", + userAgent: "my-app/v1.0.0", +}); + +export async function test() { + const client = new RestApiClient({ + baseUrl: "https://api.github.com", + headers: { + authorization: "token 123456789", + }, + }); + + expectType< + Promise<{ + status: number; + headers: Record; + data?: Record; + }> + >(client.request("")); + + const getUserResponse = await client.request("GET /user"); + expectType<{ + status: number; + headers: Record; + data?: Record; + }>(getUserResponse); + + client.request("GET /repos/{owner}/{repo}", { + owner: "gr2m", + repo: "javascript-plugin-architecture-with-typescript-definitions", + }); +} + +export async function testInstanceType(client: RestApiClient) { + client.request("GET /repos/{owner}/{repo}", { + owner: "gr2m", + repo: "javascript-plugin-architecture-with-typescript-definitions", + }); +} diff --git a/examples/rest-api-client-dts/request-plugin.d.ts b/examples/rest-api-client-dts/request-plugin.d.ts new file mode 100644 index 0000000..a7090c2 --- /dev/null +++ b/examples/rest-api-client-dts/request-plugin.d.ts @@ -0,0 +1,48 @@ +import { Base } from "../../index.js"; + +declare module "../.." { + namespace Base { + interface Options { + /** + * Base URL for all http requests + */ + baseUrl: string; + + /** + * Set a custom user agent. Defaults to "rest-api-client/1.0.0" + */ + userAgent?: string; + + /** + * Optional http request headers that will be set on all requsets + */ + headers?: { + authorization?: string; + accept?: string; + [key: string]: string | undefined; + }; + } + } +} + +interface Response { + status: number; + headers: Record; + data?: Record; +} + +interface Parameters { + headers?: Record; + [parameter: string]: unknown; +} + +interface RequestInterface { + (route: string, parameters?: Parameters): Promise; +} + +export declare function requestPlugin( + base: Base, + options: Base.Options +): { + request: RequestInterface; +}; diff --git a/examples/rest-api-client-dts/request-plugin.js b/examples/rest-api-client-dts/request-plugin.js new file mode 100644 index 0000000..4f32f33 --- /dev/null +++ b/examples/rest-api-client-dts/request-plugin.js @@ -0,0 +1,12 @@ +/** + * This example does not implement any logic, it's just meant as + * a reference for its types + * + * @param {Base} base + * @param {Base.Options} options + */ +export function requestPlugin(base, options) { + return { + async request(route, parameters) {}, + }; +} diff --git a/index.d.ts b/index.d.ts index 8594aab..68abbe6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -51,10 +51,7 @@ type RequiredIfRemaining = NonOptionalKeys< NowProvided ]; -type ConstructorRequiringOptionsIfNeeded< - Class extends ClassWithPlugins, - PredefinedOptions -> = { +type ConstructorRequiringOptionsIfNeeded = { defaults: PredefinedOptions; } & { new ( @@ -156,4 +153,29 @@ export declare class Base { constructor(options: TOptions); } + +type Extensions = { + defaults?: {}; + plugins?: Plugin[]; +}; + +type OrObject = T extends Extender ? {} : T; + +type ApplyPlugins = + Plugins extends Plugin[] + ? UnionToIntersection> + : {}; + +export type ExtendBaseWith< + BaseClass extends Base, + BaseExtensions extends Extensions +> = BaseClass & + ConstructorRequiringOptionsIfNeeded< + BaseClass & ApplyPlugins, + OrObject + > & + ApplyPlugins & { + defaults: OrObject; + }; + export {}; diff --git a/index.test-d.ts b/index.test-d.ts index 316550c..5c6f504 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,5 +1,5 @@ import { expectType } from "tsd"; -import { Base, Plugin } from "./index.js"; +import { Base, ExtendBaseWith, Plugin } from "./index.js"; import { fooPlugin } from "./plugins/foo/index.js"; import { barPlugin } from "./plugins/bar/index.js"; @@ -238,3 +238,48 @@ const baseWithManyChainedDefaultsAndPlugins = expectType(baseWithManyChainedDefaultsAndPlugins.foo); expectType(baseWithManyChainedDefaultsAndPlugins.bar); expectType(baseWithManyChainedDefaultsAndPlugins.getFooOption()); + +declare const RestApiClient: ExtendBaseWith< + Base, + { + defaults: { + defaultValue: string; + }; + plugins: [ + () => { pluginValueOne: number }, + () => { pluginValueTwo: boolean } + ]; + } +>; + +expectType(RestApiClient.defaults.defaultValue); + +// @ts-expect-error +RestApiClient.defaults.unexpected; + +expectType(RestApiClient.pluginValueOne); +expectType(RestApiClient.pluginValueTwo); + +// @ts-expect-error +RestApiClient.unexpected; + +declare const MoreDefaultRestApiClient: ExtendBaseWith< + typeof RestApiClient, + { + defaults: { + anotherDefaultValue: number; + }; + } +>; + +expectType(MoreDefaultRestApiClient.defaults.defaultValue); +expectType(MoreDefaultRestApiClient.defaults.anotherDefaultValue); + +declare const MorePluginRestApiClient: ExtendBaseWith< + typeof MoreDefaultRestApiClient, + { + plugins: [() => { morePluginValue: string[] }]; + } +>; + +expectType(MorePluginRestApiClient.morePluginValue); diff --git a/package.json b/package.json index 19c48da..03e5b1a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "npm run -s test:code && npm run -s test:typescript && npm run -s test:coverage && npm run -s lint", "test:code": "c8 uvu . '^(examples/.*/)?test.js$'", "test:coverage": "c8 check-coverage", - "test:typescript": "tsd && tsd examples/*" + "test:typescript": "tsd && for d in examples/* ; do tsd $d; done" }, "repository": "github:gr2m/javascript-plugin-architecture-with-typescript-definitions", "keywords": [