Search code examples
typescriptgenericstype-constraintsdiscriminated-union

TypeScript indexed access type constraints behaving strangely


Take this example

interface Person<TName> {
    name: TName;
}

type People =
    | Person<"Levi">
    | Person<"Julien">

type FilteredPersonBase<TPerson extends People> = TPerson["name"] extends "Levi" ? TPerson : never;
type FilteredPerson = FilteredPersonBase<People>; // never

type FilteredPersonBase2<TPerson extends People> = TPerson extends { name: "Levi" } ? TPerson : never;
type FilteredPerson2 = FilteredPersonBase2<People>; // Person<"Levi">

I would expect both FilteredPerson and FilteredPerson2 to resolve to Person<"Levi">, but they don't. Why does using the indexed access operator in the type constraint resolve differently than using the inline type?


Solution

  • I agree that the second one should resolve to person Person<"Levi">, but lets look at how that happens:

    FilteredPersonBase2<People>
        // FilteredPersonBase2 is a distributive conditional type since
        // TPerson appears nakedly in the condition of the conditional type
        => FilteredPersonBase2<Person<"Levi">> | FilteredPersonBase2<Person<"Julien">>
        // The conditional type is applied to each member of the union
        => (Person<"Levi"> extends { name: "Levi" } ? Person<"Levi"> : never) |  (Person<"Julien"> extends { name: "Levi" } ? Person<"Julien"> : never)
        // The first application return Person<"Levi">, the second resolves to never
        => Person<"Levi"> | never
        // Never is removed in a union 
        => Person<"Levi">
    

    The reason this works is the distributive behavior of conditional types

    But conditional types only distribute over naked type parameters. In the first example the condition is over TPerson["name"] which is not a naked type parameter, so no distribution occurs. Expanding the conditional type we get:

    FilteredPersonBase<People> =>
        // No distribution 
        People["name"] extends "Levi" ? People : never => 
        // People["name"] just resolves to the union
        "Levi" | "Julien" extends "Levi" ? People : never => 
        // The union does not extend a constituent of the union
        never 
    

    Since distribution does not occur, TPerson["name"] just resolves to "Levi" | "Julien" and a union does not extend a constituent. It would be true the other way around ("Levi" extends TPerson["name"]) but that would just resolve to the union (Person) since a constituent of a union is a sub-type of the union.

    Just for fun, you could force distribution over TPerson using an always true condition like TPerson extends TPerson:

    type FilteredPersonBase<TPerson extends People> = TPerson extends TPerson ? TPerson["name"] extends "Levi" ? TPerson : never : never;
    type FilteredPerson = FilteredPersonBase<People>;  // Person<"Levi">