Il problema Link to heading
Perché diamine l’accesso diretto alla proprietà property
nel corpo della access
è rotto male, mentre quello indiretto tramite la get
funziona bene?
function get<O, K extends keyof O>(o: O, k: K) {
return o[k]
}
function access<const T extends { property: unknown }>(to: T) {
return {
v1: to.property, // unknown :(
v2: get(to, "property") // T["property"] :D
}
}
const res = access({
property: "hi there"
})
res
// ^? const res: { v1: unknown; v2: "hi there" }
Spiegazione Link to heading
Quale è il tipo dell’accesso to.property
? Dato che to
ha tipo parametrico T
allora è ragionevole aspettarsi che to.property
abbia tipo T['property']
. Ed è così sulla carta, solo che questo genere di accessi viene risolto da TypeScript prematuramente (eagerly) nel corpo della funzione, ovvero in un contesto in cui il type parameter T
non è ancora noto. Questa è una scelta interna del compiler perché li mortacci loro.
Come viene risolto eagerly? TypeScript ha un po’ le mani legate poiché T
non è ancora noto, quindi sceglie di approssimare usando la constraint di T
al suo posto. La constraint di T
è { property: unknown }
, quindi TypeScript computa { property: unknown }['property']
che è esattamente unknown
.
Ecco che to.property
finisce per avere unknown
come tipo anziché T['property']
.
Nel caso dell’accesso indiretto tramite la get
niente viene risolto eagerly. L’invocazione get(to, "property")
restituisce il tipo T['property']
e tale tipo non viene ulteriormente computato.
Attenzione Link to heading
C’è quindi una importante differenza tra ciò che succede nel type level e ciò che succede nel value level per quanto concerne l’accesso diretto ad una proprietà:
- Nel value level TypeScript risolve eagerly il tipo dell’accesso diretto
to.property
, essendo quest’ultimoT['property']
, ma poichéT
non è noto il compiler si vede costretto ad approssimarlo, ma solo contestualmente all’espressioneto.property
. - Nel type level
T['property']
di base rimane deferred. Nel resto del corpo della funzione TypeScript non risolve eagerly il tipoT['property']
. Questo è il motivo per il quale laget
può usarlo come tipo di ritorno e la type assertionto.property as T['property']
risolve il problema.
Un barbatrucco Link to heading
Prendendo spunto dall’osservazione di Simone Pizzamiglio, si potrebbe tentare di fregare il compilatore usando { property: T['property'] }
come constraint per T
al posto di { property: unknown }
:
function access<const T extends { property: T['property'] }>(to: T) {
return {
v1: to.property, // T["property"] :D
v2: get(to, "property") // T["property"] :D
}
}
Quando TypeScript andrà a sostituire la constraint di T
al posto di T
per risolvere eagerly T['property']
si troverà a computare { property: T['property'] }['property']
, che è proprio pari a T['property']
! Per fortuna TypeScript non si accorge di essere tornato al punto di partenza, altrimenti proverebbe nuovamente a computare eagerly T['property']
e si ritroverebbe in un loop infinito.
Un evidente limite di questo barbatrucco risiede nell’impossibilità di impostare agevolmente una constraint diversa da unknown
per la key property
.
Generalizzando, si potrebbe pensare ad una soluzione come la seguente, dove ogni key viene intersecata alla constraint corrispondente:
function foo<T extends {
bar: T['bar'] & ConstraintForBar,
baz: T['baz'] & ConstraintForBaz,
// ...
}>(to: T) {
// ...
}
Purtroppo se una o più key constraint fossero un tipo oggetto anziché un tipo plain come number
o string
la constraint risultante per T
potrebbe non essere affatto quella preventivata.
Referenze Link to heading
Note that for a generic
T
and a non-genericK
, we eagerly resolveT[K]
if it originates in an expression. This is to preserve backwards compatibility.
— Linee 19035, 19036 e 19037 @ checker.ts
, commit d85767abfd83880cea17cea70f9913e9c4496dcc
.
Se vuoi dare un’occhiata a cosa succede dietro le quinte ho registrato questo piccolo video sull’argomento, nel quale vediamo brevemente come si comporta il compiler.