typescripttype-inferencediscriminated-unionunion-typesnarrowing

Why doesn't 'typeof' narrow a union type when the type of a property is a discriminant?


If the members of a union type share a property, and the type of that property can be used to discriminate between those members, I should be able to narrow the type within an if clause using typeof as a condition. But it doesn't work.

For example, within the if clause below, the type of event should be inferred as UserTextEvent and the type of event.target should be inferred as HTMLInputElement:

type UserTextEvent = { value: string, target: HTMLInputElement };
type UserMouseEvent = { value: [number, number], target: HTMLElement };

type UserEvent = UserTextEvent | UserMouseEvent 


function handle(event: UserEvent) {
  if (typeof event.value === 'string') {
    event.value  // string, as expected

    event.target // should be narrowed to HTMLInputElement, but
                 // is still HTMLInputElement | HTMLElement. Why?
  }
}

Solution

  • Typescript discriminated unions (currently) only support property values as discriminants, not property types.

    Here are the types of discriminant properties supported when discriminated unions support was added in Typescript 2.0:

    A discriminant property type guard is an expression of the form x.p == v, x.p === v, x.p != v, or x.p !== v, where p and v are a property and an expression of a string literal type or a union of string literal types. The discriminant property type guard narrows the type of x to those constituent types of x that have a discriminant property p with one of the possible values of v.

    Note that we currently only support discriminant properties of string literal types. We intend to later add support for boolean and numeric literal types.

    Typescript 3.2 expanded that support:

    Common properties of unions are now considered discriminants as long as they contain some singleton type (e.g. a string literal, null, or undefined), and they contain no generics.

    As a result, TypeScript 3.2 considers the error property in the following example to be a discriminant, whereas before it wouldn’t since Error isn’t a singleton type. Thanks to this, narrowing works correctly in the body of the unwrap function.

    type Result<T> = { error: Error; data: null } | { error: null; data: T };
    function unwrap<T>(result: Result<T>) {
      if (result.error) {
        // Here 'error' is non-null
        throw result.error;
      }
      // Now 'data' is non-null
      return result.data;
    }
    

    Typescript 4.5 added support for "Template String Types as Discriminants".

    TypeScript 4.5 now can narrow values that have template string types, and also recognizes template string types as discriminants.

    As an example, the following used to fail, but now successfully type-checks in TypeScript 4.5.

    export interface Success {
        type: `${string}Success`;
        body: string;
    }
     
    export interface Error {
        type: `${string}Error`;
        message: string
    }
     
    export function handler(r: Success | Error) {
        if (r.type === "HttpSuccess") {
            const token = r.body;
                         
    (parameter) r: Success
        }
    }Try
    

    In all cases, it's the value of the discriminant property that is used to discriminate, not its type.

    In other words, as of the current version of Typescript, 4.5, you're out of luck. It's just not supported (yet). Here are the relevant open issues:

    Here is a comment from Typescript dev team lead Ryan Cavanaugh:

    From the looks of it, this feature is unfortunately much more complex to implement than we had anticipated, and we don't think that the cost/benefit ratio is good in this case. As much as we want to support this pattern, the implications of these changes feel too far-reaching (even if they're necessary to actually support the scenario). We'll leave the original issue open in case later on down the line a simpler method of approach becomes available, but we aren't comfortable with introducing this much complexity in a critical codepath right now.