Skip to content

Commit 04f2454

Browse files
authored
feat: derife constructor options from chainable Base.defaults() calls (#57)
1 parent 50341d7 commit 04f2454

File tree

5 files changed

+298
-34
lines changed

5 files changed

+298
-34
lines changed

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ const instance = new BaseWithOptions();
4444
instance.options; // {foo: 'bar'}
4545
```
4646

47+
### Defaults
48+
49+
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.
50+
4751
## Credit
4852

4953
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).

index.d.ts

+93-22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
export namespace Base {
1+
export declare namespace Base {
22
interface Options {
3+
version: string;
34
[key: string]: unknown;
45
}
56
}
@@ -30,33 +31,103 @@ declare type ReturnTypeOf<T extends AnyFunction | AnyFunction[]> =
3031
? UnionToIntersection<Exclude<ReturnType<T[number]>, void>>
3132
: never;
3233

34+
type ClassWithPlugins = Constructor<any> & {
35+
plugins: any[];
36+
};
37+
38+
type ConstructorRequiringVersion<Class extends ClassWithPlugins, PredefinedOptions> = {
39+
defaultOptions: PredefinedOptions;
40+
} & (PredefinedOptions extends { version: string }
41+
? {
42+
new <NowProvided>(options?: NowProvided): Class & {
43+
options: NowProvided & PredefinedOptions;
44+
};
45+
}
46+
: {
47+
new <NowProvided>(options: Base.Options & NowProvided): Class & {
48+
options: NowProvided & PredefinedOptions;
49+
};
50+
});
51+
3352
export declare class Base<TOptions extends Base.Options = Base.Options> {
3453
static plugins: Plugin[];
54+
55+
/**
56+
* Pass one or multiple plugin functions to extend the `Base` class.
57+
* The instance of the new class will be extended with any keys returned by the passed plugins.
58+
* Pass one argument per plugin function.
59+
*
60+
* ```js
61+
* export function helloWorld() {
62+
* return {
63+
* helloWorld () {
64+
* console.log('Hello world!');
65+
* }
66+
* };
67+
* }
68+
*
69+
* const MyBase = Base.plugin(helloWorld);
70+
* const base = new MyBase();
71+
* base.helloWorld(); // `base.helloWorld` is typed as function
72+
* ```
73+
*/
3574
static plugin<
36-
S extends Constructor<any> & {
37-
plugins: any[];
38-
},
39-
T1 extends Plugin,
40-
T2 extends Plugin[]
75+
Class extends ClassWithPlugins,
76+
Plugins extends [Plugin, ...Plugin[]],
4177
>(
42-
this: S,
43-
plugin1: T1,
44-
...additionalPlugins: T2
45-
): S & {
46-
plugins: any[];
47-
} & Constructor<UnionToIntersection<ReturnTypeOf<T1> & ReturnTypeOf<T2>>>;
78+
this: Class,
79+
...plugins: Plugins,
80+
): Class & {
81+
plugins: [...Class['plugins'], ...Plugins];
82+
} & Constructor<UnionToIntersection<ReturnTypeOf<Plugins>>>;
83+
84+
/**
85+
* Set defaults for the constructor
86+
*
87+
* ```js
88+
* const MyBase = Base.defaults({ version: '1.0.0', otherDefault: 'value' });
89+
* const base = new MyBase({ option: 'value' }); // `version` option is not required
90+
* base.options // typed as `{ version: string, otherDefault: string, option: string }`
91+
* ```
92+
* @remarks
93+
* Ideally, we would want to make this infinitely recursive: allowing any number of
94+
* .defaults({ ... }).defaults({ ... }).defaults({ ... }).defaults({ ... })...
95+
* However, we don't see a clean way in today's TypeScript syntax to do so.
96+
* We instead artificially limit accurate type inference to just three levels,
97+
* since real users are not likely to go past that.
98+
* @see https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/pull/57
99+
*/
48100
static defaults<
49-
TDefaults extends Base.Options,
50-
S extends Constructor<Base<TDefaults>>
101+
PredefinedOptionsOne,
102+
ClassOne extends Constructor<Base<Base.Options & PredefinedOptionsOne>> & ClassWithPlugins
51103
>(
52-
this: S,
53-
defaults: TDefaults
54-
): {
55-
new (...args: any[]): {
56-
options: TDefaults;
57-
};
58-
} & S;
59-
constructor(options?: TOptions);
104+
this: ClassOne,
105+
defaults: PredefinedOptionsOne
106+
): ConstructorRequiringVersion<ClassOne, PredefinedOptionsOne> & {
107+
defaults<ClassTwo, PredefinedOptionsTwo>(
108+
this: ClassTwo,
109+
defaults: PredefinedOptionsTwo
110+
): ConstructorRequiringVersion<
111+
ClassOne & ClassTwo,
112+
PredefinedOptionsOne & PredefinedOptionsTwo
113+
> & {
114+
defaults<ClassThree, PredefinedOptionsThree>(
115+
this: ClassThree,
116+
defaults: PredefinedOptionsThree
117+
): ConstructorRequiringVersion<
118+
ClassOne & ClassTwo & ClassThree,
119+
PredefinedOptionsOne & PredefinedOptionsTwo & PredefinedOptionsThree
120+
> & ClassOne & ClassTwo & ClassThree;
121+
} & ClassOne & ClassTwo;
122+
} & ClassOne;
123+
124+
static defaultOptions: {};
125+
126+
/**
127+
* options passed to the constructor as constructor defaults
128+
*/
60129
options: TOptions;
130+
131+
constructor(options: TOptions);
61132
}
62133
export {};

index.js

+5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class Base {
1414
);
1515
};
1616
}
17+
1718
static defaults(defaults) {
1819
return class extends this {
1920
constructor(options) {
@@ -22,8 +23,12 @@ export class Base {
2223
...options,
2324
});
2425
}
26+
27+
static defaultOptions = { ...defaults, ...this.defaultOptions };
2528
};
2629
}
2730

31+
static defaultOptions = {};
32+
2833
static plugins = [];
2934
}

index.test-d.ts

+186-12
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,125 @@ import { barPlugin } from "./plugins/bar/index.js";
66
import { voidPlugin } from "./plugins/void/index.js";
77
import { withOptionsPlugin } from "./plugins/with-options";
88

9-
const base = new Base();
9+
const base = new Base({
10+
version: "1.2.3",
11+
});
1012

1113
// @ts-expect-error unknown properties cannot be used, see #31
1214
base.unknown;
1315

14-
const FooBase = Base.plugin(fooPlugin).defaults({
15-
default: "value",
16+
const BaseWithEmptyDefaults = Base.defaults({
17+
// there should be no required options
18+
});
19+
20+
// 'version' is missing and should still be required
21+
// @ts-expect-error
22+
new BaseWithEmptyDefaults()
23+
24+
// 'version' is missing and should still be required
25+
// @ts-expect-error
26+
new BaseWithEmptyDefaults({})
27+
28+
const BaseLevelOne = Base.plugin(fooPlugin).defaults({
29+
defaultOne: "value",
30+
version: "1.2.3",
31+
});
32+
33+
// Because 'version' is already provided, this needs no argument
34+
new BaseLevelOne();
35+
new BaseLevelOne({});
36+
37+
expectType<{
38+
defaultOne: string,
39+
version: string,
40+
}>(BaseLevelOne.defaultOptions);
41+
42+
const baseLevelOne = new BaseLevelOne({
43+
optionOne: "value",
1644
});
17-
const fooBase = new FooBase({
18-
option: "value",
45+
46+
expectType<string>(baseLevelOne.options.defaultOne);
47+
expectType<string>(baseLevelOne.options.optionOne);
48+
expectType<string>(baseLevelOne.options.version);
49+
// @ts-expect-error unknown properties cannot be used, see #31
50+
baseLevelOne.unknown;
51+
52+
const BaseLevelTwo = BaseLevelOne.defaults({
53+
defaultTwo: 0,
1954
});
2055

21-
expectType<string>(fooBase.options.default);
22-
expectType<string>(fooBase.options.option);
23-
expectType<string>(fooBase.foo);
56+
expectType<{
57+
defaultOne: string,
58+
defaultTwo: number,
59+
version: string,
60+
}>({ ...BaseLevelTwo.defaultOptions });
61+
62+
// Because 'version' is already provided, this needs no argument
63+
new BaseLevelTwo();
64+
new BaseLevelTwo({});
65+
66+
// 'version' may be overriden, though it's not necessary
67+
new BaseLevelTwo({
68+
version: 'new version',
69+
});
70+
71+
const baseLevelTwo = new BaseLevelTwo({
72+
optionTwo: true
73+
});
74+
75+
expectType<number>(baseLevelTwo.options.defaultTwo);
76+
expectType<string>(baseLevelTwo.options.defaultOne);
77+
expectType<boolean>(baseLevelTwo.options.optionTwo);
78+
expectType<string>(baseLevelTwo.options.version);
79+
// @ts-expect-error unknown properties cannot be used, see #31
80+
baseLevelTwo.unknown;
81+
82+
const BaseLevelThree = BaseLevelTwo.defaults({
83+
defaultThree: ['a', 'b', 'c'],
84+
});
85+
86+
expectType<{
87+
defaultOne: string,
88+
defaultTwo: number,
89+
defaultThree: string[],
90+
version: string,
91+
}>({ ...BaseLevelThree.defaultOptions });
92+
93+
// Because 'version' is already provided, this needs no argument
94+
new BaseLevelThree();
95+
new BaseLevelThree({});
96+
97+
// Previous settings may be overriden, though it's not necessary
98+
new BaseLevelThree({
99+
optionOne: '',
100+
optionTwo: false,
101+
version: 'new version',
102+
});
103+
104+
const baseLevelThree = new BaseLevelThree({
105+
optionThree: [0, 1, 2]
106+
});
107+
108+
expectType<string>(baseLevelThree.options.defaultOne);
109+
expectType<number>(baseLevelThree.options.defaultTwo);
110+
expectType<string[]>(baseLevelThree.options.defaultThree);
111+
expectType<number[]>(baseLevelThree.options.optionThree);
112+
expectType<string>(baseLevelThree.options.version);
113+
// @ts-expect-error unknown properties cannot be used, see #31
114+
baseLevelThree.unknown;
24115

25116
const BaseWithVoidPlugin = Base.plugin(voidPlugin);
26-
const baseWithVoidPlugin = new BaseWithVoidPlugin();
117+
const baseWithVoidPlugin = new BaseWithVoidPlugin({
118+
version: "1.2.3",
119+
});
27120

28121
// @ts-expect-error unknown properties cannot be used, see #31
29122
baseWithVoidPlugin.unknown;
30123

31124
const BaseWithFooAndBarPlugins = Base.plugin(barPlugin, fooPlugin);
32-
const baseWithFooAndBarPlugins = new BaseWithFooAndBarPlugins();
125+
const baseWithFooAndBarPlugins = new BaseWithFooAndBarPlugins({
126+
version: "1.2.3",
127+
});
33128

34129
expectType<string>(baseWithFooAndBarPlugins.foo);
35130
expectType<string>(baseWithFooAndBarPlugins.bar);
@@ -42,7 +137,9 @@ const BaseWithVoidAndNonVoidPlugins = Base.plugin(
42137
voidPlugin,
43138
fooPlugin
44139
);
45-
const baseWithVoidAndNonVoidPlugins = new BaseWithVoidAndNonVoidPlugins();
140+
const baseWithVoidAndNonVoidPlugins = new BaseWithVoidAndNonVoidPlugins({
141+
version: "1.2.3",
142+
});
46143

47144
expectType<string>(baseWithVoidAndNonVoidPlugins.foo);
48145
expectType<string>(baseWithVoidAndNonVoidPlugins.bar);
@@ -51,6 +148,83 @@ expectType<string>(baseWithVoidAndNonVoidPlugins.bar);
51148
baseWithVoidAndNonVoidPlugins.unknown;
52149

53150
const BaseWithOptionsPlugin = Base.plugin(withOptionsPlugin);
54-
const baseWithOptionsPlugin = new BaseWithOptionsPlugin();
151+
const baseWithOptionsPlugin = new BaseWithOptionsPlugin({
152+
version: "1.2.3",
153+
});
55154

56155
expectType<string>(baseWithOptionsPlugin.getFooOption());
156+
157+
// Test depth limits of `.defaults()` chaining
158+
const BaseLevelFour = BaseLevelThree.defaults({ defaultFour: 4 });
159+
160+
expectType<{
161+
version: string;
162+
defaultOne: string;
163+
defaultTwo: number;
164+
defaultThree: string[];
165+
defaultFour: number;
166+
}>({ ...BaseLevelFour.defaultOptions });
167+
168+
const baseLevelFour = new BaseLevelFour();
169+
170+
// See the node on static defaults in index.d.ts for why defaultFour is missing
171+
// .options from .defaults() is only supported until a depth of 4
172+
expectType<{
173+
version: string;
174+
defaultOne: string;
175+
defaultTwo: number;
176+
defaultThree: string[];
177+
}>({ ...baseLevelFour.options });
178+
179+
expectType<{
180+
version: string;
181+
defaultOne: string;
182+
defaultTwo: number;
183+
defaultThree: string[];
184+
defaultFour: number;
185+
// @ts-expect-error - .options from .defaults() is only supported until a depth of 4
186+
}>({ ...baseLevelFour.options });
187+
188+
const BaseWithChainedDefaultsAndPlugins = Base
189+
.defaults({
190+
defaultOne: "value",
191+
})
192+
.plugin(fooPlugin)
193+
.defaults({
194+
defaultTwo: 0,
195+
});
196+
197+
const baseWithChainedDefaultsAndPlugins =
198+
new BaseWithChainedDefaultsAndPlugins({
199+
version: "1.2.3",
200+
});
201+
202+
expectType<string>(baseWithChainedDefaultsAndPlugins.foo);
203+
204+
const BaseWithManyChainedDefaultsAndPlugins = Base.defaults({
205+
defaultOne: "value",
206+
})
207+
.plugin(fooPlugin, barPlugin, voidPlugin)
208+
.defaults({
209+
defaultTwo: 0,
210+
})
211+
.plugin(withOptionsPlugin)
212+
.defaults({
213+
defaultThree: ["a", "b", "c"],
214+
});
215+
216+
expectType<{
217+
defaultOne: string;
218+
defaultTwo: number;
219+
defaultThree: string[];
220+
}>({ ...BaseWithManyChainedDefaultsAndPlugins.defaultOptions });
221+
222+
const baseWithManyChainedDefaultsAndPlugins =
223+
new BaseWithManyChainedDefaultsAndPlugins({
224+
version: "1.2.3",
225+
foo: "bar",
226+
});
227+
228+
expectType<string>(baseWithManyChainedDefaultsAndPlugins.foo);
229+
expectType<string>(baseWithManyChainedDefaultsAndPlugins.bar);
230+
expectType<string>(baseWithManyChainedDefaultsAndPlugins.getFooOption());

0 commit comments

Comments
 (0)