arraystypescripttuplestype-narrowing

Narrow the type of the first array element based on the length of the array?


I'm working with an array for which the type of the first element is known, if the array has length one, and which could be one of two types, otherwise; i.e. something like the following:

type NarrowableArray<TKnown, TGenereal> = [TKnown] | [TGeneral, TGeneral, ...TGeneral[]]

type Foo = number | number[]

let a: NarrowableArray<number, Foo> = Math.random() > 0.5 ? [3] : [3,4,5]

if (a.length === 1) {
    a 
    console.log(a[0] + 2) //Error > TS can't tell `a[0]` has type 'number'. 
}

Is this expected? Is there a work around?


Solution

  • The problem is that the length property of an open-ended tuple type is number, as described in microsoft/TypeScript#24897; TypeScript doesn't have accurate range types (as requested in microsoft/TypeScript#15480) to express "any number except 1" or {length: WholeNumber & GreaterThan<1>}.

    So unfortunately just checking the length property for 1 won't be able to discriminate the union, since both union members have a length to which 1 is assignable.


    There are other narrowing methods that work directly with tuples more naturally, for example, you could use in narrowing and check "1" in a as follows:

    function aFunction(a: NarrowableArray<number, Foo>) {
        if (!("1" in a)) {
            a // (parameter) a: [number]
            console.log(a[0] + 2) // okay
        } else {
            a // (parameter) a: [Foo, Foo, ...Foo[]]
        }
    }
    

    Playground link to code

    However, if you want to perform a length check yourself you might need to refactor to a user-defined type guard function of the form:

    function hasLengthOne(x: any[]): x is [any] {
        return x.length === 1;
    }
    

    And then call that instead of directly checking the length:

    function aFunction(a: NarrowableArray<number, Foo>) {
        if (hasLengthOne(a)) {
            a // (parameter) a: [number]
            console.log(a[0] + 2); // okay
        } else {
            a // (parameter) a: [Foo, Foo, ...Foo[]]
        }
    }
    

    That works because the compiler is able to filter NarrowableArray<number, Foo> by [any]; we're circumventing the length issue entirely.

    Playground link to code