1

I'm working on a factory; I need to eventually add custom methods, but thanks to this answer and this answer, we was able to make it work as expected.

Now I need to control the type of the id through an option, once again I'm so close, but I'm still missing something.

I need to control the type of the id attribute through the str option: if true the type of id has to be string, while if false or not provided, the type of id has to be number, but I get string | number.

type Base = { save: () => Promise<boolean> }; // Base and BaseId must be kept separated for next steps ;)
type BaseId<B extends boolean> = { id: B extends true ? string : number };
type Options<B extends boolean> = { opt1?: boolean; opt2?: number; opt3?: string; str?: B };
type Factory<E> = new () => E;

function factory<B extends boolean, E extends Base & BaseId<B>>(name: string, options?: Options<B>): Factory<E>;
function factory<B extends boolean, E extends Base & BaseId<B>, M extends Record<string, <S extends M>(this: E & S) => unknown>>(
  name: string, options: Options<B>, methods: M & Record<string, ((this: E & M) => void)>
): Factory<E & M>;
function factory<B extends boolean, E extends Base & BaseId<B>, M extends Record<string, <S extends M>(this: E & S) => unknown>>(
  name: string, options?: Options<B>, methods?: M & Record<string, ((this: E & M) => void)>
): Factory<E> {
  const ret = function (this: E) {};

  Object.defineProperty(ret, "name", { value: name });
  if(methods) Object.assign(ret.prototype, methods);

  return ret as unknown as Factory<E>;
}

const T1 = factory("T1");
const t1 = new T1();
t1.id = "0"; // Error: string | number but number is expected
console.log(t1, t1.id);

const T2 = factory(
  "T2", { str: true }, {
    test: function (repeat = true) {
      if (repeat) this.test(false);
      return "test";
    }
  }
);
const t2 = new T2();
t2.id = "0"; // Ok: string
console.log(t2, t2.id, t2.test());

Here is a playground to experiment.

Daniele Ricci
  • 15,422
  • 1
  • 27
  • 55

1 Answers1

1

You have string|number in your first case because second argument options is not provided. You need to change your upper/last overload signature and get rid of options. You need to provide overloaded signature without options because options is optional parameter. You have provided signature with optional options, with required options but you did not have signature without options. Since B generic parameter is binded with options argument you need to use some default value instead of B.

Consider this example:

type Base = { save: () => Promise<boolean> }; // Base and BaseId must be kept separated for next steps ;)
type BaseId<B extends boolean> = { id: B extends true ? string : number };
type Options<B extends boolean> = { opt1?: boolean; opt2?: number; opt3?: string; str?: B };
type Factory<E> = new () => E;

function factory<E extends Base & BaseId<false>>(name: string): Factory<E>;
function factory<B extends boolean, E extends Base & BaseId<B>, M extends Record<string, <S extends M>(this: E & S) => unknown>>(
  name: string, options: Options<B>, methods: M & Record<string, ((this: E & M) => void)>
): Factory<E & M>;
function factory<B extends boolean, E extends Base & BaseId<B>, M extends Record<string, <S extends M>(this: E & S) => unknown>>(
  name: string, options?: Options<B>, methods?: M & Record<string, ((this: E & M) => void)>
): Factory<E> {
  const ret = function (this: E) { };

  Object.defineProperty(ret, "name", { value: name });
  if (methods) Object.assign(ret.prototype, methods);

  return ret as unknown as Factory<E>;
}

const T1 = factory("T1");
const t1 = new T1();
t1.id = "0"; // Error: string | number but number is expected
t1.id = 1; // Ok number

Playground I have used false as a default type for B

UPDATE

boolean is a union of true | false

If you don't want to touch overload signatures, you can check if B is a union. If it is a union (boolean) it means that B is not infered and we need to use some default value:

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// credits goes to https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true

type Base = { save: () => Promise<boolean> };
type BaseId<B extends boolean> = { id: IsUnion<B> extends true ? number : B extends true ? string : number }; // <------ CHANGE IS HERE
type Options<B extends boolean> = { opt1?: boolean; opt2?: number; opt3?: string; str?: B };
type Factory<E> = new () => E;

function factory<B extends boolean, E extends Base & BaseId<B>>(name: string, options?: Options<B>): Factory<E>;
function factory<B extends boolean, E extends Base & BaseId<B>, M extends Record<string, <S extends M>(this: E & S) => unknown>>(
  name: string, options: Options<B>, methods: M & Record<keyof M, ((this: E & M) => void)>
): Factory<E & M>;
function factory<B extends boolean, E extends Base & BaseId<B>, M extends Record<string, <S extends M>(this: E & S) => unknown>>(
  name: string, options?: Options<B>, methods?: M & Record<string, ((this: E & M) => void)>
): Factory<E> {
  const ret = function (this: E) { };

  Object.defineProperty(ret, "name", { value: name });
  if (methods) Object.assign(ret.prototype, methods);

  return ret as unknown as Factory<E>;
}

const T1 = factory("T1",{}, {
  test:function(repeat = true) {
    this.id = 0;
    if(repeat) this.test(false);
  }
});
const t1 = new T1();
t1.id = "0"; // Error: id should be number

Playground

In general, since id is required property and options is not, id should not rely on options.

Made small update. It should be Record<keyof M, ((this: E & M) => void)> instead of Record<string, ((this: E & M) => void)>