TypeScript confronta i tipi per forma, quindi due alias di string sono intercambiabili. Un branded type aggiunge un'etichetta che esiste solo nel sistema di tipi, string & { readonly __brand: "UserId" }, e questo rende un UserId incompatibile con una semplice stringa e con ogni altro brand:
A runtime userId è solo la stringa "u_42". Il brand è un'etichetta di compilazione, e il suo unico compito è impedire di confondere ID, unità di misura e stringhe validate.
Il problema: gli alias sono solo nomi
Un type alias non crea un nuovo tipo. Dà un secondo nome a un tipo esistente, e il compilatore tratta i due nomi come la stessa cosa:
È la tipizzazione strutturale: TypeScript controlla che la forma combaci, e string combacia con string. Per gli oggetti le forme di solito differiscono; per ID, email, valute e unità di misura non differiscono mai. I brand risolvono proprio questo caso.
Come funziona il brand
string & { readonly __brand: "UserId" } è un'intersezione: un valore deve essere una stringa e avere anche una proprietà __brand di tipo "UserId". Nessuna stringa reale ha quella proprietà, quindi nessuna semplice stringa le è assegnabile:
index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
Types of property '__brand' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.
La direzione che conta funziona ancora: un UserId è una stringa, quindi puoi passarlo a qualsiasi cosa accetti una stringa, chiamarci .startsWith() o inserirlo in un template. Il brand blocca solo la strada d'ingresso.
Funzioni costruttrici che validano
L'asserzione as UserId è l'unica strada d'ingresso, e un'asserzione non controlla nulla. Mettila in una funzione che valida l'input, e ogni valore con brand del programma avrà superato quel controllo:
sendWelcome non controlla mai più il suo input, perché il tipo del parametro dice che il controllo è già avvenuto. È l'idea di "parse, don't validate": controlla al confine, poi porta la prova nel tipo. Anche una type guard funziona da costruttore quando preferisci un booleano a un'eccezione: function isEmail(s: string): s is Email.
Un helper Brand generico
Scrivere a mano l'intersezione per ogni tipo diventa ripetitivo. Un piccolo generico lo fa una volta sola:
Dare un brand a un numero funziona come darlo a una stringa. Nota che amount / 100 è un semplice number: l'aritmetica su un numero con brand dà un risultato senza brand, come vedrai più avanti.
Brand con unique symbol
Un nome di proprietà stringa come __brand sembra una proprietà vera: userId.__brand supera il controllo dei tipi come "UserId" ma è undefined a runtime, e due librerie potrebbero scegliere lo stesso nome. Una chiave unique symbol evita entrambi i problemi:
declare const brand: unique symbol dichiara un simbolo che esiste solo per il type checker; la parola chiave declare significa che non viene emesso alcun JavaScript. Dato che il simbolo non viene esportato dal suo modulo, il codice di altri file non può nemmeno nominare la proprietà del brand, quindi fuori da quel modulo gli unici modi per ottenere un Meters sono le funzioni che esporti o un'asserzione as Meters.
I brand non costano nulla a runtime
L'output compilato non contiene traccia del brand. Queste sono le righe emesse per l'esempio Meters, sotto l'intestazione di modulo aggiunta dal compilatore: il declare, entrambi i type alias e ogni as sono spariti, e la chiamata soppressa sull'ultima riga viene eseguita lo stesso.
function toMeters(feet) {
return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);
Un valore con brand è il semplice primitivo: typeof dà "string" o "number", JSON.stringify lo scrive come al solito e i confronti funzionano come prima. Il rovescio della medaglia è che a runtime non viene controllato nulla, a meno che la tua funzione costruttrice non lo controlli. I dati analizzati da JSON, da un database o da un URL arrivano come string, e diventano un UserId solo quando li passi da quella funzione.
Aritmetica e metodi perdono il brand
Le operazioni su un valore con brand restituiscono il tipo base, perché il brand non fa parte di ciò che producono + o .slice():
type Cents = number & { readonly __brand: "Cents" };
const a = 500 as Cents;
const b = 250 as Cents;
const sum = a + b; // number, not Cents
const total: Cents = a + b; // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid
Di solito è ciò che vuoi: sommare due importi in centesimi dà centesimi, ma moltiplicare centesimi per centesimi no, e solo tu sai quali operazioni conservano il significato. Scrivi piccoli helper come addCents(a: Cents, b: Cents): Cents per le operazioni di cui il tuo codice ha bisogno.
Quando usare i branded type
Usa i brand dove confondere due valori dello stesso tipo primitivo è un rischio reale e il compilatore non può aiutarti in altro modo:
| Situazione | Brand di esempio |
|---|---|
| ID di tabelle diverse | UserId, OrderId, ProductId |
| Stringhe validate | Email, Url, NonEmptyString, Slug |
| Unità di misura e valute | Meters, Feet, Cents, Usd, Eur |
| Testo sanificato o con escape | SafeHtml, SqlIdentifier |
| Numeri con un intervallo | Percentage, PositiveInt |
Lasciali perdere per valori che non vengono mai confusi e per tipi oggetto che hanno già forme diverse. Le librerie di validazione possono produrre branded type da uno schema: in Zod, z.string().brand<"UserId">() dà uno schema il cui parse restituisce un UserId con brand, e ti risparmia di scrivere a mano le funzioni costruttrici.
Domande frequenti
Cosa sono i branded type in TypeScript?
Un pattern che rende incompatibili due tipi con la stessa rappresentazione a runtime. Intersechi il tipo base con un'etichetta che nessun valore normale possiede: type UserId = string & { readonly __brand: "UserId" }. Una semplice stringa, o un OrderId con un'etichetta diversa, viene allora rifiutata dove è atteso un UserId.
TypeScript ha tipi nominali?
No. Il sistema di tipi di TypeScript è strutturale: due tipi con la stessa forma sono intercambiabili, qualunque sia il loro nome. Le dichiarazioni di classe con membri private o #private si comportano in modo nominale, e i branded type sono il modo comune per ottenere lo stesso effetto per primitivi come stringhe e numeri.
I branded type hanno un costo a runtime?
No. Il brand esiste solo nel tipo. A runtime il valore è ancora una semplice stringa o un numero, senza proprietà extra, e il JavaScript compilato è identico a quello senza brand. L'unico codice a runtime è la validazione che scegli di mettere nella funzione che crea i valori con brand.
Come si crea un valore di un branded type?
Con una type assertion, idealmente in una piccola funzione che prima controlla l'input: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Tenere l'as in quell'unico punto significa che ogni Email del programma ha superato il controllo.
Che differenza c'è tra un type alias e un branded type?
type UserId = string è solo un nuovo nome: qualsiasi stringa viene accettata dove è atteso un UserId. type UserId = string & { readonly __brand: "UserId" } è un tipo nuovo e incompatibile: una semplice stringa deve prima passare da una funzione costruttrice o da un'asserzione.