typescriptnarrowing

Narrow SomeType vs SomeType[]


I can easily use a constant string value to narrow down a union type:

type Payload1 = { /* ... arbitrary type ... */ };
type Payload2 = { /* ... arbitrary type ... */ };
type T1 = { type: 'type1', payload: Payload1 }
type T2 = { type: 'type2', payload: Payload2 }
type T = T1 | T2;

const fn = (value: T) => {

    if (value.type === 'type1') {
        value; // Typescript knows `value is T1`
    }

    if (value.type === 'type2') {
        value; // Typescript knows `value is T2`
    }

};

Here there are only two cases:

  1. value.type is the constant "type1"
  2. value.type is the constant "type2"

But what if I expand T, allowing payload to be either a single item or an array? Now there are 4 possibilities:

  1. value.type is "type1" and value.payload is not an array
  2. value.type is "type1" and value.payload is an array
  3. value.type is "type2" and value.payload is not an array
  4. value.type is "type2" and value.payload is an array

Here is an example:

type Payload1 = {};
type Payload2 = {};
type T1Single = { type: 'type1', payload: Payload1 }
type T1Batch = { type: 'type1', payload: Payload1[] };
type T2Single = { type: 'type2', payload: Payload2 }
type T2Batch = { type: 'type2', payload: Payload2[] };

// Here's T, now with 4 types instead of 2:
type T = T1Single | T1Batch | T2Single | T2Batch;

const fn = (value: T) => {

    if (value.type === 'type1' && !Array.isArray(value.payload)) {
        value; // Typescript says `value is T1Single | T1Batch`?!
        // How does `T1Batch` remain in the union if `value.payload` isn't an array??
    }

    if (value.type === 'type1' && Array.isArray(value.payload)) {
        value; // Typescript says `value is T1Single | T1Batch`?!
        // How does `T1Single` remain in the union if `value.payload` is an array??
    }

    if (value.type === 'type2' && !Array.isArray(value.payload)) {
        value; // Typescript says `value is T2Single | T2Batch`?!
        // How does `T2Batch` remain in the union if `value.payload` isn't an array??
    }

    if (value.type === 'type2' && Array.isArray(value.payload)) {
        value; // Typescript says `value is T2Single | T2Batch`?!
        // How does `T2Single` remain in the union if `value.payload` is an array??
    }

};

Playground

Why is typescript only partially narrowing down the type, and how can I achieve fully narrowed values for the 4 cases?

EDIT: Looks like multiple conditions in the if is irrelevant; typescript struggles to narrow based on Array.isArray alone:

type Payload = {};
type Single = { payload: Payload }
type Batch = { payload: Payload[] };

const fn = (value: Single | Batch) => {

    if (!Array.isArray(value.payload)) {
        value; // Typescript says `value is Single | Batch`?!
    }

    if (Array.isArray(value.payload)) {
        value; // Typescript says `value is Single | Batch`?!
    }

};

Solution

  • You are trying to treat T as a discriminated union, but the payload property is not recognized as a discriminant. For a property to be seen as a valid discriminant, it must contain unit/literal types. Your type property is valid because "type1" and "type2" are string literal types. But arrays and your Payload types are object types, not literal types. So you can't check value.payload and have it narrow the apparent type of value itself.

    Note that Array.isArray(value.payload) does act as a type guard on the value.payload property, but because the property is not a discriminant, this narrowing does not propagate up to value itself. There is an open feature request at microsoft/TypeScript#42384 to allow property type guards to propagate up to containing objects. It's not part of the language yet, though, and previous requests for it were declined as it was considered too expensive to synthesize new types for every type guard check on a nested property.


    For now, if you want to get behavior like this you could write a custom type guard function that narrows a value based on whether its payload property is an array. Like this:

    function hasArrayPayload<T extends { payload: any }>(
        value: T): value is Extract<T, { payload: any[] }> {
        return Array.isArray(value.payload)
    }
    

    Then instead of writing Array.isArray(value.payload) inline, you call hasArrayPayload(value):

    const fn = (value: T) => {
        if (value.type === 'type1' && !hasArrayPayload(value)) {
            value; // (parameter) value: T1Single
        }
    
        if (value.type === 'type1' && hasArrayPayload(value)) {
            value; // (parameter) value: T1Batch
        }
    
        if (value.type === 'type2' && !hasArrayPayload(value)) {
            value; // (parameter) value: T2Single
        }
    
        if (value.type === 'type2' && hasArrayPayload(value)) {
            value; // (parameter) value: T2Batch
        }
    };
    

    Playground link to code