Question

When can arrays be cast to tuples with a single as assertion

Given the following assertions are all permitted:

//1. A readonly number[] can be cast to readonly tuple. Despite the length of array being unknown.
([] as readonly number[]) as readonly [number, number, number] 

//2. Similarly, a number[] can be cast to tuple. Again despite the length of the array being unknown.
([] as number[]) as [number, number, number] 

//3. Additionally, a number[] can be cast to a readonly number[]
([] as number[]) as readonly number[]

So from this I understand that it is possible to cast a number[] to a tuple. So the compiler accepts that I want to make that assertion. I am also able to make assertion that casts an array to a readonly array.

So I guess I am considering these two assertions as individual assertions with independent "features" of the types. The first one concerned with the array vs tuple only. And the second one with readonly vs not readonly. So I suppose by trying to combine these assertions, I might expect that I could assert an array as a readonly tuple, for example:

([] as number[]) as readonly [number, number, number] 

But this is not allowed. So maybe it's not correct for me to think about this assertion as a kind of composition/combination of two other assertions that both are only concerned with one feature of the types. But rather this may need to be considered as a third independent assertion with its own rules.

I hope that's a bit more clear. Here's an updated playground link.


Additionally, I can do

([] as number[]) as (readonly number[]) as readonly [number, number, number]

Which I guess is close to some formally more correct way of doing it. And if that's true, and the question then reduces itself to why the need to make individual assertions, then I also understand that perhaps there is not a better answer than that's the way typescript was designed. From what I understand, typescript, by design, does not define some formal set of rules that work almost mathematically, but is more concerned with ease of use and convenience for most cases.

But either way, if there are insights that can make understanding the scenario I have outline more clearly, it is appreciated. Thank you.

 3  75  3
1 Jan 1970

Solution

 3

Assertions let you widen or narrow, not "sidecast"

The point of assertions is to let you "upcast" or "downcast" a value to a type. So the rule of thumb for assertions is: if you have a value x of type X, then you can write the assertion x as Y if either X extends Y or Y extends X. If neither of those are true then TypeScript will probably complain that the two types are not related enough to assert between and that it's probably a mistake. You can't directly "sidecast" a value to a type.

Note that the extends relationship goes by different names, and while there are sometimes differences between them, they are all approximately the same concept: you can say "T extends U" or "T is assignable to U" or "T is a subtype of U" or "T is a narrowing of U" or "U is a widening of T", or "TU". This relationship is not symmetric (T extends U doesn't imply that U extends T) but it is usually transitive (T extends U and U extends V often implies that T extends V).

On the other hand, the assertability relationship is symmetric (if you can assert t as typeof u then you can assert u as typeof t) and is usually not transitive (t as U as V being allowed does not imply that t as V is allowed). So even if you can't directly sidecast a value t to a type V, you can find an intermediate type U that works and write t as U as V.


Tuples are narrower than arrays

Mutable tuple types like [T] and [T, T] are assignable to unordered arbitrary-length mutable array types like T[], but not vice versa:

declare let mutArr: number[];
declare let mutTup: [number, number, number];

mutArr = mutTup; // okay
mutTup = mutArr; // error

The same relationship is true between readonly tuples like readonly [T] and readonly array types like readonly T[]:

declare let roArr: readonly number[];
declare let roTup: readonly [number, number, number];

roArr = roTup; // okay
roTup = roArr; // error

Mutable array/tuples are narrower than readonly array/tuples

Mutable array types like T[] are assignable to readonly array types like readonly T[] but not vice versa:

roArr = mutArr; // okay
mutArr = roArr; // error

roTup = mutTup; // okay
mutTup = roTup; // error

Table of assignability

wider narrower
wider readonly T[] T[]
narrower readonly [T, T, T] [T, T, T]

So, if you want to assert between two of the types shown above, you can do so if you are narrowing (moving to the right and/or down) or widening (moving up and/or to the left). But you cannot do so if you are doing some lateral move (moving down and to the left, or up and to the right).

That means you can expect any of these assertions to work:

// narrowings
roArr as number[]; // okay
roArr as readonly [number, number, number]; // okay
roArr as [number, number, number]; // okay
mutArr as [number, number, number]; // okay
roTup as [number, number, number]; // okay

// widenings
mutArr as readonly number[]; // okay
roTup as readonly number[]; // okay
mutTup as readonly number[]; // okay
mutTup as number[]; // okay
mutTup as readonly [number, number, number]; // okay

But you can expect these assertions to fail:

// sidecasting
mutArr as readonly [number, number, number]; // error
/* Conversion of type 'number[]' to type 'readonly [number, number, number]' may be a 
   mistake because neither type sufficiently overlaps with the other.
   If this was intentional, convert the expression to 'unknown' first. */
roTup as number[]; // error
/* Conversion of type 'readonly [number, number, number]' to type 'number[]' may be a 
   mistake because neither type sufficiently overlaps with the other. 
   If this was intentional, convert the expression to 'unknown' first. */

And that's indeed what happens. And if you really do want to "sidecast", then you can do so by using an intermediate type that is assertable to both. The compiler recommends unknown, but you can use anything you want that's appropriate, such as the other corners of the chart:

// widen then narrow
mutArr as readonly number[] as readonly [number, number, number]; // okay
roTup as [number, number, number] as number[]; // okay

// narrow then widen
mutArr as [number, number, number] as readonly [number, number, number]; // okay
roTup as readonly number[] as number[]; // okay

Playground link to code

2024-07-20
jcalz