Featured image

Series: TypeScript

Il problema Link to heading

TypeScript controlla di default che l’accesso diretto agli elementi di una tupla presente nello scope sia safe:

const tuple = [1, "due", { tre: 4 }] as const

tuple[0] // ok

tuple[4] // Tuple type 'readonly [1, "due", { readonly tre: 4; }]' of length '3' has no element at index '4'.

Playground

Quando invece abbiamo a che fare con funzioni generiche che prendono come input tuple o array, non è presente alcun controllo:

function get<T extends any[]>(tuple: [...T], index: number): T[number] {
  tuple[500] // ok
  return tuple[index] // ok
}

Playground

dove tuple è tipizzato con [...T] anziché T perché questo impone al type checker di inferire una tipo tupla se la funzione viene invocata su una tupla e non su un array:

declare function get<T extends any[]>(tuple: [...T], index: number): T[number]
get([1,2,3], 1) // T inferred as [number, number, number]
get([1,2,3] as Array<number>, 1) // T inferred as number[]


declare function get2<T extends any[]>(tuple: T, index: number): T[number]
get2([1,2,3], 1) // T inferred as number[]
get2([1,2,3] as Array<number>, 1) // T inferred as number[]

Playground

Purtroppo TypeScript non ci permette di garantire a tempo di compilazione che l’accesso ad un elemento di un array sia sempre safe; per fare questo servirebbero i dependent type. Se ci limitiamo però alle tuple, ovvero ad array dichiarati staticamente, possiamo nuovamente rendere funzioni come la get più sicure. Nulla ci impedirà di accedere a tuple[500], però possiame costringere l’indice i proveniente dall’esterno ad essere corretto per la tupla in questione. Possiamo inoltre bloccare l’uso della funzione se il type system rileva in ingresso un array anziché una tupla.

La soluzione Link to heading

Per risolvere il problema abbiamo prima bisogno di creare alcune type function utili.

NumberToString Link to heading

type NumberToString<N extends number | bigint> = `${N}`;

Questa type function prende in ingresso un qualsiasi tipo numerico e restituisce il corrispondente tipo ma sottoforma di stringa:

const ten: NumberToString<10> =  "10"
const whatever: NumberToString<number> = "123"

Playground

Per fare ciò facciamo uso dei template literal types.

IndexesKeysOfTuple Link to heading

type IndexesKeysOfTuple<T extends any[]> = Exclude<
  keyof T & string,
  keyof any[]
>;

Questa type function, leggermente più complessa, restituisce una union di stringhe contenenti le chiavi numeriche utilizzabili in modo safe sulla tupla:

type test1 = IndexesKeysOfTuple<[]> // never
type test2 = IndexesKeysOfTuple<[1, 2, 3]> // "0" | "1" | "2"
type test3 = IndexesKeysOfTuple<Array<string>> // never

Playground

Notiamo che sia sulla tupla vuota che su un generico array restituisce never, indicante un insieme vuoto.

Cerchiamo ora di comprendere meglio la stregoneria che c’è dietro. In primo luogo keyof T & string restituisce l’unione di tutte le chiavi stringa presenti in una generica tupla; & string serve proprio per filtrare via le chiavi che non sono stringhe:

type StringKeys = keyof [1, 2, 3] & string
/*
"0" | "1" | "2" | "length" | "toString" | "toLocaleString" | "pop" | "push" | "concat"
| "join" | "reverse" | "shift" | "slice" | "sort" | "splice" | "unshift" | "indexOf" | "lastIndexOf"
| ... 14 more ... | "includes"
*/

Playground

D’altra parte keyof any[] restituisce l’unione di tutte le possibili chiavi stringa utilizzabili su un generico array, le quali sono praticamente tutte quelle utilizzabili su una tupla tranne chiavi come "0", "1", ecc. Ecco che Exclude andrà ad eliminare tutte le altre:

type StringKeys = Exclude<keyof [1, 2, 3] & string, keyof any[]> // "0" | "1" | "2"

Playground

ValidIndex Link to heading

type ValidIndex<T extends any[], I extends number> = 
  NumberToString<I> extends IndexesKeysOfTuple<T>
  ? I
  : never;

Questa type function semplicemente controlla se il tipo indice I, trasformato in stringa, fa effettivamente parte degli indici safe utilizzabili sulla tupla T. In caso positivo restituisce l’indice I stesso, altrimenti never:

type test1 = ValidIndex<[1, 2, 3], 1> // 1
type test2 = ValidIndex<[1, 2, 3], 4> // never
type test3 = ValidIndex<Array<string>, 1> // never

Playground

Osserviamo che se l’indice non è safe oppure se T è un tipo array, anziché un tipo tupla, allora il risultato è il tipo never.

Accesso safe Link to heading

Possiamo ora creare una qualsiasi funzione che prende in ingresso solo tuple e un indice corretto per accedere ad un qualsiasi elemento in piena sicurezza:

function fn<T extends any[], I extends number>(
  t: readonly [...T],
  i: ValidIndex<T, I>
): void {
  // ...
}

fn(["hi", "how", "are", "you", "?"], 2); // ok
fn(["hi", "how", "are", "you", "?"], 5); // error

fn(["hi", "how", "are", "you", "?"] as Array<string>, 2); // error
fn(["hi", "how", "are", "you", "?"] as Array<string>, 5); // error

Playground

L’errore che otteniamo, cioè Argument of type 'number' is not assignable to parameter of type 'never' deriva dal fatto che su input errati il tipo ValidIndex<T, I> si riduce a never, e valori di tipo never non ne esistono.