Utforska TypeScript branded types, en kraftfull teknik för att uppnå nominell typning i ett strukturellt typsystem. Lär dig hur du förbättrar typsäkerhet och kodtydlighet.
TypeScript Branded Types: Nominell typning i ett strukturellt system
TypeScript's strukturella typsystem erbjuder flexibilitet men kan ibland leda till oväntat beteende. Branded types är ett sätt att upprätthålla nominell typning, vilket förbättrar typsäkerheten och kodens tydlighet. Denna artikel utforskar branded types i detalj, med praktiska exempel och bästa praxis för deras implementering.
Förstå strukturell vs. nominell typning
Innan vi dyker in i branded types, låt oss klargöra skillnaden mellan strukturell och nominell typning.
Strukturell typning (Duck Typing)
I ett strukturellt typsystem anses två typer vara kompatibla om de har samma struktur (dvs. samma egenskaper med samma typer). TypeScript använder strukturell typning. Tänk på detta exempel:
interface Point {
x: number;
y: number;
}
interface Vector {
x: number;
y: number;
}
const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // Valid in TypeScript
console.log(vector.x); // Output: 10
Även om Point
och Vector
deklareras som distinkta typer, tillåter TypeScript att ett Point
-objekt tilldelas en Vector
-variabel eftersom de delar samma struktur. Detta kan vara bekvämt, men det kan också leda till fel om du behöver skilja mellan logiskt olika typer som råkar ha samma form. Tänk till exempel på koordinater för latitud/longitud som av en slump kan matcha skärmpixelkoordinater.
Nominell typning
I ett nominellt typsystem anses typer vara kompatibla endast om de har samma namn. Även om två typer har samma struktur behandlas de som distinkta om de har olika namn. Språk som Java och C# använder nominell typning.
Behovet av Branded Types
TypeScript's strukturella typning kan vara problematisk när du behöver säkerställa att ett värde tillhör en specifik typ, oavsett dess struktur. Tänk till exempel på att representera valutor. Du kan ha olika typer för USD och EUR, men båda kan representeras som tal. Utan en mekanism för att skilja dem åt kan du av misstag utföra operationer på fel valuta.
Branded types löser detta problem genom att låta dig skapa distinkta typer som är strukturellt lika men behandlas som olika av typsystemet. Detta förbättrar typsäkerheten och förhindrar fel som annars skulle kunna slinka igenom.
Implementera Branded Types i TypeScript
Branded types implementeras med hjälp av intersection-typer och en unik symbol eller strängliteral. Idén är att lägga till ett "märke" (brand) till en typ som skiljer den från andra typer med samma struktur.
Använda symboler (rekommenderas)
Att använda symboler för märkning (branding) är generellt att föredra eftersom symboler garanterat är unika.
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);
// Uncommenting the next line will cause a type error
// const invalidOperation = addUSD(usd1, eur1);
I det här exemplet är USD
och EUR
branded types baserade på number
-typen. Den unika symbolen säkerställer att dessa typer är distinkta. Funktionerna createUSD
och createEUR
används för att skapa värden av dessa typer, och funktionen addUSD
accepterar endast USD
-värden. Ett försök att addera ett EUR
-värde till ett USD
-värde kommer att resultera i ett typfel.
Använda strängliteraler
Du kan också använda strängliteraler för märkning, även om denna metod är mindre robust än att använda symboler eftersom strängliteraler inte garanterat är unika.
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);
// Uncommenting the next line will cause a type error
// const invalidOperation = addUSD(usd1, eur1);
Detta exempel uppnår samma resultat som det föregående, men använder strängliteraler istället för symboler. Även om det är enklare är det viktigt att säkerställa att de strängliteraler som används för märkning är unika inom din kodbas.
Praktiska exempel och användningsfall
Branded types kan tillämpas i olika scenarier där du behöver upprätthålla typsäkerhet utöver strukturell kompatibilitet.
ID:n
Tänk dig ett system med olika typer av ID:n, såsom UserID
, ProductID
och OrderID
. Alla dessa ID:n kan representeras som tal eller strängar, men du vill förhindra oavsiktlig blandning av olika ID-typer.
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 } {
// ... fetch user data
return { name: "Alice" };
}
function getProduct(id: ProductID): { name: string, price: number } {
// ... fetch product data
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);
// Uncommenting the next line will cause a type error
// const invalidCall = getUser(productID);
Detta exempel visar hur branded types kan förhindra att ett ProductID
skickas till en funktion som förväntar sig ett UserID
, vilket förbättrar typsäkerheten.
Domänspecifika värden
Branded types kan också vara användbara för att representera domänspecifika värden med begränsningar. Du kan till exempel ha en typ för procentandelar som alltid ska vara mellan 0 och 100.
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 must be between 0 and 100');
}
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);
// Uncommenting the next line will cause an error during runtime
// const invalidPercentage = createPercentage(120);
} catch (error) {
console.error(error);
}
Detta exempel visar hur man kan upprätthålla en begränsning på värdet av en branded type vid körtid. Även om typsystemet inte kan garantera att ett Percentage
-värde alltid är mellan 0 och 100, kan funktionen createPercentage
upprätthålla denna begränsning vid körtid. Du kan också använda bibliotek som io-ts för att upprätthålla validering av branded types vid körtid.
Representationer av datum och tid
Att arbeta med datum och tider kan vara knepigt på grund av olika format och tidszoner. Branded types kan hjälpa till att skilja mellan olika representationer av datum och tid.
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 {
// Validate that the date string is in UTC format (e.g., ISO 8601 with Z)
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
throw new Error('Invalid UTC date format');
}
return dateString as UTCDate;
}
function createLocalDate(dateString: string): LocalDate {
// Validate that the date string is in local date format (e.g., YYYY-MM-DD)
if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
throw new Error('Invalid local date format');
}
return dateString as LocalDate;
}
function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
// Perform time zone conversion
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);
}
Detta exempel skiljer mellan UTC- och lokala datum, vilket säkerställer att du arbetar med rätt datum- och tidsrepresentation i olika delar av din applikation. Validering vid körtid säkerställer att endast korrekt formaterade datumsträngar kan tilldelas dessa typer.
Bästa praxis för att använda Branded Types
För att effektivt använda branded types i TypeScript, överväg följande bästa praxis:
- Använd symboler för märkning: Symboler ger den starkaste garantin för unikhet, vilket minskar risken för typfel.
- Skapa hjälpfunktioner: Använd hjälpfunktioner för att skapa värden av branded types. Detta ger en central punkt för validering och säkerställer konsekvens.
- Tillämpa validering vid körtid: Även om branded types förbättrar typsäkerheten, förhindrar de inte att felaktiga värden tilldelas vid körtid. Använd validering vid körtid för att upprätthålla begränsningar.
- Dokumentera Branded Types: Dokumentera tydligt syftet och begränsningarna för varje branded type för att förbättra kodens underhållbarhet.
- Överväg prestandakonsekvenser: Branded types introducerar en liten overhead på grund av intersection-typen och behovet av hjälpfunktioner. Överväg prestandapåverkan i prestandakritiska delar av din kod.
Fördelar med Branded Types
- Förbättrad typsäkerhet: Förhindrar oavsiktlig blandning av strukturellt lika men logiskt olika typer.
- Förbättrad kodtydlighet: Gör koden mer läsbar och lättare att förstå genom att explicit skilja mellan typer.
- Minskade fel: Fångar potentiella fel vid kompilering, vilket minskar risken för buggar vid körtid.
- Ökad underhållbarhet: Gör koden lättare att underhålla och refaktorera genom att erbjuda en tydlig ansvarsfördelning.
Nackdelar med Branded Types
- Ökad komplexitet: Lägger till komplexitet i kodbasen, särskilt när man hanterar många branded types.
- Overhead vid körtid: Introducerar en liten overhead vid körtid på grund av behovet av hjälpfunktioner och validering.
- Risk för boilerplate: Kan leda till repetitiv kod (boilerplate), särskilt vid skapande och validering av branded types.
Alternativ till Branded Types
Även om branded types är en kraftfull teknik för att uppnå nominell typning i TypeScript, finns det alternativa metoder som du kan överväga.
Opaque Types
Opaque types liknar branded types men erbjuder ett mer explicit sätt att dölja den underliggande typen. TypeScript har inget inbyggt stöd för opaque types, men du kan simulera dem med hjälp av moduler och privata symboler.
Klasser
Att använda klasser kan erbjuda ett mer objektorienterat tillvägagångssätt för att definiera distinkta typer. Även om klasser är strukturellt typade i TypeScript, erbjuder de en tydligare ansvarsfördelning och kan användas för att upprätthålla begränsningar genom metoder.
Bibliotek som `io-ts` eller `zod`
Dessa bibliotek erbjuder sofistikerad typvalidering vid körtid och kan kombineras med branded types för att säkerställa säkerhet både vid kompilering och körtid.
Slutsats
TypeScript branded types är ett värdefullt verktyg för att förbättra typsäkerhet och kodtydlighet i ett strukturellt typsystem. Genom att lägga till ett "märke" (brand) till en typ kan du upprätthålla nominell typning och förhindra oavsiktlig blandning av strukturellt lika men logiskt olika typer. Även om branded types introducerar viss komplexitet och overhead, överväger fördelarna med förbättrad typsäkerhet och kodunderhållbarhet ofta nackdelarna. Överväg att använda branded types i scenarier där du behöver säkerställa att ett värde tillhör en specifik typ, oavsett dess struktur.
Genom att förstå principerna bakom strukturell och nominell typning, och genom att tillämpa bästa praxis som beskrivs i denna artikel, kan du effektivt utnyttja branded types för att skriva mer robust och underhållbar TypeScript-kod. Från att representera valutor och ID:n till att upprätthålla domänspecifika begränsningar, erbjuder branded types en flexibel och kraftfull mekanism för att förbättra typsäkerheten i dina projekt.
När du arbetar med TypeScript, utforska de olika tekniker och bibliotek som finns tillgängliga för typvalidering och upprätthållande. Överväg att använda branded types tillsammans med bibliotek för validering vid körtid som io-ts
eller zod
för att uppnå en heltäckande strategi för typsäkerhet.