From 352c1aeaa42d8d716f2ed533f1eeeba14db1b3b5 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Tue, 20 Jul 2021 15:42:43 -0700 Subject: [PATCH 1/3] build(scripts): fix `test:typescript` script to run `tsd` in all `examples/*` repositories --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": [ From d5362261497164a5d1c8de10623adb6051916eb0 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Tue, 20 Jul 2021 15:42:59 -0700 Subject: [PATCH 2/3] feat: `RestApiClient` example --- examples/rest-api-client-dts/README.md | 20 ++++++++ examples/rest-api-client-dts/index.d.ts | 9 ++++ examples/rest-api-client-dts/index.js | 5 ++ examples/rest-api-client-dts/index.test-d.ts | 45 +++++++++++++++++ .../rest-api-client-dts/request-plugin.d.ts | 48 +++++++++++++++++++ .../rest-api-client-dts/request-plugin.js | 12 +++++ 6 files changed, 139 insertions(+) create mode 100644 examples/rest-api-client-dts/README.md create mode 100644 examples/rest-api-client-dts/index.d.ts create mode 100644 examples/rest-api-client-dts/index.js create mode 100644 examples/rest-api-client-dts/index.test-d.ts create mode 100644 examples/rest-api-client-dts/request-plugin.d.ts create mode 100644 examples/rest-api-client-dts/request-plugin.js 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..6f338e1 --- /dev/null +++ b/examples/rest-api-client-dts/index.d.ts @@ -0,0 +1,9 @@ +import { Base } from "../../index.js"; + +import { requestPlugin } from "./request-plugin.js"; + +declare type Constructor = new (...args: any[]) => T; + +export class RestApiClient extends Base { + request: ReturnType["request"]; +} 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..07cee73 --- /dev/null +++ b/examples/rest-api-client-dts/index.test-d.ts @@ -0,0 +1,45 @@ +import { expectType } from "tsd"; + +import { RestApiClient } from "./index.js"; + +// @ts-expect-error - An argument for 'options' was not provided +new RestApiClient(); + +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", + }); +} 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) {}, + }; +} From 1ae82cade3f1a706fb628c877a2216efbb4aef83 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Thu, 5 Aug 2021 16:08:21 -0400 Subject: [PATCH 3/3] feat(typescript): `ExtendBaseWith` Generic type (#69) --- README.md | 34 ++++++++++++++ examples/rest-api-client-dts/index.d.ts | 16 ++++--- examples/rest-api-client-dts/index.test-d.ts | 11 ++++- index.d.ts | 30 +++++++++++-- index.test-d.ts | 47 +++++++++++++++++++- 5 files changed, 127 insertions(+), 11 deletions(-) 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/index.d.ts b/examples/rest-api-client-dts/index.d.ts index 6f338e1..d2c67d5 100644 --- a/examples/rest-api-client-dts/index.d.ts +++ b/examples/rest-api-client-dts/index.d.ts @@ -1,9 +1,15 @@ -import { Base } from "../../index.js"; +import { Base, ExtendBaseWith } from "../../index.js"; import { requestPlugin } from "./request-plugin.js"; -declare type Constructor = new (...args: any[]) => T; +export const RestApiClient: ExtendBaseWith< + Base, + { + defaults: { + userAgent: string; + }; + plugins: [typeof requestPlugin]; + } +>; -export class RestApiClient extends Base { - request: ReturnType["request"]; -} +export type RestApiClient = typeof RestApiClient; diff --git a/examples/rest-api-client-dts/index.test-d.ts b/examples/rest-api-client-dts/index.test-d.ts index 07cee73..8bffe51 100644 --- a/examples/rest-api-client-dts/index.test-d.ts +++ b/examples/rest-api-client-dts/index.test-d.ts @@ -3,7 +3,9 @@ import { expectType } from "tsd"; import { RestApiClient } from "./index.js"; // @ts-expect-error - An argument for 'options' was not provided -new RestApiClient(); +let value: typeof RestApiClient = new RestApiClient(); + +expectType<{ userAgent: string }>(value.defaults); expectType<{ userAgent: string }>(RestApiClient.defaults); @@ -43,3 +45,10 @@ export async function test() { 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/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);