Beim Modellieren von Entitäten mit TypeScript ist es sehr üblich, eine Schnittstelle wie diese zu erhalten:
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 }
}
Das Problem
Die Typen der Eigenschaften haben keine semantische Bedeutung. In Bezug auf Typen sind User.id
, Order.id
, Order.year
usw. gleich: eine Zahl, und als Zahl sind sie austauschbar, aber semantisch gesehen sind sie es nicht.
Nach dem vorherigen Beispiel können wir eine Reihe von Funktionen haben, die Aktionen über die Entitäten ausführen. Zum Beispiel:
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
Diese Funktionen akzeptieren jede Zahl in einem beliebigen Argument, unabhängig von der semantischen Bedeutung der Zahl. Zum Beispiel:
const id = getUserId()
deleteOrder(id)
Offensichtlich ist das ein großer Fehler, und es könnte einfach erscheinen, den Code zu lesen zu vermeiden, aber der Code ist nicht immer so einfach wie das Beispiel.
Das Gleiche gilt für getOrdersFiltered
: Wir können die Werte von Tag und Monat vertauschen, und es wird keine Warnung oder Fehlermeldung geben. Die Fehler treten auf, wenn der Tag größer als 12 ist, aber es ist offensichtlich, dass das Ergebnis nicht das Erwartete sein wird.
Die Lösung
Die Regeln der Objektkalisthenik bieten eine Lösung dafür: Verpacken Sie alle Primitivtypen und Strings (Verwandtes Primitive Obsession Anti-Pattern). Die Regel besagt, dass die Primitiven in einem Objekt verpackt werden sollen, das eine semantische Bedeutung darstellt (DDD beschreibt das als ValueObjects
).
Aber mit TypeScript müssen wir dafür keine Klassen oder Objekte verwenden: Wir können das Typsystem verwenden, um sicherzustellen, dass eine Zahl, die etwas anderes als ein Jahr darstellt, nicht anstelle eines Jahres verwendet werden kann.
Markierte Typen
Dieses Muster nutzt die Erweiterbarkeit von Typen, um eine Eigenschaft hinzuzufügen, die die semantische Bedeutung sicherstellt:
type Year = number & { __brand: 'year' }
Diese einfache Zeile erstellt einen neuen Typ, der als Zahl fungieren kann – aber keine Zahl ist, sondern ein Jahr.
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) // ✅
Allgemeine Lösung
Um das Schreiben eines Typs pro Markentyp zu vermeiden, können wir einen Hilfstyp erstellen, der beispielsweise folgendermaßen aussieht:
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
Der ein eindeutiges Symbol als Markeneigenschaftsnamen verwendet, um Konflikte mit Ihren Eigenschaften zu vermeiden, und die ursprüngliche Typ- und Marken als generische Parameter erhält.
Damit können wir unsere Modelle und Funktionen wie folgt umstrukturieren:
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) { // ... }
Jetzt wird in diesem Beispiel der IDE ein Fehler angezeigt, da id
ein UserId
ist und deleteOrder
einen OrderId
erwartet.
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
Abwägungen
Als kleinen Kompromiss müssen Sie X
als Brand
verwenden. Zum Beispiel const year = 2012 as Year
, wenn Sie einen neuen Wert aus einem primitiven Wert erstellen, aber dies entspricht einem new Year(2012)
, wenn Sie Wertobjekte verwenden. Sie können eine Funktion bereitstellen, die als eine Art „Konstruktor“ fungiert:
function year(year: number): Year {
return year as Year
}
Validierung mit Markentypen
Markentypen sind auch nützlich, um sicherzustellen, dass die Daten gültig sind, da Sie spezifische Typen für validierte Daten haben können und Sie darauf vertrauen können, dass der Benutzer nur durch Verwendung von Typen validiert wurde:
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
ist nicht obligatorisch, aber um sicherzustellen, dass Ihr Code die Daten nach der Validierung nicht ändert, wird es sehr empfohlen.
Zusammenfassung
Markentypen sind eine einfache Lösung, die Folgendes umfasst:
- Verbessert die Lesbarkeit des Codes: Macht deutlicher, welcher Wert in jedem Argument verwendet werden sollte
- Zuverlässigkeit: Hilft, Fehler im Code zu vermeiden, die schwer zu erkennen sein können; jetzt hilft uns die IDE (und die Typüberprüfung), zu erkennen, ob der Wert am richtigen Platz ist
- Datenvalidierung: Sie können gebrandete Typen verwenden, um sicherzustellen, dass die Daten gültig sind.
Sie können sich gebrandete Typen als eine Art Version von ValueObjects
vorstellen, jedoch ohne Klassen zu verwenden – nur Typen und Funktionen.
Genießen Sie die Kraft der Typisierung!
Source:
https://dzone.com/articles/branded-types-in-typescript