Wanneer je entiteiten modelleert met TypeScript, is het heel gebruikelijk om een interface zoals deze te krijgen:
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 }
}
Het Probleem
De types van de eigenschappen hebben geen semantische betekenis. In termen van types zijn User.id
, Order.id
, Order.year
, enz. hetzelfde: een nummer, en als nummer zijn ze uitwisselbaar, maar semantisch zijn ze dat niet.
Volgend op het vorige voorbeeld kunnen we een set functies hebben die acties over de entiteiten uitvoeren. Bijvoorbeeld:
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
Die functies zullen elk nummer in elke arg accepteren, ongeacht de semantische betekenis van het nummer. Bijvoorbeeld:
const id = getUserId()
deleteOrder(id)
Het is duidelijk dat dit een grote fout is, en het lijkt misschien eenvoudig om te vermijden door de code te lezen, maar de code is niet altijd zo eenvoudig als het voorbeeld.
Hetzelfde gebeurt met getOrdersFiltered
: we kunnen de waarden van dag en maand verwisselen, en we zullen geen waarschuwing of foutmelding krijgen. De fouten zullen zich voordoen als de dag groter is dan 12, maar het is duidelijk dat het resultaat niet zal zijn wat je verwacht.
De Oplossing
De regels van objectcalisthenics bieden een oplossing hiervoor: verpak alle primitieve types en Strings (gerelateerd aan de anti-patroon van primitieve obsessie). De regel is om de primitieve types in een object te verpakken dat een semantische betekenis vertegenwoordigt (DDD beschrijft dit als ValueObjects
).
Maar met TypeScript hoeven we daarvoor geen klassen of objecten te gebruiken: we kunnen het typesysteem gebruiken om ervoor te zorgen dat een nummer dat iets anders dan een jaar vertegenwoordigt, niet kan worden gebruikt in plaats van een jaar.
Gemerkt Types
Dit patroon maakt gebruik van de uitbreidbaarheid van types om een eigenschap toe te voegen die de semantische betekenis waarborgt:
type Year = number & { __brand: 'year' }
Deze eenvoudige regel maakt een nieuw type dat kan functioneren als een nummer — maar is geen nummer, het is een jaar.
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) // ✅
Generalizing the Solution
Om te voorkomen dat we een type per merktype schrijven, kunnen we een hulptype maken zoals:
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
Dat een uniek symbool gebruikt als de merk-eigenschap naam om conflicten met uw eigenschappen te voorkomen en het oorspronkelijke type en het merk als generieke parameters krijgt.
Met dit kunnen we onze modellen en functies als volgt refactoren:
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) { // ... }
Nu zal de IDE in dit voorbeeld een fout tonen omdat id
een UserId
is en deleteOrder
een OrderId
verwacht.
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
Trade-Offs
Als een kleine trade-off moet u X
gebruiken als Brand
. Bijvoorbeeld, const year = 2012 as Year
wanneer u een nieuwe waarde van een primitief maakt, maar dit is gelijk aan een new Year(2012)
als u waarde-objecten gebruikt. U kunt een functie bieden die werkt als een soort “constructor”:
function year(year: number): Year {
return year as Year
}
Validatie Met Merktype
Merktype zijn ook nuttig om ervoor te zorgen dat de gegevens geldig zijn, aangezien u specifieke types voor gevalideerde gegevens kunt hebben, en u kunt erop vertrouwen dat de gebruiker gevalideerd was door alleen types te gebruiken:
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
is niet verplicht, maar om er zeker van te zijn dat uw code de gegevens niet wijzigt na validatie, wordt het sterk aanbevolen.
Recap
Merktype zijn een eenvoudige oplossing die het volgende omvat:
- Verbetert de leesbaarheid van de code: Maakt duidelijker welke waarde in elk argument moet worden gebruikt.
- Betrouwbaarheid: Helpt om fouten in de code te vermijden die moeilijk te detecteren zijn; nu helpt de IDE (en de typecontrole) ons om te detecteren of de waarde op de juiste plaats staat
- Gegevensvalidatie: Je kunt branded types gebruiken om ervoor te zorgen dat de gegevens geldig zijn.
Je kunt branded types zien als een soort versie van ValueObjects
maar zonder gebruik te maken van klassen — alleen types en functies.
Geniet van de kracht van typings!
Source:
https://dzone.com/articles/branded-types-in-typescript