diff --git a/README.md b/README.md index 83e325a..772656f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ const instance = new BaseWithOptions(); instance.options; // {foo: 'bar'} ``` +### Defaults + +TypeScript will not complain when chaining `.defaults()` calls endlessly: the static `.defaultOptions` property will be set correctly. However, when instantiating from a class with 4+ chained `.defaults()` 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. + ## Credit This plugin architecture was extracted from [`@octokit/core`](https://github.com/octokit/core.js). The implementation was made possible by help from [@karol-majewski](https://github.com/karol-majewski), [@dragomirtitian](https://github.com/dragomirtitian), and [StackOverflow user "hackape"](https://stackoverflow.com/a/58706699/206879). diff --git a/index.d.ts b/index.d.ts index 6489eb8..ad8fffc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,6 @@ -export namespace Base { +export declare namespace Base { interface Options { + version: string; [key: string]: unknown; } } @@ -30,33 +31,103 @@ declare type ReturnTypeOf = ? UnionToIntersection, void>> : never; +type ClassWithPlugins = Constructor & { + plugins: any[]; +}; + +type ConstructorRequiringVersion = { + defaultOptions: PredefinedOptions; +} & (PredefinedOptions extends { version: string } + ? { + new (options?: NowProvided): Class & { + options: NowProvided & PredefinedOptions; + }; + } + : { + new (options: Base.Options & NowProvided): Class & { + options: NowProvided & PredefinedOptions; + }; + }); + export declare class Base { static plugins: Plugin[]; + + /** + * Pass one or multiple plugin functions to extend the `Base` class. + * The instance of the new class will be extended with any keys returned by the passed plugins. + * Pass one argument per plugin function. + * + * ```js + * export function helloWorld() { + * return { + * helloWorld () { + * console.log('Hello world!'); + * } + * }; + * } + * + * const MyBase = Base.plugin(helloWorld); + * const base = new MyBase(); + * base.helloWorld(); // `base.helloWorld` is typed as function + * ``` + */ static plugin< - S extends Constructor & { - plugins: any[]; - }, - T1 extends Plugin, - T2 extends Plugin[] + Class extends ClassWithPlugins, + Plugins extends [Plugin, ...Plugin[]], >( - this: S, - plugin1: T1, - ...additionalPlugins: T2 - ): S & { - plugins: any[]; - } & Constructor & ReturnTypeOf>>; + this: Class, + ...plugins: Plugins, + ): Class & { + plugins: [...Class['plugins'], ...Plugins]; + } & Constructor>>; + + /** + * Set defaults for the constructor + * + * ```js + * const MyBase = Base.defaults({ version: '1.0.0', otherDefault: 'value' }); + * const base = new MyBase({ option: 'value' }); // `version` option is not required + * base.options // typed as `{ version: string, otherDefault: string, option: string }` + * ``` + * @remarks + * Ideally, we would want to make this infinitely recursive: allowing any number of + * .defaults({ ... }).defaults({ ... }).defaults({ ... }).defaults({ ... })... + * However, we don't see a clean way in today's TypeScript syntax to do so. + * We instead artificially limit accurate type inference to just three levels, + * since real users are not likely to go past that. + * @see https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/pull/57 + */ static defaults< - TDefaults extends Base.Options, - S extends Constructor> + PredefinedOptionsOne, + ClassOne extends Constructor> & ClassWithPlugins >( - this: S, - defaults: TDefaults - ): { - new (...args: any[]): { - options: TDefaults; - }; - } & S; - constructor(options?: TOptions); + this: ClassOne, + defaults: PredefinedOptionsOne + ): ConstructorRequiringVersion & { + defaults( + this: ClassTwo, + defaults: PredefinedOptionsTwo + ): ConstructorRequiringVersion< + ClassOne & ClassTwo, + PredefinedOptionsOne & PredefinedOptionsTwo + > & { + defaults( + this: ClassThree, + defaults: PredefinedOptionsThree + ): ConstructorRequiringVersion< + ClassOne & ClassTwo & ClassThree, + PredefinedOptionsOne & PredefinedOptionsTwo & PredefinedOptionsThree + > & ClassOne & ClassTwo & ClassThree; + } & ClassOne & ClassTwo; + } & ClassOne; + + static defaultOptions: {}; + + /** + * options passed to the constructor as constructor defaults + */ options: TOptions; + + constructor(options: TOptions); } export {}; diff --git a/index.js b/index.js index 0de99a3..ad02bc1 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ export class Base { ); }; } + static defaults(defaults) { return class extends this { constructor(options) { @@ -22,8 +23,12 @@ export class Base { ...options, }); } + + static defaultOptions = { ...defaults, ...this.defaultOptions }; }; } + static defaultOptions = {}; + static plugins = []; } diff --git a/index.test-d.ts b/index.test-d.ts index dc522d0..0d06ac9 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -6,30 +6,125 @@ import { barPlugin } from "./plugins/bar/index.js"; import { voidPlugin } from "./plugins/void/index.js"; import { withOptionsPlugin } from "./plugins/with-options"; -const base = new Base(); +const base = new Base({ + version: "1.2.3", +}); // @ts-expect-error unknown properties cannot be used, see #31 base.unknown; -const FooBase = Base.plugin(fooPlugin).defaults({ - default: "value", +const BaseWithEmptyDefaults = Base.defaults({ + // there should be no required options +}); + +// 'version' is missing and should still be required +// @ts-expect-error +new BaseWithEmptyDefaults() + +// 'version' is missing and should still be required +// @ts-expect-error +new BaseWithEmptyDefaults({}) + +const BaseLevelOne = Base.plugin(fooPlugin).defaults({ + defaultOne: "value", + version: "1.2.3", +}); + +// Because 'version' is already provided, this needs no argument +new BaseLevelOne(); +new BaseLevelOne({}); + +expectType<{ + defaultOne: string, + version: string, +}>(BaseLevelOne.defaultOptions); + +const baseLevelOne = new BaseLevelOne({ + optionOne: "value", }); -const fooBase = new FooBase({ - option: "value", + +expectType(baseLevelOne.options.defaultOne); +expectType(baseLevelOne.options.optionOne); +expectType(baseLevelOne.options.version); +// @ts-expect-error unknown properties cannot be used, see #31 +baseLevelOne.unknown; + +const BaseLevelTwo = BaseLevelOne.defaults({ + defaultTwo: 0, }); -expectType(fooBase.options.default); -expectType(fooBase.options.option); -expectType(fooBase.foo); +expectType<{ + defaultOne: string, + defaultTwo: number, + version: string, +}>({ ...BaseLevelTwo.defaultOptions }); + +// Because 'version' is already provided, this needs no argument +new BaseLevelTwo(); +new BaseLevelTwo({}); + +// 'version' may be overriden, though it's not necessary +new BaseLevelTwo({ + version: 'new version', +}); + +const baseLevelTwo = new BaseLevelTwo({ + optionTwo: true +}); + +expectType(baseLevelTwo.options.defaultTwo); +expectType(baseLevelTwo.options.defaultOne); +expectType(baseLevelTwo.options.optionTwo); +expectType(baseLevelTwo.options.version); +// @ts-expect-error unknown properties cannot be used, see #31 +baseLevelTwo.unknown; + +const BaseLevelThree = BaseLevelTwo.defaults({ + defaultThree: ['a', 'b', 'c'], +}); + +expectType<{ + defaultOne: string, + defaultTwo: number, + defaultThree: string[], + version: string, +}>({ ...BaseLevelThree.defaultOptions }); + +// Because 'version' is already provided, this needs no argument +new BaseLevelThree(); +new BaseLevelThree({}); + +// Previous settings may be overriden, though it's not necessary +new BaseLevelThree({ + optionOne: '', + optionTwo: false, + version: 'new version', +}); + +const baseLevelThree = new BaseLevelThree({ + optionThree: [0, 1, 2] +}); + +expectType(baseLevelThree.options.defaultOne); +expectType(baseLevelThree.options.defaultTwo); +expectType(baseLevelThree.options.defaultThree); +expectType(baseLevelThree.options.optionThree); +expectType(baseLevelThree.options.version); +// @ts-expect-error unknown properties cannot be used, see #31 +baseLevelThree.unknown; const BaseWithVoidPlugin = Base.plugin(voidPlugin); -const baseWithVoidPlugin = new BaseWithVoidPlugin(); +const baseWithVoidPlugin = new BaseWithVoidPlugin({ + version: "1.2.3", +}); // @ts-expect-error unknown properties cannot be used, see #31 baseWithVoidPlugin.unknown; const BaseWithFooAndBarPlugins = Base.plugin(barPlugin, fooPlugin); -const baseWithFooAndBarPlugins = new BaseWithFooAndBarPlugins(); +const baseWithFooAndBarPlugins = new BaseWithFooAndBarPlugins({ + version: "1.2.3", +}); expectType(baseWithFooAndBarPlugins.foo); expectType(baseWithFooAndBarPlugins.bar); @@ -42,7 +137,9 @@ const BaseWithVoidAndNonVoidPlugins = Base.plugin( voidPlugin, fooPlugin ); -const baseWithVoidAndNonVoidPlugins = new BaseWithVoidAndNonVoidPlugins(); +const baseWithVoidAndNonVoidPlugins = new BaseWithVoidAndNonVoidPlugins({ + version: "1.2.3", +}); expectType(baseWithVoidAndNonVoidPlugins.foo); expectType(baseWithVoidAndNonVoidPlugins.bar); @@ -51,6 +148,83 @@ expectType(baseWithVoidAndNonVoidPlugins.bar); baseWithVoidAndNonVoidPlugins.unknown; const BaseWithOptionsPlugin = Base.plugin(withOptionsPlugin); -const baseWithOptionsPlugin = new BaseWithOptionsPlugin(); +const baseWithOptionsPlugin = new BaseWithOptionsPlugin({ + version: "1.2.3", +}); expectType(baseWithOptionsPlugin.getFooOption()); + +// Test depth limits of `.defaults()` chaining +const BaseLevelFour = BaseLevelThree.defaults({ defaultFour: 4 }); + +expectType<{ + version: string; + defaultOne: string; + defaultTwo: number; + defaultThree: string[]; + defaultFour: number; +}>({ ...BaseLevelFour.defaultOptions }); + +const baseLevelFour = new BaseLevelFour(); + +// See the node on static defaults in index.d.ts for why defaultFour is missing +// .options from .defaults() is only supported until a depth of 4 +expectType<{ + version: string; + defaultOne: string; + defaultTwo: number; + defaultThree: string[]; +}>({ ...baseLevelFour.options }); + +expectType<{ + version: string; + defaultOne: string; + defaultTwo: number; + defaultThree: string[]; + defaultFour: number; + // @ts-expect-error - .options from .defaults() is only supported until a depth of 4 +}>({ ...baseLevelFour.options }); + +const BaseWithChainedDefaultsAndPlugins = Base + .defaults({ + defaultOne: "value", + }) + .plugin(fooPlugin) + .defaults({ + defaultTwo: 0, + }); + +const baseWithChainedDefaultsAndPlugins = + new BaseWithChainedDefaultsAndPlugins({ + version: "1.2.3", + }); + +expectType(baseWithChainedDefaultsAndPlugins.foo); + +const BaseWithManyChainedDefaultsAndPlugins = Base.defaults({ + defaultOne: "value", +}) + .plugin(fooPlugin, barPlugin, voidPlugin) + .defaults({ + defaultTwo: 0, + }) + .plugin(withOptionsPlugin) + .defaults({ + defaultThree: ["a", "b", "c"], + }); + +expectType<{ + defaultOne: string; + defaultTwo: number; + defaultThree: string[]; +}>({ ...BaseWithManyChainedDefaultsAndPlugins.defaultOptions }); + +const baseWithManyChainedDefaultsAndPlugins = + new BaseWithManyChainedDefaultsAndPlugins({ + version: "1.2.3", + foo: "bar", + }); + +expectType(baseWithManyChainedDefaultsAndPlugins.foo); +expectType(baseWithManyChainedDefaultsAndPlugins.bar); +expectType(baseWithManyChainedDefaultsAndPlugins.getFooOption()); diff --git a/test/base.test.js b/test/base.test.js index 4bbc9e2..a250839 100644 --- a/test/base.test.js +++ b/test/base.test.js @@ -33,6 +33,7 @@ test(".defaults({foo: 'bar'})", () => { const BaseWithDefaults = Base.defaults({ foo: "bar" }); const defaultsTest = new BaseWithDefaults(); const mergedOptionsTest = new BaseWithDefaults({ baz: "daz" }); + assert.equal(BaseWithDefaults.defaultOptions, { foo: "bar" }); assert.equal(defaultsTest.options, { foo: "bar" }); assert.equal(mergedOptionsTest.options, { foo: "bar", baz: "daz" }); }); @@ -44,6 +45,15 @@ test(".defaults({one: 1}).defaults({two: 2})", () => { assert.equal(mergedOptionsTest.options, { one: 1, two: 2, three: 3 }); }); +test(".defaults({foo: 'bar', baz: 'daz' })", () => { + const BaseWithDefaults = Base.defaults({ foo: "bar" }).defaults({ baz: "daz" }); + const defaultsTest = new BaseWithDefaults(); + const mergedOptionsTest = new BaseWithDefaults({ faz: "boo" }); + assert.equal(BaseWithDefaults.defaultOptions, { foo: "bar", baz: "daz" }); + assert.equal(defaultsTest.options, { foo: "bar", baz: "daz" }); + assert.equal(mergedOptionsTest.options, { foo: "bar", baz: "daz", faz: "boo" }); +}); + test(".plugin().defaults()", () => { const BaseWithPluginAndDefaults = Base.plugin(fooPlugin).defaults({ baz: "daz",