typescripttuples

Why do I only get an error for a union but not for a bare type?


Sometimes to simplify a function signature with complex inputs, we define a collection of "known" complex inputs each associated with a token, and allow the user to provide the token instead.

My function works with tuples (simplified to 3-element tuples here), and initially it didn't have any compiler errors:

const TUPLE_LENGTH = 3

type Tuple = readonly [unknown, unknown, unknown]

function fn(tuple: Tuple): void {
    if (tuple.length !== TUPLE_LENGTH) {
        throw new Error(`Expected ${TUPLE_LENGTH} items, instead got ${tuple.length}`)
    }
}

Try it.

… but after adding "known" inputs, a compiler error started to appear:

const TUPLE_LENGTH = 3

type Tuple = readonly [unknown, unknown, unknown]

const knownTuples = {
    foo: [1, 2, 3],
    bar: ['a', 'b', 'c'],
    baz: [true, false, null],
} satisfies Readonly<Record<string, Tuple>>

type TupleName = keyof typeof knownTuples

function fn(tuple: Tuple | TupleName): void {
    if (typeof tuple === 'string') {
        tuple = knownTuples[tuple]
    }

    if (tuple.length !== TUPLE_LENGTH) {
        throw new Error(`Expected ${TUPLE_LENGTH} items, instead got ${tuple.length}`)
        //                                                                   ^^^^^^
        // Error: Property 'length' does not exist on type 'never'
    }
}

Try it.

For some reason, after if (tuple.length !== TUPLE_LENGTH) in the first case it's tuple: Tuple but in the second case it is narrowed down to tuple: never. Why is that?


Solution

  • TypeScript mostly only performs narrowing on union types. If tuple is of the union type Tuple | TupleName, then your first check eliminates the TupleName possibility (since it's not a string) and the second check eliminates the Tuple possibility (since its length is not 3 and all Tuples are of length 3). So tuple is narrowed all the way to the impossible never type and you get errors when trying to interact with it further. Those errors are telling you that code flow cannot reach the code block in question.

    But when tuple is of the non-union type Tuple, no such narrowing happens, even though logically you've still eliminated all possibilities and control flow cannot reach the code lock in question. Your question is: why?


    The answer is that this is a design decision of TypeScript, described at microsoft/TypeScript#38963.

    The biggest reason for this is that compiler performance would suffer terribly if narrowing had to happen for all values, and there would be very little benefit. You'd get a little more consistency around the never type, but mostly you'd just get the type checker doing a bunch of extra work to potentially narrow values that almost nobody is trying to narrow in the first place.

    The second reason is that apparently there are plenty of non-union types where people perform extra runtime checks that should not be necessary (like you yourself have done in your first example) and those becoming errors would annoy people. Yes, as you've shown, that could happen for unions too, but in practice it does not happen very often. People who go through the trouble of modeling their values as having union types generally mean for those to be properly typed without needing runtime checks.

    Yes, one could argue that it should be either both or neither, for consistency's sake, but consistency isn't the ultimate goal of TypeScript. See this comment on microsoft/TypeScript#9825 (and the whole issue for that matter). Ease of use for real world code is sometimes considered more important than consistency. And this has to be measured empirically and statistically; how often do people run into such an issue, and do we make the average experience better or worse if we change it? In this situation, the current behavior of "unions narrow and non-unions don't" works well enough for a wide range of real world code, and making it consistent would harm the average user for reasons laid out above.