Progettare token type-safe senza generics espliciti
Un container DI ha bisogno di un modo per identificare un servizio. Le chiavi stringa sono la scelta più ovvia, ed è anche la più facile da sbagliare: due moduli non correlati possono scegliere la stessa stringa per due cose diverse, e un refuso nella stringa emerge come errore a runtime invece che in compilazione.
TinyDI usa invece i token. La parte interessante non è che i token esistano — molti container hanno una qualche nozione di token. È nel modo in cui un token di TinyDI permette a resolve() di conoscere il tipo di ritorno esatto, senza mai scrivere resolve<IMailService>(token).
Il problema: inferire T senza generics espliciti
L'obiettivo era che questo funzionasse così com'è, con il tipo a destra dedotto interamente da MailServiceToken:
// Nessun generic esplicito necessario — inferito come IMailService.
const mailService = container.resolve(MailServiceToken);
Questo significa che il token stesso deve trasportare IMailService in qualche modo, a livello di tipo, anche se a runtime un token è solo un identificatore — non c'è nulla da "trasportare" una volta che il programma è effettivamente in esecuzione.
La forma del token: identità tramite symbol, tipo fantasma
export interface Token<T> {
readonly symbol: symbol;
readonly description: string;
readonly __type?: T;
}
export function createToken<T>(description: string): Token<T> {
return {
symbol: Symbol(description),
description,
};
}
__type è tutto il trucco: è dichiarato nell'interfaccia unicamente per il type checker, marcato come opzionale, e non viene mai assegnato nell'implementazione. createToken restituisce un oggetto con solo symbol e description — nessuna proprietà __type esiste sull'oggetto reale a runtime. TypeScript lo usa comunque per trasportare T attraverso il tipo, ed è esattamente ciò che permette a resolve(token: Token<T>): T di inferire il tipo di ritorno corretto da qualunque token gli venga passato.
Un dettaglio che per poco non è passato inosservato
La prima versione funzionante di createToken effettuava un cast esplicito del valore di ritorno come Token<T>. La regola ESLint no-unnecessary-type-assertion lo ha segnalato: l'oggetto letterale { symbol, description } è già strutturalmente assegnabile a Token<T>, perché __type è opzionale. Il cast era puro rumore — rimuoverlo non ha cambiato nulla di ciò che il type checker accetta.
L'identità è il symbol, non la description
description è una semplice stringa, usata solo come etichetta leggibile nei messaggi di errore ("No registration found for token ..."). Non viene mai usata come chiave di lookup. Il container mappa le registrazioni su token.symbol, quindi due token creati con la stessa description restano due registrazioni distinte:
const a = createToken<string>('name');
const b = createToken<string>('name');
a.symbol !== b.symbol; // true — token distinti, nessuna collisione
Conseguenze
Questo esclude un'intera categoria di bug che i container a chiave stringa devono aggirare per convenzione (namespacing delle chiavi, lint per i duplicati): qui è strutturalmente impossibile che due chiamate createToken non correlate collidano, perché JavaScript garantisce che ogni chiamata a Symbol() produca un valore unico.
Il costo è quasi nullo — un'allocazione in più per token, creata una sola volta al caricamento del modulo, non a ogni risoluzione. È una forma rara per un compromesso: di solito una garanzia più forte costa qualcosa, a runtime o in ergonomia, e qui nessuno dei due costi si applica.
Questo è stato uno dei due punti della libreria core che hanno richiesto un vero lavoro di design invece di una scelta ovvia — la forma del token qui, e capire, dopo aver rilevato un ciclo, come spiegarlo a chi lo legge.