Ontdek TypeScript branded types, een krachtige techniek voor nominale typering in een structureel typesysteem. Leer hoe u typeveiligheid en codehelderheid verbetert.
TypeScript Branded Types: Nominale Typering in een Structureel Systeem
Het structurele typesysteem van TypeScript biedt flexibiliteit, maar kan soms leiden tot onverwacht gedrag. Branded types bieden een manier om nominale typering af te dwingen, wat de typeveiligheid en de helderheid van de code verbetert. Dit artikel verkent branded types in detail, met praktische voorbeelden en best practices voor de implementatie ervan.
Structurele vs. Nominale Typering Begrijpen
Voordat we dieper ingaan op branded types, lichten we het verschil tussen structurele en nominale typering toe.
Structurele Typering (Duck Typing)
In een structureel typesysteem worden twee types als compatibel beschouwd als ze dezelfde structuur hebben (d.w.z. dezelfde eigenschappen met dezelfde types). TypeScript gebruikt structurele typering. Bekijk dit voorbeeld:
interface Point {
x: number;
y: number;
}
interface Vector {
x: number;
y: number;
}
const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // Geldig in TypeScript
console.log(vector.x); // Output: 10
Hoewel Point
en Vector
als afzonderlijke types worden gedeclareerd, staat TypeScript toe dat een Point
-object wordt toegewezen aan een Vector
-variabele omdat ze dezelfde structuur delen. Dit kan handig zijn, maar het kan ook tot fouten leiden als u onderscheid moet maken tussen logisch verschillende types die toevallig dezelfde vorm hebben. Denk bijvoorbeeld aan coördinaten voor lengte- en breedtegraden die per ongeluk overeenkomen met schermpixelcoördinaten.
Nominale Typering
In een nominaal typesysteem worden types alleen als compatibel beschouwd als ze dezelfde naam hebben. Zelfs als twee types dezelfde structuur hebben, worden ze als verschillend behandeld als ze verschillende namen hebben. Talen zoals Java en C# gebruiken nominale typering.
De Noodzaak van Branded Types
De structurele typering van TypeScript kan problematisch zijn wanneer u moet garanderen dat een waarde tot een specifiek type behoort, ongeacht de structuur. Denk bijvoorbeeld aan het representeren van valuta's. U kunt verschillende types hebben voor USD en EUR, maar beide kunnen worden weergegeven als getallen. Zonder een mechanisme om ze te onderscheiden, zou u per ongeluk bewerkingen op de verkeerde valuta kunnen uitvoeren.
Branded types lossen dit probleem op door u in staat te stellen afzonderlijke types te creëren die structureel vergelijkbaar zijn, maar door het typesysteem als verschillend worden behandeld. Dit verhoogt de typeveiligheid en voorkomt fouten die anders onopgemerkt zouden blijven.
Branded Types Implementeren in TypeScript
Branded types worden geïmplementeerd met behulp van intersectietypes en een uniek symbool of een string-literal. Het idee is om een "merk" (brand) aan een type toe te voegen dat het onderscheidt van andere types met dezelfde structuur.
Symbolen Gebruiken (Aanbevolen)
Het gebruik van symbolen voor branding heeft over het algemeen de voorkeur omdat symbolen gegarandeerd uniek zijn.
const USD = Symbol('USD');
type USD = number & { readonly [USD]: unique symbol };
const EUR = Symbol('EUR');
type EUR = number & { readonly [EUR]: unique symbol };
function createUSD(value: number): USD {
return value as USD;
}
function createEUR(value: number): EUR {
return value as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);
const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);
// Het verwijderen van het commentaar op de volgende regel veroorzaakt een typefout
// const invalidOperation = addUSD(usd1, eur1);
In dit voorbeeld zijn USD
en EUR
branded types gebaseerd op het number
-type. Het unique symbol
zorgt ervoor dat deze types verschillend zijn. De functies createUSD
en createEUR
worden gebruikt om waarden van deze types te creëren, en de functie addUSD
accepteert alleen USD
-waarden. Een poging om een EUR
-waarde bij een USD
-waarde op te tellen, resulteert in een typefout.
String-literals Gebruiken
U kunt ook string-literals gebruiken voor branding, hoewel deze aanpak minder robuust is dan het gebruik van symbolen, omdat string-literals niet gegarandeerd uniek zijn.
type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };
function createUSD(value: number): USD {
return value as USD;
}
function createEUR(value: number): EUR {
return value as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);
const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);
// Het verwijderen van het commentaar op de volgende regel veroorzaakt een typefout
// const invalidOperation = addUSD(usd1, eur1);
Dit voorbeeld bereikt hetzelfde resultaat als het vorige, maar maakt gebruik van string-literals in plaats van symbolen. Hoewel het eenvoudiger is, is het belangrijk om ervoor te zorgen dat de string-literals die voor branding worden gebruikt, uniek zijn binnen uw codebase.
Praktische Voorbeelden en Gebruiksscenario's
Branded types kunnen worden toegepast in diverse scenario's waar u typeveiligheid moet afdwingen die verder gaat dan structurele compatibiliteit.
ID's
Stel u een systeem voor met verschillende soorten ID's, zoals UserID
, ProductID
en OrderID
. Al deze ID's kunnen worden weergegeven als getallen of strings, maar u wilt voorkomen dat verschillende ID-types per ongeluk door elkaar worden gehaald.
const UserIDBrand = Symbol('UserID');
type UserID = string & { readonly [UserIDBrand]: unique symbol };
const ProductIDBrand = Symbol('ProductID');
type ProductID = string & { readonly [ProductIDBrand]: unique symbol };
function getUser(id: UserID): { name: string } {
// ... haal gebruikersgegevens op
return { name: "Alice" };
}
function getProduct(id: ProductID): { name: string, price: number } {
// ... haal productgegevens op
return { name: "Example Product", price: 25 };
}
function createUserID(id: string): UserID {
return id as UserID;
}
function createProductID(id: string): ProductID {
return id as ProductID;
}
const userID = createUserID('user123');
const productID = createProductID('product456');
const user = getUser(userID);
const product = getProduct(productID);
console.log("User:", user);
console.log("Product:", product);
// Het verwijderen van het commentaar op de volgende regel veroorzaakt een typefout
// const invalidCall = getUser(productID);
Dit voorbeeld laat zien hoe branded types kunnen voorkomen dat een ProductID
wordt doorgegeven aan een functie die een UserID
verwacht, wat de typeveiligheid verhoogt.
Domeinspecifieke Waarden
Branded types kunnen ook nuttig zijn voor het representeren van domeinspecifieke waarden met beperkingen. U kunt bijvoorbeeld een type hebben voor percentages dat altijd tussen 0 en 100 moet liggen.
const PercentageBrand = Symbol('Percentage');
type Percentage = number & { readonly [PercentageBrand]: unique symbol };
function createPercentage(value: number): Percentage {
if (value < 0 || value > 100) {
throw new Error('Percentage moet tussen 0 en 100 liggen');
}
return value as Percentage;
}
function applyDiscount(price: number, discount: Percentage): number {
return price * (1 - discount / 100);
}
try {
const discount = createPercentage(20);
const discountedPrice = applyDiscount(100, discount);
console.log("Discounted Price:", discountedPrice);
// Het verwijderen van het commentaar op de volgende regel veroorzaakt een fout tijdens runtime
// const invalidPercentage = createPercentage(120);
} catch (error) {
console.error(error);
}
Dit voorbeeld toont hoe u een beperking op de waarde van een branded type tijdens runtime kunt afdwingen. Hoewel het typesysteem niet kan garanderen dat een Percentage
-waarde altijd tussen 0 en 100 ligt, kan de createPercentage
-functie deze beperking tijdens runtime afdwingen. U kunt ook bibliotheken zoals io-ts gebruiken om runtime-validatie van branded types af te dwingen.
Datum- en Tijdrepresentaties
Werken met datums en tijden kan lastig zijn vanwege verschillende formaten en tijdzones. Branded types kunnen helpen bij het onderscheiden van verschillende datum- en tijdrepresentaties.
const UTCDateBrand = Symbol('UTCDate');
type UTCDate = string & { readonly [UTCDateBrand]: unique symbol };
const LocalDateBrand = Symbol('LocalDate');
type LocalDate = string & { readonly [LocalDateBrand]: unique symbol };
function createUTCDate(dateString: string): UTCDate {
// Valideer dat de datumstring in UTC-formaat is (bijv. ISO 8601 met Z)
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
throw new Error('Ongeldig UTC-datumformaat');
}
return dateString as UTCDate;
}
function createLocalDate(dateString: string): LocalDate {
// Valideer dat de datumstring in lokaal datumformaat is (bijv. YYYY-MM-DD)
if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
throw new Error('Ongeldig lokaal datumformaat');
}
return dateString as LocalDate;
}
function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
// Voer tijdzoneconversie uit
const date = new Date(utcDate);
const localDateString = date.toLocaleDateString();
return createLocalDate(localDateString);
}
try {
const utcDate = createUTCDate('2024-01-20T10:00:00.000Z');
const localDate = convertUTCDateToLocalDate(utcDate);
console.log("UTC Date:", utcDate);
console.log("Local Date:", localDate);
} catch (error) {
console.error(error);
}
Dit voorbeeld maakt onderscheid tussen UTC- en lokale datums, zodat u zeker weet dat u met de juiste datum- en tijdrepresentatie werkt in verschillende delen van uw applicatie. Runtime-validatie zorgt ervoor dat alleen correct geformatteerde datumstrings aan deze types kunnen worden toegewezen.
Best Practices voor het Gebruik van Branded Types
Om branded types effectief te gebruiken in TypeScript, overweeg de volgende best practices:
- Gebruik Symbolen voor Branding: Symbolen bieden de sterkste garantie voor uniciteit, wat het risico op typefouten vermindert.
- Maak Hulpfuncties: Gebruik hulpfuncties om waarden van branded types te creëren. Dit biedt een centraal punt voor validatie en zorgt voor consistentie.
- Pas Runtime-validatie toe: Hoewel branded types de typeveiligheid verbeteren, voorkomen ze niet dat onjuiste waarden tijdens runtime worden toegewezen. Gebruik runtime-validatie om beperkingen af te dwingen.
- Documenteer Branded Types: Documenteer duidelijk het doel en de beperkingen van elk branded type om de onderhoudbaarheid van de code te verbeteren.
- Houd Rekening met Prestatie-implicaties: Branded types introduceren een kleine overhead vanwege het intersectietype en de noodzaak van hulpfuncties. Overweeg de impact op de prestaties in prestatiekritieke delen van uw code.
Voordelen van Branded Types
- Verbeterde Typeveiligheid: Voorkomt het per ongeluk door elkaar halen van structureel vergelijkbare maar logisch verschillende types.
- Verbeterde Codehelderheid: Maakt code leesbaarder en gemakkelijker te begrijpen door expliciet onderscheid te maken tussen types.
- Minder Fouten: Vangt potentiële fouten op tijdens het compileren, wat het risico op runtime-bugs vermindert.
- Verhoogde Onderhoudbaarheid: Maakt code gemakkelijker te onderhouden en te refactoren door een duidelijke scheiding van verantwoordelijkheden te bieden.
Nadelen van Branded Types
- Verhoogde Complexiteit: Voegt complexiteit toe aan de codebase, vooral bij het omgaan met veel branded types.
- Runtime Overhead: Introduceert een kleine runtime-overhead vanwege de noodzaak van hulpfuncties en runtime-validatie.
- Potentieel voor Boilerplate: Kan leiden tot boilerplate-code, met name bij het creëren en valideren van branded types.
Alternatieven voor Branded Types
Hoewel branded types een krachtige techniek zijn voor het bereiken van nominale typering in TypeScript, zijn er alternatieve benaderingen die u kunt overwegen.
Opaque Types
Opaque types lijken op branded types, maar bieden een meer expliciete manier om het onderliggende type te verbergen. TypeScript heeft geen ingebouwde ondersteuning voor opaque types, maar u kunt ze simuleren met modules en private symbolen.
Klassen
Het gebruik van klassen kan een meer objectgeoriënteerde benadering bieden voor het definiëren van afzonderlijke types. Hoewel klassen in TypeScript structureel getypeerd zijn, bieden ze een duidelijkere scheiding van verantwoordelijkheden en kunnen ze worden gebruikt om beperkingen af te dwingen via methoden.
Bibliotheken zoals `io-ts` of `zod`
Deze bibliotheken bieden geavanceerde runtime-typevalidatie en kunnen worden gecombineerd met branded types om zowel compile-time als runtime veiligheid te garanderen.
Conclusie
TypeScript branded types zijn een waardevol hulpmiddel om de typeveiligheid en codehelderheid in een structureel typesysteem te verbeteren. Door een "merk" (brand) aan een type toe te voegen, kunt u nominale typering afdwingen en het per ongeluk door elkaar halen van structureel vergelijkbare maar logisch verschillende types voorkomen. Hoewel branded types enige complexiteit en overhead met zich meebrengen, wegen de voordelen van verbeterde typeveiligheid en onderhoudbaarheid van de code vaak op tegen de nadelen. Overweeg het gebruik van branded types in scenario's waar u moet garanderen dat een waarde tot een specifiek type behoort, ongeacht de structuur.
Door de principes achter structurele en nominale typering te begrijpen en de best practices uit dit artikel toe te passen, kunt u branded types effectief inzetten om robuustere en beter onderhoudbare TypeScript-code te schrijven. Van het representeren van valuta's en ID's tot het afdwingen van domeinspecifieke beperkingen, branded types bieden een flexibel en krachtig mechanisme om de typeveiligheid in uw projecten te verhogen.
Terwijl u met TypeScript werkt, verken de verschillende technieken en bibliotheken die beschikbaar zijn voor typevalidatie en -handhaving. Overweeg het gebruik van branded types in combinatie met runtime-validatiebibliotheken zoals io-ts
of zod
om een alomvattende aanpak voor typeveiligheid te bereiken.