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}`)
}
}
… 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'
}
}
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?
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 Tuple
s 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.