4

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

Gregor
  • 2,325
  • 17
  • 27
  • Let's agree to not change the question after it is answered. If you have other requirements, – please add them all now. – Parzh from Ukraine Jul 06 '21 at 10:31
  • Apologies, I won't add any other requirements. I tried to reduce the problem as much as possible and left out the chaining. For full context: I want to add this feature to the `javascript-plugin-architecture-with-typescript-definitions` project, see https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/pull/57 – Gregor Jul 06 '21 at 17:04
  • This kind of chaining is definitely possible in pure JavaScript in at least two ways that I can think of. In TypeScript, however, it will be a lot trickier to implement this in exactly the way you want. – Parzh from Ukraine Jul 07 '21 at 10:56
  • I know it’s possible in JS I’ve been doing it for years. But can you think of any approach to make it work with Types, too? – Gregor Jul 07 '21 at 19:56
  • Oh, that's great. Could you share your pure JavaScript solution for merging the defaults, and I'll try to sprinkle typings there? If it is too big to fit in the question, let's use a new or existing TypeScript playground. – Parzh from Ukraine Jul 08 '21 at 08:45
  • You can see it implemented in https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/. It's extracted from the JavaScript Octokit, see https://github.com/octokit/core.js – Gregor Jul 08 '21 at 18:43
  • I couldn’t find the functionality of merging current defaults with the previous ones. If you have it already implemented, could you point me to it? – Parzh from Ukraine Jul 08 '21 at 21:20
  • implementation: https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/blob/50341d768593df88d4a1624074b20bb98a1d9a57/index.js#L17-L26 - test: https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/blob/50341d768593df88d4a1624074b20bb98a1d9a57/test/base.test.js#L39-L45 – Gregor Jul 08 '21 at 22:06

3 Answers3

2

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.

Parzh from Ukraine
  • 7,999
  • 3
  • 34
  • 65
  • Thank you for the answer, I'm looking at it right now! One question: why did you add `interface BaseParams extends BaseProps { }` instead of just using `BaseProps`? – Gregor Jul 05 '21 at 20:25
  • Also just curious: Do the `// – Gregor Jul 05 '21 at 20:37
  • Can you explain if/why the `implements BaseProps` part is necessary? – Gregor Jul 05 '21 at 20:44
  • 1
    It is not necessary for the implementation. Is there to tell the compiler that the properties of `Base` class are related to the properties of `BaseParams` object in the constructor. Just good programming, y’know. – Parzh from Ukraine Jul 05 '21 at 22:27
  • No, `// ` and `// ` are just arbitrary comments – Parzh from Ukraine Jul 05 '21 at 22:32
  • I’m seeing now that I have missed the part about `options` property. Sorry for that! It’s a trivial change though: just change the three `this.___ = params.___;` lines in the `Base` constructor with `this.options = params;`. – Parzh from Ukraine Jul 05 '21 at 22:38
  • One thing that does not work with your implementation is chaining of the `.defaults()` call. The class returned by `.defaults()` should retain all static methods of `Base`, and default options should accumulate, e.g. `const MyBase = Base.defaults({ one: "" }).defaults({ two: "" })` would default the options `one`, and `two`. Then `const base = new MyBase({ three: "" })` would type `base.options` as `{ one: string, two: string, three: string })` – Gregor Jul 05 '21 at 23:00
0
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

  • When I change `const testWithDefaults = new MyBaseWithVersion({})` to `const testWithDefaults = new MyBaseWithVersion({ someOption: "value" })` I'd expect `testWithDefaults.options.someOption` to be typed as `string` but it's not typed at all. Would that be something that you could be added easily with your implementation? – Gregor Jul 05 '21 at 20:48
  • What's also not working is chaining the `.defaults()` call: `Base.defaults({ one: "" }).defaults({ two: "" })` is not working – Gregor Jul 05 '21 at 23:13
0

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

Gregor
  • 2,325
  • 17
  • 27