Generic type extending union is not narrowed by type guard

Viewed 1765

I tried to replicate Anders' example for conditional types and generics which he showed at Build 2018 (36:45). He uses a conditional type as a return type as replacement for more traditional function overloads.

The slide has the following:

type Name = { name: string };
type Id = { id: number };
type Check = { enabled: boolean };

type LabelForType<T> =
  T extends string ? Name :
  T extends number ? Id :
  T extends boolean ? Check :
  never;

declare function createLabel<T extends string | number | boolean>(value: T): LabelForType<T>

I tried to simplify this a bit and came up with the following example. The conditional type returns number when given a string and vice versa, while the function implements this conditional type as return type.

type Return<T> = T extends string ? number : T extends number ? string : never;

function typeSwitch<T extends string | number>(x: T):  Return<T>{
  if (typeof x == "string") {
    return 42;
  } else if (typeof x == "number") {
    return "Hello World!";
  }
  throw new Error("Invalid input"); // needed because TS return analysis doesn't currently factor in complete control flow analysis
}

const x = typeSwitch("qwerty"); // number

However both return statements show the same error:

Type '42' is not assignable to type 'Return<T>'.(2322)
Type '"Hello World!"' is not assignable to type 'Return<T>'.(2322)

What am I missing here?

1 Answers

Here's why it doesn't work: Typescript does control-flow type narrowing on regular variables, but not on type variables like your T. The type guard typeof x === "string" can be used to narrow the variable x to type string, but it cannot narrow T to be string, and does not try.

This makes sense, because T could be the union type string | number even when x is a string, so it would be unsound to narrow T itself, or to narrow T's upper bound. In theory it would be sound to narrow T to something like "a type which extends string | number but whose intersection with string is not never", but that would add an awful lot of complexity to the type system for relatively little gain. There is no fully general way around this except to use type assertions; for example, in your code, return 42 as Return<T>;.

That said, in your use-case you don't need a generic function at all; you can just write two overload signatures:

// overload signatures
function typeSwitch(x: string): number;
function typeSwitch(x: number): string;
// implementation
function typeSwitch(x: string | number): string | number {
  if (typeof x === "string") {
    return 42;
  } else {
    // typeof x === "number" here
    return "Hello World!";
  }
}

Playground Link

Related