diff --git a/examples/required-options/README.md b/examples/required-options/README.md new file mode 100644 index 0000000..4bdcc5e --- /dev/null +++ b/examples/required-options/README.md @@ -0,0 +1,29 @@ +# required options Example + +`Base` has no required options by default, so the following code has no type errors. + +```js +import { Base } from "javascript-plugin-architecture-with-typescript-definitions"; + +const base1 = new Base(); +const base2 = new Base({}); +``` + +But required options can be added by extending the `Base.Options` interface. + +```ts +declare module "javascript-plugin-architecture-with-typescript-definitions" { + namespace Base { + interface Options { + myRequiredUserOption: string; + } + } +} +``` + +With that extension, the same code will have a type error + +```ts +// TS Error: Property 'myRequiredUserOption' is missing in type '{}' but required in type 'Options' +const base = new Base({}); +``` diff --git a/examples/required-options/index.d.ts b/examples/required-options/index.d.ts new file mode 100644 index 0000000..6d21740 --- /dev/null +++ b/examples/required-options/index.d.ts @@ -0,0 +1,11 @@ +import { Base } from "../../index.js"; + +declare module "../.." { + namespace Base { + interface Options { + myRequiredUserOption: string; + } + } +} + +export class MyBase extends Base {} diff --git a/examples/required-options/index.js b/examples/required-options/index.js new file mode 100644 index 0000000..bd842e0 --- /dev/null +++ b/examples/required-options/index.js @@ -0,0 +1,13 @@ +import { Base } from "../../index.js"; + +/** + * @param {Base} base + * @param {Base.Options} options + */ +function pluginRequiringOption(base, options) { + if (typeof options.myRequiredUserOption !== "string") { + throw new Error('Required option "myRequiredUserOption" missing'); + } +} + +export const MyBase = Base.plugin(pluginRequiringOption); diff --git a/examples/required-options/index.test-d.ts b/examples/required-options/index.test-d.ts new file mode 100644 index 0000000..cf622d9 --- /dev/null +++ b/examples/required-options/index.test-d.ts @@ -0,0 +1,17 @@ +import { MyBase } from "./index.js"; + +// @ts-expect-error - An argument for 'options' was not provided +new MyBase(); + +// @ts-expect-error - Type '{}' is missing the following properties from type 'Options': myRequiredUserOption +new MyBase({}); + +new MyBase({ + myRequiredUserOption: "", +}); + +const MyBaseWithDefaults = MyBase.defaults({ + myRequiredUserOption: "", +}); + +new MyBaseWithDefaults(); diff --git a/examples/required-options/test.js b/examples/required-options/test.js new file mode 100644 index 0000000..842885a --- /dev/null +++ b/examples/required-options/test.js @@ -0,0 +1,18 @@ +import { test } from "uvu"; +import * as assert from "uvu/assert"; + +import { MyBase } from "./index.js"; + +test("new MyBase()", () => { + assert.throws(() => new MyBase()); +}); + +test("new MyBase({})", () => { + assert.throws(() => new MyBase({})); +}); + +test('new MyBase({ myRequiredUserOption: ""})', () => { + assert.not.throws(() => new MyBase({ myRequiredUserOption: "" })); +}); + +test.run(); diff --git a/index.d.ts b/index.d.ts index ad8fffc..3ece520 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,8 +1,5 @@ export declare namespace Base { - interface Options { - version: string; - [key: string]: unknown; - } + interface Options { } } declare type ApiExtension = { @@ -26,28 +23,36 @@ declare type UnionToIntersection = ( declare type AnyFunction = (...args: any) => any; declare type ReturnTypeOf = T extends AnyFunction - ? ReturnType - : T extends AnyFunction[] - ? UnionToIntersection, void>> - : never; + ? ReturnType + : T extends AnyFunction[] + ? UnionToIntersection, void>> + : never; type ClassWithPlugins = Constructor & { - plugins: any[]; + plugins: Plugin[]; }; +type RemainingRequirements = + keyof PredefinedOptions extends never + ? Base.Options + : Omit + +type NonOptionalKeys = { + [K in keyof Obj]: {} extends Pick ? undefined : K; +}[keyof Obj]; + +type RequiredIfRemaining = + NonOptionalKeys> extends undefined + ? [(Partial & NowProvided)?] + : [Partial & RemainingRequirements & NowProvided]; + type ConstructorRequiringVersion = { defaultOptions: PredefinedOptions; -} & (PredefinedOptions extends { version: string } - ? { - new (options?: NowProvided): Class & { - options: NowProvided & PredefinedOptions; - }; - } - : { - new (options: Base.Options & NowProvided): Class & { - options: NowProvided & PredefinedOptions; - }; - }); +} & { + new (...options: RequiredIfRemaining): Class & { + options: NowProvided & PredefinedOptions; + }; +}; export declare class Base { static plugins: Plugin[]; @@ -74,9 +79,9 @@ export declare class Base { static plugin< Class extends ClassWithPlugins, Plugins extends [Plugin, ...Plugin[]], - >( - this: Class, - ...plugins: Plugins, + >( + this: Class, + ...plugins: Plugins, ): Class & { plugins: [...Class['plugins'], ...Plugins]; } & Constructor>>; @@ -130,4 +135,4 @@ export declare class Base { constructor(options: TOptions); } -export {}; +export { }; diff --git a/index.test-d.ts b/index.test-d.ts index 0d06ac9..2efc299 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -6,37 +6,45 @@ import { barPlugin } from "./plugins/bar/index.js"; import { voidPlugin } from "./plugins/void/index.js"; import { withOptionsPlugin } from "./plugins/with-options"; -const base = new Base({ - version: "1.2.3", +declare module "./index.js" { + namespace Base { + interface Options { + required: string; + } + } +} + +const baseSatisfied = new Base({ + required: "1.2.3", }); // @ts-expect-error unknown properties cannot be used, see #31 -base.unknown; +baseSatisfied.unknown; const BaseWithEmptyDefaults = Base.defaults({ // there should be no required options }); -// 'version' is missing and should still be required +// 'required' is missing and should still be required // @ts-expect-error -new BaseWithEmptyDefaults() +new BaseWithEmptyDefaults(); -// 'version' is missing and should still be required +// 'required' is missing and should still be required // @ts-expect-error -new BaseWithEmptyDefaults({}) +new BaseWithEmptyDefaults({}); const BaseLevelOne = Base.plugin(fooPlugin).defaults({ defaultOne: "value", - version: "1.2.3", + required: "1.2.3", }); -// Because 'version' is already provided, this needs no argument +// Because 'required' is already provided, this needs no argument new BaseLevelOne(); new BaseLevelOne({}); expectType<{ - defaultOne: string, - version: string, + defaultOne: string; + required: string; }>(BaseLevelOne.defaultOptions); const baseLevelOne = new BaseLevelOne({ @@ -45,7 +53,7 @@ const baseLevelOne = new BaseLevelOne({ expectType(baseLevelOne.options.defaultOne); expectType(baseLevelOne.options.optionOne); -expectType(baseLevelOne.options.version); +expectType(baseLevelOne.options.required); // @ts-expect-error unknown properties cannot be used, see #31 baseLevelOne.unknown; @@ -54,68 +62,68 @@ const BaseLevelTwo = BaseLevelOne.defaults({ }); expectType<{ - defaultOne: string, - defaultTwo: number, - version: string, + defaultOne: string; + defaultTwo: number; + required: string; }>({ ...BaseLevelTwo.defaultOptions }); -// Because 'version' is already provided, this needs no argument +// Because 'required' is already provided, this needs no argument new BaseLevelTwo(); new BaseLevelTwo({}); -// 'version' may be overriden, though it's not necessary +// 'required' may be overriden, though it's not necessary new BaseLevelTwo({ - version: 'new version', + required: "new required", }); const baseLevelTwo = new BaseLevelTwo({ - optionTwo: true + optionTwo: true, }); expectType(baseLevelTwo.options.defaultTwo); expectType(baseLevelTwo.options.defaultOne); expectType(baseLevelTwo.options.optionTwo); -expectType(baseLevelTwo.options.version); +expectType(baseLevelTwo.options.required); // @ts-expect-error unknown properties cannot be used, see #31 baseLevelTwo.unknown; const BaseLevelThree = BaseLevelTwo.defaults({ - defaultThree: ['a', 'b', 'c'], + defaultThree: ["a", "b", "c"], }); expectType<{ - defaultOne: string, - defaultTwo: number, - defaultThree: string[], - version: string, + defaultOne: string; + defaultTwo: number; + defaultThree: string[]; + required: string; }>({ ...BaseLevelThree.defaultOptions }); -// Because 'version' is already provided, this needs no argument +// Because 'required' is already provided, this needs no argument new BaseLevelThree(); new BaseLevelThree({}); // Previous settings may be overriden, though it's not necessary new BaseLevelThree({ - optionOne: '', + optionOne: "", optionTwo: false, - version: 'new version', + required: "new required", }); const baseLevelThree = new BaseLevelThree({ - optionThree: [0, 1, 2] + optionThree: [0, 1, 2], }); expectType(baseLevelThree.options.defaultOne); expectType(baseLevelThree.options.defaultTwo); expectType(baseLevelThree.options.defaultThree); expectType(baseLevelThree.options.optionThree); -expectType(baseLevelThree.options.version); +expectType(baseLevelThree.options.required); // @ts-expect-error unknown properties cannot be used, see #31 baseLevelThree.unknown; const BaseWithVoidPlugin = Base.plugin(voidPlugin); const baseWithVoidPlugin = new BaseWithVoidPlugin({ - version: "1.2.3", + required: "1.2.3", }); // @ts-expect-error unknown properties cannot be used, see #31 @@ -123,7 +131,7 @@ baseWithVoidPlugin.unknown; const BaseWithFooAndBarPlugins = Base.plugin(barPlugin, fooPlugin); const baseWithFooAndBarPlugins = new BaseWithFooAndBarPlugins({ - version: "1.2.3", + required: "1.2.3", }); expectType(baseWithFooAndBarPlugins.foo); @@ -138,7 +146,7 @@ const BaseWithVoidAndNonVoidPlugins = Base.plugin( fooPlugin ); const baseWithVoidAndNonVoidPlugins = new BaseWithVoidAndNonVoidPlugins({ - version: "1.2.3", + required: "1.2.3", }); expectType(baseWithVoidAndNonVoidPlugins.foo); @@ -149,7 +157,7 @@ baseWithVoidAndNonVoidPlugins.unknown; const BaseWithOptionsPlugin = Base.plugin(withOptionsPlugin); const baseWithOptionsPlugin = new BaseWithOptionsPlugin({ - version: "1.2.3", + required: "1.2.3", }); expectType(baseWithOptionsPlugin.getFooOption()); @@ -158,7 +166,7 @@ expectType(baseWithOptionsPlugin.getFooOption()); const BaseLevelFour = BaseLevelThree.defaults({ defaultFour: 4 }); expectType<{ - version: string; + required: string; defaultOne: string; defaultTwo: number; defaultThree: string[]; @@ -170,14 +178,14 @@ 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; + required: string; defaultOne: string; defaultTwo: number; defaultThree: string[]; }>({ ...baseLevelFour.options }); expectType<{ - version: string; + required: string; defaultOne: string; defaultTwo: number; defaultThree: string[]; @@ -185,19 +193,19 @@ expectType<{ // @ts-expect-error - .options from .defaults() is only supported until a depth of 4 }>({ ...baseLevelFour.options }); -const BaseWithChainedDefaultsAndPlugins = Base - .defaults({ - defaultOne: "value", - }) +const BaseWithChainedDefaultsAndPlugins = Base.defaults({ + defaultOne: "value", +}) .plugin(fooPlugin) .defaults({ defaultTwo: 0, }); -const baseWithChainedDefaultsAndPlugins = - new BaseWithChainedDefaultsAndPlugins({ - version: "1.2.3", - }); +const baseWithChainedDefaultsAndPlugins = new BaseWithChainedDefaultsAndPlugins( + { + required: "1.2.3", + } +); expectType(baseWithChainedDefaultsAndPlugins.foo); @@ -221,7 +229,7 @@ expectType<{ const baseWithManyChainedDefaultsAndPlugins = new BaseWithManyChainedDefaultsAndPlugins({ - version: "1.2.3", + required: "1.2.3", foo: "bar", }); diff --git a/package.json b/package.json index 0fe2d12..abae84e 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "types": "./index.d.ts", "scripts": { "test": "npm run -s test:code && npm run -s test:typescript && npm run -s test:coverage", - "test:code": "c8 node test.js", + "test:code": "c8 uvu . '^(examples/.*/)?test.js$'", "test:coverage": "c8 check-coverage", - "test:typescript": "tsd" + "test:typescript": "tsd && tsd examples/*" }, "repository": "github:gr2m/javascript-plugin-architecture-with-typescript-definitions", "keywords": [