Quando si modellano le entità con TypeScript, è molto comune ottenere un’interfaccia del genere:
interface User {
id: number
username: string
}
interface Order {
id: number
userId: number
title: string
year: number
month: number
day: number
amount: { currency: 'EUR' | 'USD', value: number }
}
Il Problema
I tipi delle proprietà non hanno alcun significato semantico. In termini di tipi, User.id
, Order.id
, Order.year
, ecc. sono uguali: un numero, e come numero sono interscambiabili, ma semanticamente, non lo sono.
Seguendo l’esempio precedente, possiamo avere un insieme di funzioni che eseguono azioni sulle entità. Per esempio:
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
Queste funzioni accetteranno qualsiasi numero in qualsiasi argomento, indipendentemente dal significato semantico del numero. Per esempio:
const id = getUserId()
deleteOrder(id)
Ovviamente, questo è un grande errore, e potrebbe sembrare facile evitarlo leggendo il codice, ma il codice non è sempre semplice come l’esempio.
Lo stesso accade con getOrdersFiltered
: possiamo scambiare i valori di giorno e mese, e non otterremo alcun avviso o errore. Gli errori si verificheranno se il giorno è maggiore di 12, ma è ovvio che il risultato non sarà quello atteso.
La Soluzione
Le regole delle calisteniche degli oggetti forniscono una soluzione per questo: incapsulare tutti i primitivi e le stringhe (relativo all’anti-pattern dell’ossessione per i primitivi). La regola è di incapsulare i primitivi in un oggetto che rappresenta un significato semantico (DDD descrive ciò come ValueObjects
).
Ma con TypeScript, non è necessario utilizzare classi o oggetti per questo: possiamo utilizzare il sistema di tipi per garantire che un numero che rappresenta qualcosa di diverso da un anno non possa essere utilizzato al posto di un anno.
Tipi Brandizzati
Questo modello utilizza l’estensibilità dei tipi per aggiungere una proprietà che garantisce il significato semantico:
type Year = number & { __brand: 'year' }
Questa semplice riga crea un nuovo tipo che può funzionare come un numero — ma non è un numero, è un anno.
const year = 2012 as Year
function age(year: Year): number { //... }
age(2012) // ❌ IDE will show an error as 2012 is not a Year
age(year) // ✅
Generalizzando la soluzione
Per evitare di scrivere un tipo per ogni tipo di marchio, possiamo creare un tipo di utilità come:
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
Che utilizza un simbolo unico come nome della proprietà del marchio per evitare conflitti con le tue proprietà e ottiene il tipo originale e il marchio come parametri generici.
Con questo, possiamo rifattorizzare i nostri modelli e funzioni come segue:
type UserId = Branded<number, 'UserId'>
type OrderId = Branded<number, 'OrderId'>
type Year = Branded<number, 'Year'>
type Month = Branded<number, 'Month'>
type Day = Branded<number, 'Day'>
type Amount = Branded<{ currency: 'EUR' | 'USD', value: number}, 'Amount'>
interface User {
id: UserId
username: string
}
interface Order {
id: OrderId
userId: UserId
title: string
year: Year
month: Month
day: Day
amount: Amount
}
function getOrdersFiltered(userId: UserId, year: Year, month: Month, day: Day, amount: Amount) { // ...}
function deleteOrder(id: OrderId) { // ... }
Ora, in questo esempio, l’IDE mostrerà un errore poiché id
è un UserId
e deleteOrder
si aspetta un OrderId
.
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
Compromessi
Come piccolo compromesso, dovrai usare X
come Brand
. Ad esempio, const year = 2012 as Year
quando crei un nuovo valore da un primitivo, ma questo è l’equivalente di un new Year(2012)
se usi oggetti valore. Puoi fornire una funzione che funge da una sorta di “costruttore”:
function year(year: number): Year {
return year as Year
}
Validazione con tipi di marchio
I tipi di marchio sono anche utili per garantire che i dati siano validi poiché puoi avere tipi specifici per dati validati, e puoi fidarti che l’utente sia stato convalidato semplicemente utilizzando i tipi:
type User = { id: UserId, email: Email}
type ValidUser = Readonly<Brand<User, 'ValidUser'>>
function validateUser(user: User): ValidUser {
// Checks if user is in the database
if (!/* logic to check the user is in database */) {
throw new InvalidUser()
}
return user as ValidUser
}
// We can not pass just a User, needs to be a ValidUser
function doSomethingWithAValidUser(user: ValidUser) {
}
Readonly
non è obbligatorio, ma per essere sicuro che il tuo codice non modifichi i dati dopo averli convalidati, è molto raccomandato.
Riepilogo
I tipi di marchio sono una soluzione semplice che include quanto segue:
- Migliora la leggibilità del codice: Rende più chiaro quale valore dovrebbe essere utilizzato in ciascun argomento
- Affidabilità: Aiuta a evitare errori nel codice che possono essere difficili da rilevare; ora l’IDE (e il controllo dei tipi) ci aiutano a rilevare se il valore è nel posto corretto
- Validazione dei dati: Puoi utilizzare i tipi marchiati per garantire che i dati siano validi.
Puoi pensare ai tipi marchiati come a una sorta di versione di ValueObjects
ma senza utilizzare classi — solo tipi e funzioni.
Goditi il potere dei tipi!
Source:
https://dzone.com/articles/branded-types-in-typescript