The unexpected way in which conditional types constrain type variables in TypeScript

[2025-02-23] dev, typescript
(Ad, please don’t block)

The TypeScript handbook makes an interesting statement: “Often, the checks in a conditional type will provide us with some new information. Just like narrowing with type guards can give us a more specific type, the true branch of a conditional type will further constrain generics by the type we check against.”

In this blog post, we’ll see that this goes further than you may think.

Normal “narrowing” of type variables  

Many operations are not allowed on arbitrary types T:

// @ts-ignore: Type '"length"' cannot be used to index type 'T'.
type GetLength1<T> = T['length'];

With a conditional type, we can “narrow” T so that retrieving the length is possible:

type GetLength2<T> = T extends {length: unknown} ? T['length'] : never;

type _ = [
  Assert<Equal<
    GetLength2<['x']>, 1
  >>,
  Assert<Equal<
    GetLength2<123>, never
  >>,
  Assert<Equal<
    GetLength2<123 | ['x'] | []>, 1 | 0 // (A)
  >>,
];

In line A we can see that conditional types are distributive. The result never of GetLength2<123> disappears because it is a neutral element for the union operator |:

type _ = Assert<Equal<
  never | 'abc',
  'abc'
>>;

Beyond “narrowing”  

In this section, we explore an example that shows us that the constraints placed on type variables by conditional types are more complex than it might seem. The real surprise comes at the very end.

SimpleEqual1 naively compares two types:

type SimpleEqual1<X, Y> =
  X extends Y
    ? (Y extends X ? true : false)
    : false
;
type _ = [
  Assert<Equal<
    'a' extends 'a'|'b'|'c' ? true : false,
    true
  >>,
  Assert<Equal<
    'a'|'b'|'c' extends 'a' ? true : false,
    false
  >>,
  Assert<Equal<
    SimpleEqual1<'a', 'a'|'b'|'c'>,
    true | false // (A)
  >>,
];

In line A, why isn’t the result either true or false but both? That’s because conditional types are distributive. Let’s explore further how that works.

The first conditional type produces the following results:

type CondX<X, Y> = X extends Y ? ['trueX', X, Y] : ['falseX', X, Y];
type _ = Assert<Equal<
  CondX<'a'|'b', 'a'|'b'>,
  ['trueX', 'a', 'a' | 'b'] | ['trueX', 'b', 'a' | 'b']
>>;

We can see that a conditional type is only distributive over the left-hand side of extends. What happens if we feed those results into the second condition?

type CondY<X, Y> = Y extends X ? ['trueY', X, Y] : ['falseY', X, Y];
type _ = [
  Assert<Equal<
    CondY<'a', 'a' | 'b'>,
    ['trueY', 'a', 'a'] | ['falseY', 'a', 'b']
  >>,
  Assert<Equal<
    CondY<'b', 'a' | 'b'>,
    ['falseY', 'b', 'a'] | ['trueY', 'b', 'b']
  >>,
];

CondY is distributed over Y. Its results explains the result true|false of SimpleEqual1: They contain both of those values.

So far, there were no surprises (apart from conditional types being distributive). But now, we’ll trace the computation of SimpleEqual1 similarly to how we did above:

type SimpleEqual2<X, Y> =
  X extends Y
    ? (Y extends X ? ['trueY', X, Y] : ['falseY', X, Y])
    : ['falseX', X, Y]
;
type _ = Assert<Equal<
  SimpleEqual2<'a'|'b', 'a'|'b'>,
  | ['trueY', 'a', 'a'] | ['falseY', never, 'b']
  | ['falseY', never, 'a'] | ['trueY', 'b', 'b']
>>;

Why does never appear in some of these tuples?

  • Hypothesis 1: X was narrowed to 'a'|'b' via the first conditional type. However, that can’t be true because never prevents the value 'a' and the value 'b' – neither of which conflict with 'a'|'b'.

  • Hypothesis 2: TypeScript remembers the constraint X extends Y. And, indeed, we get never whenever that constraint is broken. Interestingly, the constraint only affects X.

If you found other examples of this phenomenon, I’d love to know more – via a comment (below), Mastodon, Bluesky or email.