Search code examples
typescriptgenericsnarrowingtype-narrowing

Types narrowing with generics


I would like to link 2 generics types in a function, and use narrowing for both types by checking one of them. What is the correct way to do this?

type A = 'A';
type B = 'B';

type AB = A | B

type ComplexType<T> = {value: T}

const f = (next: ComplexType<A>) => {}

const builder = <T extends AB>(value: T) => (next: ComplexType<T>) => {
    if (value === 'A') {
        f(next) // expect next is ComplexType<A> but got error
    }
}

Solution

  • There is currently no way to narrow a type parameter like T by checking a value like value. It is possible that value === "A" is true but that does not mean T is "A". After all, maybe value is of type "A" | "B", say by pass ing in an expression where the compiler infers the full union type:

    builder(Math.random() <= 0.999 ? "A" : "B") // no error
    

    Here there is a 99.9% chance that you've passed in "A" but it's still possible that you've passed in "B". The compiler infers that T is "A" | "B". And therefore the next parameter will be of type ComplexType<"A" | "B">. So there is no compiler error when you call this:

    builder(Math.random() <= 0.999 ? "A" : "B")({ value: "B" }); // no error
    

    which means the compiler is technically correct that f(next) might be in error.


    There are multiple existing issues in GitHub asking for support for narrowing type parameters inside generic function bodies. The most relevant for your code is probably microsoft/TypeScript#27808. This asks for some way to tell the compiler that T should be either "A" or "B" and not "A" | "B". Maybe the syntax would be something like T extends_oneof [A, B] or (T extends A) | (T extends B) or something completely different. Then perhaps when you test value === "A" the compiler would conclude that T extends A, and everything would work. Alas, there is currently no such support.


    For now then you just have to work around it. If you're fairly confident nobody is going to call your builder() incorrectly, you could just use a type assertion and move on:

    const builder = <T extends AB>(value: T) => (next: ComplexType<T>) => {
      if (value === 'A') {
        f(next as ComplexType<A>) // okay
      }
    }
    

    If you really need to prevent callers from doing the wrong thing with builder() you could make increasingly complicated call signatures that amount to simulating the "extends one of" constraint, like:

    type NoneOf<T, U extends any[]> =
      [T] extends [U[number]] ? never : T;
    type OneOf<T, U extends any[]> =
      U extends [infer F, ...infer R] ? [T] extends [F] ? NoneOf<T, R> : OneOf<T, R> : never;
    
    const builder = <T extends "A" | "B">(
      value: T & OneOf<T, ["A", "B"]>
    ) => (next: ComplexType<T>) => {
      if (value === 'A') {
        f(next as ComplexType<"A">)
      }
    }
    
    builder(Math.random() <= 0.999 ? "A" : "B"); // error now
    builder2("A") // okay
    builder2("B") // okay
    

    but of course the compiler can't follow that inside the body of builder anyway (generic conditional types are hard for it to deal with) so you still need the type assertion. Personally I'd just use your original signature with the type assertion and only revisit anything more complex if you run into invalid calls in practice.

    Playground link to code