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

Viewed 432

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

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

interface Options {
  version: string;
  [key: string]: unknown;
}

type CalssType = abstract new (...args: any) => any;

// obtain parameters of constructor
type Params = ConstructorParameters<typeof Base>

// obtain instance type
type Instance = InstanceType<typeof Base>

// make first element of tuple Partial
type FstPartial<Tuple extends any[]> = Tuple extends [infer Fst, ...infer Tail] ? [Partial<Fst>, ...Tail] : never

// clone constructor type and replace first argument in rest parameters with partial
type GetConstructor<T extends CalssType> = new (...args: FstPartial<ConstructorParameters<T>>) => InstanceType<T>

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;
    };
  } & GetConstructor<typeof Base> { // <--- change is here
    return class extends this {
      constructor(...args: any[]) {
        super(Object.assign({}, defaults, args[0] || {}));
      }
    };
  }

  constructor(options: TOptions) {
    this.options = options;
  };

  options: TOptions;
}

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({}) // ok

// should be both typed as string
testWithDefaults.options.version
testWithDefaults.options.foo

Playground

With the help of Josh Goldberg we came to the conclusion that an infinite chainable Base.defaults().defaults()... API cannot be typed with today's TypeScript.

What we resorted doing instead is to implement a chaining of up to 3 .defaults() calls, which will still set the .options property correctly on the instance.

Here is the full type declaration file that we ended up implementing. It's a bit more complicated because it also includes the .plugin()/.plugins API that I left out from my original question for the sake of simplicity.

export declare namespace Base {
  interface Options {
    version: string;
    [key: string]: unknown;
  }
}

declare type ApiExtension = {
  [key: string]: unknown;
};
declare type Plugin = (
  instance: Base,
  options: Base.Options
) => ApiExtension | void;

declare type Constructor<T> = new (...args: any[]) => T;
/**
 * @author https://stackoverflow.com/users/2887218/jcalz
 * @see https://stackoverflow.com/a/50375286/10325032
 */
declare type UnionToIntersection<Union> = (
  Union extends any ? (argument: Union) => void : never
) extends (argument: infer Intersection) => void
  ? Intersection
  : never;
declare type AnyFunction = (...args: any) => any;
declare type ReturnTypeOf<T extends AnyFunction | AnyFunction[]> =
  T extends AnyFunction
    ? ReturnType<T>
    : T extends AnyFunction[]
    ? UnionToIntersection<Exclude<ReturnType<T[number]>, void>>
    : never;

type ClassWithPlugins = Constructor<any> & {
  plugins: any[];
};

type ConstructorRequiringVersion<Class extends ClassWithPlugins, PredefinedOptions> = {
  defaultOptions: PredefinedOptions;
} & (PredefinedOptions extends { version: string }
  ? {
      new <NowProvided>(options?: NowProvided): Class & {
        options: NowProvided & PredefinedOptions;
      };
    }
  : {
      new <NowProvided>(options: Base.Options & NowProvided): Class & {
        options: NowProvided & PredefinedOptions;
      };
    });

export declare class Base<TOptions extends Base.Options = Base.Options> {
  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<
    Class extends ClassWithPlugins,
    Plugins extends [Plugin, ...Plugin[]],
  >(
    this: Class,
    ...plugins: Plugins,
  ): Class & {
    plugins: [...Class['plugins'], ...Plugins];
  } & Constructor<UnionToIntersection<ReturnTypeOf<Plugins>>>;

  /**
   * 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<
    PredefinedOptionsOne,
    ClassOne extends Constructor<Base<Base.Options & PredefinedOptionsOne>> & ClassWithPlugins
  >(
    this: ClassOne,
    defaults: PredefinedOptionsOne
  ): ConstructorRequiringVersion<ClassOne, PredefinedOptionsOne> & {
    defaults<ClassTwo, PredefinedOptionsTwo>(
      this: ClassTwo,
      defaults: PredefinedOptionsTwo
    ): ConstructorRequiringVersion<
      ClassOne & ClassTwo,
      PredefinedOptionsOne & PredefinedOptionsTwo
    > & {
      defaults<ClassThree, PredefinedOptionsThree>(
        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 {};

Here is the pull request adding that feature to the javascript-plugin-architecture-with-typescript-definitions module.

https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/pull/59

Related