Say I have a class Base
with a constructor that requires one object argument with at least a version
key. The Base
class also has a static .defaults()
method which can set defaults for any options on the new constructor it returns.
In code, here is what I want
const test = new Base({
// `version` should be typed as required for the `Base` constructor
version: "1.2.3"
})
const MyBaseWithDefaults = Base.defaults({
// `version` should be typed as optional for `.defaults()`
foo: "bar"
})
const MyBaseWithVersion = Base.defaults({
version: "1.2.3",
foo: "bar"
})
const testWithDefaults = new MyBaseWithVersion({
// `version` should not be required to be set at all
})
// should be both typed as string
testWithDefaults.options.version
testWithDefaults.options.foo
Bonus question: is it possible to make the constructor options
argument optional if none of the keys are required because version
was set via .defaults()
?
Here is the code I have so far:
interface Options {
version: string;
[key: string]: unknown;
}
type Constructor<T> = new (...args: any[]) => T;
class Base<TOptions extends Options = Options> {
static defaults<
TDefaults extends Options,
S extends Constructor<Base<TDefaults>>
>(
this: S,
defaults: Partial<TDefaults>
): {
new (...args: any[]): {
options: TDefaults;
};
} & S {
return class extends this {
constructor(...args: any[]) {
super(Object.assign({}, defaults, args[0] || {}));
}
};
}
constructor(options: TOptions) {
this.options = options;
};
options: TOptions;
}
TypeScript playground
Update Jul 5
I should have mentioned that cascading defaults should work: Base.defaults({ one: "" }).defaults({ two: "" })
Let's walk through the requirements, and create a rough plan on how to implement them.
static .defaults()
method which can set defaults
The idea here would be to create a structure "above" the original constructor (namely, a child constructor). This structure would take default values, the rest of the values, combine them into a single object, and supply it to the original constructor. You came pretty close on this, actually. The types for this setup will definitely include generics, but if you are familiar with generics, this shouldn't be a problem at all.
make the constructor options
argument optional if none of the keys are required
This part of the question is actually trickier than you might think. To implement it, we'll have to use:
keyof
operator, when applied to an object without properties ({}
), produces never
("empty objects never have properties, so no keys as well");cascading defaults: Base.defaults({ one: "" }).defaults({ two: "" })
Because the result of .defaults()
is a child class (see above), it must have all the static members of its parent class, – including .defaults()
itself. So, from a pure JavaScript point of view, there's nothing new to implement, it should already work.
In TypeScript, however, we bump into a major problem. The .defaults()
method has to have access to the current class's defaults in order to produce types for the new defaults, combined from the old and new objects. E.g., given the case in the title, in order to get { one: string } & { two: string }
, we infer { two: string }
(new defaults) directly from the argument, and { one: string }
(old defaults) from someplace else. The best place for that would be class's type arguments (e.g., class Base<Defaults extends Options>
), but here's the deal: static members cannot reference class type parameters.
There's a workaround for this, however, it requires some semi-reasonable assumptions and a tiny bit of giving up on DRY. Most importantly though, you can't define the class declaratively anymore, you'll have to imperatively (a.k.a. "dynamically") create the first, "topmost" member of the inheritance chain (as in const Class = createClass();
), which I personally find rather unfortunate (despite it working pretty well).
With all that said, here's the result (and a playground for it; feel free to collapse/remove the <TRIAGE>
section):
type WithOptional<
OriginalObject extends object,
OptionalKey extends keyof OriginalObject = never,
> = Omit<OriginalObject, OptionalKey> & Partial<Pick<OriginalObject, OptionalKey>>;
type KeyOfByValue<Obj extends object, Value> = {
[Key in keyof Obj]: Obj[Key] extends Value ? Key : never;
}[keyof Obj];
type RequiredKey<Obj extends object> =
Exclude<KeyOfByValue<Obj, Exclude<Obj[keyof Obj], undefined>>, undefined>;
type OptionalParamIfEmpty<Obj extends object> =
RequiredKey<Obj> extends never ? [ Obj? ] : [ Obj ];
function createClass<
Options extends object,
OptionalKey extends keyof Options = never,
>(
defaults?: Pick<Options, OptionalKey>,
Parent: new(options: Options) => object = Object
) {
return class Class extends Parent {
static defaults<
OptionalKey2 extends keyof Options,
>(
additionalDefaults: Pick<Options, OptionalKey2>,
) {
const newDefaults = { ...defaults, ...additionalDefaults } as Options;
return createClass<Options, OptionalKey | OptionalKey2>(newDefaults, this);
}
public options: Options;
constructor(
...[explicit]: OptionalParamIfEmpty<WithOptional<Options, OptionalKey>>
) {
const options = { ...defaults, ...explicit } as Options;
super(options);
this.options = options;
}
}
}
Broken down:
createClass()
is supposed to be explicitly called only to create the first class in the inheritance chain (the subsequent child classes are created via .defaults()
calls).
createClass()
takes (all – optionally):
options
property;options
to pre-populate (the defaults
object, the first value argument of the function);Object
by default (common parent class for all objects).The Options
type argument in createClass()
is supposed to be provided explicitly.
The OptionalKey
type argument in createClass()
is inferred automatically from the type of provided defaults
object.
createClass()
returns a class with updated typings for constructor()
, – namely, the properties already present in defaults
are not required in explicit
anymore.
If all of the options
properties are optional, the explicit
argument itself becomes optional.
Since the whole definition of the returned class is placed inside of a function, its .defaults()
method has direct access to the above defaults
object via closure. This allows the method to only require additional defaults; the two sets of defaults are then merged in one object, and – together with the definition of the current class – passed to createClass(defaults, Parent)
to create a new child class with pre-populated defaults.
Since the returned class is required to call super()
somewhere in the constructor, for consistency, the parent class's constructor is enforced to take options: Options
as its first argument. It is totally possible, however, for a constructor to ignore this argument; that's why after the super()
call, the value of the options
property is set explicitly anyway.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With