Series: TypeScript
Introduzione Link to heading
La ricorsione, incubo di ogni sviluppatore wannabe, la troviamo in TypeScript anche nel type system, specialmente dalla versione 3.7
che ne ha notevolmente aumentato il supporto. Ad esempio è possibile rappresentare il JSON
in questo modo:
type JSON =
| string
| number
| boolean
| null
| { [property: string]: JSON }
| JSON[];
Il problema che discuteremo in questo episodio, estrapolato da un caso d’uso reale, ha a che fare proprio con un tipo ricorsivo e con la necessità di estenderlo.
Il problema Link to heading
Il tipo ricorsivo con cui abbiamo a che fare è il seguente:
type Recursive<D extends string> = {
[K in D]: string;
} & {
key: string;
children: Recursive<D>[];
};
In particolare abbiamo almeno una chiave dinamica, D
, una chiave key
e una chiave children
, che è dove la ricorsione ha luogo, contenente appunto un array di entità aventi il medesimo tipo di quella che stiamo qua definendo.
Il codice problematico è il seguente:
type FlattenRecursive =
<D extends string, R extends Recursive<D>>(rs: R[]) => Omit<R, "children">[]
const flatten: FlattenRecursive =
rs => rs.flatMap(r => flatten(r.children))
Semplicemente vorremmo spacchettare la struttura ricorsiva in un array flat di oggetti di tipo R extends Recursive<D>
privati della proprietà children
, che appunto è stata ricorsivamente appiattita. TypeScript però non è molto d’accordo:
Type 'Omit<Recursive<D>, "children">[]' is not assignable to type 'Omit<R, "children">[]'.
Type 'Omit<Recursive<D>, "children">' is not assignable to type 'Omit<R, "children">'.
Type 'Exclude<keyof R, "children">' is not assignable to type '"key" | Exclude<D, "children">'.
Type 'keyof R' is not assignable to type '"key" | Exclude<D, "children">'.
Type 'string | number | symbol' is not assignable to type '"key" | Exclude<D, "children">'.
Type 'string | number | symbol' is not assignable to type '"key" | Exclude<D, "children">'.
Type 'string' is not assignable to type '"key" | Exclude<D, "children">'.
Keep calm e concentriamoci sulla prima riga: stiamo erroneamente cercando di assegnare qualcosa di più generale, un Omit<Recursive<D>, "children">[]
, dove è richiesto un qualcosa di più specifico, ovvero un Omit<R, "children">[]
. In effetti il tipo di r.children
è tipizzato come un generico Recursive<D>
, quindi la flatten
ricorsiva può solo restituire un Omit<Recursive<D>, "children">[]
.
Il nocciolo del problema sta nel fatto che Recursive<D>
non è assolutamente obbligato ad essere uguale al tipo R
:
type RValueAnimal = Recursive<"value"> & { animal: string }
type RValuePerson = Recursive<"value"> & { person: string }
const rAnimals: RValueAnimal[] = [
{
key: "string1",
value: "v1",
animal: "a1",
children: []
},
{
key: "string2",
value: "v2",
animal: "a2",
children: []
},
{
key: "string3",
value: "v3",
animal: "a3",
children: []
},
]
const rPerson: RValuePerson = {
key: "string4",
value: "v4",
person: "p1",
children: rAnimals // <-- qualquadra non cosa
}
Nell’esempio creiamo due diversi tipi che estendono il medesimo Recursive<D> = Recursive<"value">
. La definizione corrente del tipo Recursive
non ci impedisce di assegnare alla chiave children
di un RValuePerson
un array di RValueAnimal
, o viceversa, perché children
ha l’unica costrain di essere un Recursive<"value">
e sia RValuePerson
che RValueAnimal
soddisfano questo vincolo. Se invocassimo la flatten
su rPerson.children
l’array risultante sarebbe un Omit<RValueAnimal, "children">[]
che non ha nulla a che vedere con il tipo Omit<RValuePerson, "children">[]
atteso.
Come possiamo impedire di avere l’array children
out of sync?
La soluzione Link to heading
Nelle interfacce che creiamo in TypeScript possiamo referenziare il tipo corrente. La cosa interessante è il fatto che il this
type rimane coerente con eventuali estensioni di una interfaccia:
interface Rec {
rec: this
}
interface RecExt extends Rec {
prop: number
}
type RexExt2 = RecExt & { key: string }
type r = Rec["rec"] // Rec
type re = RecExt["rec"] // RecExt
type re2 = RexExt2["rec"] // RexExt2
Modifichiamo quindi il tipo Recursive
come segue:
interface Rec {
key: string;
children: this[]; // <-- notare il this
}
type Recursive<D extends string> = {
[K in D]: string;
} & Rec;
Siamo costretti a separare le due parti sia perché il tipo this
può essere utilizzato solo nelle interfacce, sia perché ai mapped type non piace che siano dichiarate in parallelo altre proprietà. La cosa importante è il fatto che ogni volta che Rec
o Recursive
verranno estesi, il this
si allineerà alla chain “puntando” sempre al “tipo corrente”.
Riprendiamo prima in mano l’esempio di RValuePerson
e RValueAnimal
:
type RValueAnimal = Recursive<"value"> & { animal: string }
type RValuePerson = Recursive<"value"> & { person: string }
const rAnimals: RValueAnimal[] = [
{
key: "string1",
value: "v1",
animal: "a1",
children: []
},
{
key: "string2",
value: "v2",
animal: "a2",
children: []
},
{
key: "string3",
value: "v3",
animal: "a3",
children: []
},
]
const rPerson: RValuePerson = {
key: "string4",
value: "v4",
person: "p1",
children: rAnimals // <- adesso è un errore
}
Adesso non possiamo più assegnare un array di RValueAnimal
alla propietà children
di un RValuePerson
, perché grazie al this
essa è correttamente tipizzata come RValuePerson[]
.
Ed ecco che abbiamo risolto il problema iniziale, in quanto r.children
adesso è correttamente tipizzato come R[]
e quindi la chiamata ricorsiva a flatten
restituisce l’Omit<R, "children">[]
desiderato:
interface Rec {
key: string;
children: this[];
}
type Recursive<D extends string> = {
[K in D]: string;
} & Rec;
type FlattenRecursive =
<D extends string, R extends Recursive<D>>(rs: R[]) => Omit<R, "children">[]
const flatten: FlattenRecursive =
rs => rs.flatMap(r => flatten(r.children))
TypeScript colpisce ancora Link to heading
Vi è purtroppo un secondo problema, e ce ne accorgiamo se proviamo a invocare la flatten
sull’array rAnimals
:
const rAnimals: RValueAnimal[] = [
{
key: "string1",
value: "v1",
animal: "a1",
children: []
},
{
key: "string2",
value: "v2",
animal: "a2",
children: []
},
{
key: "string3",
value: "v3",
animal: "a3",
children: []
},
]
const flatten: FlattenRecursive =
rs => rs.flatMap(r => flatten(r.children))
flatten(rAnimals) // <-- Argument of type 'RValueAnimal[]' is not assignable to parameter of type 'Recursive<string>[]'
Notiamo che i type parameter inferiti nella chiamata sono string
e Recursive<string>>
anziché i "value"
e Recursive<"value">>
attesi:
const flatten: <string, Recursive<string>>(rs: Recursive<string>[]) => Omit<Recursive<string>, "children">[]
Ci stiamo scontrando con una limitazione del type system, il quale non utilizza le costrain sui generici come punti dai quali fare inferenza per altri generici. Ecco che il type parameter D
che compare nella costrain di R
non può essere inferito da quest’ultimo, il quale ipotrebbe essere determinato a partire dall’argomento rAnimals
, e quindi viene considerato pari al suo upper bound string
come tipo di ripiego.
Per risolvere questo primo problema sfruttiamo il fatto che il tipo Recursive
è ora intersezione di due tipi per ristrutturare il tipo della flatten
:
type FlattenRecursive =
<R extends Rec>(rs: R[]) => Omit<R, "children">[]
Poiché la property children
appartiene all’interfaccia Rec
necessitiamo solo di essa come upper bound del type parameter R
. Esso è l’unico type parameter che ci interessa davvero, e TypeScript è in grado di inferirlo correttamente.