typescript

Why are generics not working properly here?


class Token<T> {
  public name: T;
  constructor(name: T) {
    this.name = name;
  }
}

class LazyToken<T> {
  public callback: () => GenericToken<T>;
  constructor(callback: () => GenericToken<T>) {
    this.callback = callback;
  }

  public resolve() {
    return this.callback();
  }
}

type GenericToken<T = unknown> = Token<T> | LazyToken<T> | Newable<T>;

type Newable<
  TInstance = unknown,
  TArgs extends unknown[] = any[]
> = new (...args: TArgs) => TInstance;


function get<T>(token: GenericToken<T>, options: any = {}): T | undefined {
  return 123 as T;
}

class Test1 {

}

class Test2 {
  public name = 123;
}

class Test3 {
  sayHello() { }
}

class Test4 {
  public hello = 123;
}

class Test5 {
  public hello = 123;
  sayHello() { }
}

const test1 = get(Test1); // Test1 | undefined
const test2 = get(Test2); // test2: string | undefined
const test3 = get(Test3); // test3: string | undefined
const test4 = get(Test4); // test4: string | undefined
const test5 = get(Test5); // test5: string | undefined

Playground

The type of test1 is Test1 | undefined as I expect. But test2 through test5 are all of type string | undefined, instead of Test2 | undefined through Test5 | undefined, which I don't expect. How can I fix it?


Solution

  • The problem is that there is an unexpected ambiguity in your types. The type Token<string> corresponding to an instance of the Token class where T is string is structurally equivalent to {name: string}. And this turns out to be a supertype of the Function interface, which (at least for ES2015 and later) is declared in TypeScript's library to have a name property of type string. So, as far as TypeScript is concerned, everything with a string-valued name property is also a Token<string>. So that includes all Functions, which includes all class constructors, like typeof Test1, typeof Test2, et cetera. Yes, that's weird, but it's a consequence of TypeScript's structural type system. If you're used to nominally typed languages like Java or C# where such things simply cannot happen, then you might object that of course a class constructor is not an instance of Token. But TypeScript is only looking at the shape of the type, not where it is declared.

    So when you try to infer T from a value of type GenericToken<T> which is a class constructor, then it is ambiguous. It could either decide that the GenericToken<T> is a Newable<T> and infer T as the instance type of the class constructor, or it could decide that the GenericToken<T> is a Token<string> and infer T as string. The particular details of when TypeScript chooses one over the other might be interesting, but I don't think you can rely on them. It's best to avoid the problem, rather than try to really understand why typeof Test1 is inferred as a Newable<Test1> while the rest of them are inferred as a Token<string>. It's ambiguous, and TypeScript chooses one, and it's not always what you want. (See this FAQ entry for a similar issue with inference.)

    The way to fix the problem is to break the structural compatibility between Function and Token<string>. I'd do this by adding any property to Token that you don't expect to see in the wild. (You could also add a private property to Token, since that will never conflict.) For example:

    class Token<T> {
      anyOtherProp = true; // <-- this could be just about anything
      public name: T;
      constructor(name: T) {
        this.name = name;
      }
    }
    

    Now there's no ambiguity. A class constructor is very unlikely to be a Token<string> (it would need to have a static anyOtherProp property of type boolean), so you get the inference you expect in all the cases:

    const test1 = get(Test1); // Test1 | undefined
    const test2 = get(Test2); // Test2 | undefined
    const test3 = get(Test3); // Test3 | undefined
    const test4 = get(Test4); // Test4 | undefined
    const test5 = get(Test5); // Test5 | undefined
    

    Playground link to code