I just finished watching the Primagen’s live podcast “The Standup”. Near the end, the cast started discussing some code that looks like this:
const nums: number[] = [0]
function mutate(arr: (string | number)[]) {
arr.push("oops")
}
mutate(nums)
const num: number = nums[1]
console.log(num) // "oops"
The cast sounded quite ill informed about the reason this happens, so I thought I’d go over it quickly. TypeScript’ type system is based around the idea of subtyping. A type A is said to be a subtype of type B if the former can be used whenever the latter could be used. In particular:
Ais always a subtype ofA | B- If
Ais a subtype ofB, thenA[]is a subtype ofB[]
From the above, it follows that number[] is a subtype of type (string | number)[], hence why the code compiles.
◇ Variance
The proper way to fix this is to track the variance of type arguments (A[] is just syntactic sugar for Array<A>, hence why the inner type is a “type argument”). Every type argument can naturally fall in one of three camps:
A type argument is covariant if whenever
Ais a subtype ofB, thenP<A>is a subtype ofP<B>. This is how TypeScript treats arrays, and the mistake causing this bug.A type argument is contravariant if whenever
Ais a subtype ofB, thenP<B>is a subtype ofP<A>(notice the direction of the relation got flipped!). This explains the following example:type P<T> = (v: T) => number const p: P<number | string> = (_) => 3 function test(v: P<number>) { console.log(v(3)) } test(p)A type argument is invariant if
P<A>is a subtype ofP<B>only whenA = B.
The correct thing to do when implementing a type system is to make (mutable, i.e. the default in TypeScript) arrays invariant in their type argument (failing to do so is a common mistake when implementing type systems). TypeScript has the tools to track variance (the issue above has been known about for a long time), but it will (likely) never get fixed in order to preserve backwards compatibility (said behaviour is sadly quite common in practice).
◇ So why does the error not occurr with a direct .push()?
One thing Casey asked on stream is why the error does not occur with the following snippet:
const nums: number[] = [0]
nums.push("oops") // type error!
The issue is that (informally), at every point, TypeScript keeps track of the most narrow type possible for the variables involved. That is, nums has type number[], hence the type mismatch. Still, we can guide TypeScript into widening said “most narrow type” with a type annotation, thus recreating the bug without any new functions:
const nums: number[] = [0]
const arr: (number | string)[] = nums
arr.push("oops") // works, even though we're still referencing the same array!
Duck typing the procedure multiple times (as suggested by Casey) will not fix the issue, as the issue is not tied to procedures in the first place. As stated above, the issue arises from TypeScript’ treatment of array variance, which could be trivially fixed if backwards compatibility wasn’t a concern.
Thanks for reading :3