Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make required constructor options optional when they were set using MyClass.defaults(options)

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: "" })

like image 585
Gregor Avatar asked Oct 20 '25 09:10

Gregor


1 Answers

Let's walk through the requirements, and create a rough plan on how to implement them.

  1. 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.

  2. 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:

    • the fact that the keyof operator, when applied to an object without properties ({}), produces never ("empty objects never have properties, so no keys as well");
    • the ability of TypeScript to move around function parameter lists in the form of tuples, which will help to assign different parameters to the constructor, depending on a given condition (in our case, it's "object is empty");
  3. 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):

    • a type definition for options property;
    • an excerpt of options to pre-populate (the defaults object, the first value argument of the function);
    • a reference to the parent class (the second value argument), set to 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.

like image 170
Dima Parzhitsky Avatar answered Oct 21 '25 23:10

Dima Parzhitsky



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!