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éTnon è 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 lagetpuò 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
Tand 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.
