Norsk

Utforsk TypeScript branded types, en kraftig teknikk for å oppnå nominell typing i et strukturelt typesystem. Lær hvordan du kan forbedre typesikkerhet og kodeklarhet.

TypeScript Branded Types: Nominell Typing i et Strukturelt System

TypeScripts strukturelle typesystem tilbyr fleksibilitet, men kan noen ganger føre til uventet oppførsel. Branded types gir en måte å håndheve nominell typing på, noe som forbedrer typesikkerhet og kodeklarhet. Denne artikkelen utforsker branded types i detalj, og gir praktiske eksempler og beste praksis for implementeringen.

Forstå Strukturell vs. Nominell Typing

Før vi dykker inn i branded types, la oss klargjøre forskjellen mellom strukturell og nominell typing.

Strukturell Typing (Duck Typing)

I et strukturelt typesystem anses to typer som kompatible hvis de har samme struktur (dvs. de samme egenskapene med de samme typene). TypeScript bruker strukturell typing. Se på dette eksempelet:


interface Point {
  x: number;
  y: number;
}

interface Vector {
  x: number;
  y: number;
}

const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // Gyldig i TypeScript

console.log(vector.x); // Utdata: 10

Selv om Point og Vector er deklarert som distinkte typer, tillater TypeScript å tildele et Point-objekt til en Vector-variabel fordi de deler samme struktur. Dette kan være praktisk, men det kan også føre til feil hvis du trenger å skille mellom logisk forskjellige typer som tilfeldigvis har samme form. For eksempel, tenk på koordinater for breddegrad/lengdegrad som tilfeldigvis kan matche skjermpikselkoordinater.

Nominell Typing

I et nominelt typesystem anses typer som kompatible kun hvis de har samme navn. Selv om to typer har samme struktur, blir de behandlet som distinkte hvis de har forskjellige navn. Språk som Java og C# bruker nominell typing.

Behovet for Branded Types

TypeScripts strukturelle typing kan være problematisk når du trenger å sikre at en verdi tilhører en spesifikk type, uavhengig av dens struktur. For eksempel, tenk på representasjon av valutaer. Du kan ha forskjellige typer for USD og EUR, men begge kan representeres som tall. Uten en mekanisme for å skille dem, kan du ved et uhell utføre operasjoner på feil valuta.

Branded types løser dette problemet ved å la deg lage distinkte typer som er strukturelt like, men som behandles som forskjellige av typesystemet. Dette forbedrer typesikkerheten og forhindrer feil som ellers kunne ha sluppet gjennom.

Implementering av Branded Types i TypeScript

Branded types implementeres ved hjelp av intersection-typer og et unikt symbol eller en strengliteral. Ideen er å legge til et "merke" (brand) til en type som skiller den fra andre typer med samme struktur.

Bruke Symboler (Anbefalt)

Å bruke symboler for merking er generelt foretrukket fordi symboler er garantert å være unike.


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);

// Hvis du fjerner kommentaren på neste linje, vil det forårsake en typefeil
// const invalidOperation = addUSD(usd1, eur1);

I dette eksempelet er USD og EUR branded types basert på number-typen. Det unike symbolet (unique symbol) sikrer at disse typene er distinkte. Funksjonene createUSD og createEUR brukes til å lage verdier av disse typene, og addUSD-funksjonen aksepterer bare USD-verdier. Forsøk på å legge en EUR-verdi til en USD-verdi vil resultere i en typefeil.

Bruke Strengliteraler

Du kan også bruke strengliteraler for merking, selv om denne tilnærmingen er mindre robust enn å bruke symboler fordi strengliteraler ikke er garantert å være unike.


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);

// Hvis du fjerner kommentaren på neste linje, vil det forårsake en typefeil
// const invalidOperation = addUSD(usd1, eur1);

Dette eksempelet oppnår samme resultat som det forrige, men bruker strengliteraler i stedet for symboler. Selv om det er enklere, er det viktig å sikre at strengliteralene som brukes til merking er unike i kodebasen din.

Praktiske Eksempler og Bruksområder

Branded types kan brukes i ulike scenarioer der du trenger å håndheve typesikkerhet utover strukturell kompatibilitet.

ID-er

Tenk deg et system med forskjellige typer ID-er, som UserID, ProductID og OrderID. Alle disse ID-ene kan representeres som tall eller strenger, men du vil forhindre utilsiktet blanding av forskjellige 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 } {
  // ... hent brukerdata
  return { name: "Alice" };
}

function getProduct(id: ProductID): { name: string, price: number } {
  // ... hent produktdata
  return { name: "Eksempelprodukt", 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);

// Hvis du fjerner kommentaren på neste linje, vil det forårsake en typefeil
// const invalidCall = getUser(productID);

Dette eksempelet demonstrerer hvordan branded types kan forhindre at en ProductID sendes til en funksjon som forventer en UserID, noe som forbedrer typesikkerheten.

Domene-spesifikke Verdier

Branded types kan også være nyttige for å representere domenespesifikke verdier med begrensninger. For eksempel kan du ha en type for prosenter som alltid skal være mellom 0 og 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);

  // Hvis du fjerner kommentaren på neste linje, vil det forårsake en feil under kjøring
  // const invalidPercentage = createPercentage(120);
} catch (error) {
  console.error(error);
}

Dette eksempelet viser hvordan man kan håndheve en begrensning på verdien av en branded type under kjøring (runtime). Selv om typesystemet ikke kan garantere at en Percentage-verdi alltid er mellom 0 og 100, kan createPercentage-funksjonen håndheve denne begrensningen under kjøring. Du kan også bruke biblioteker som io-ts for å håndheve runtime-validering av branded types.

Representasjoner av Dato og Tid

Å jobbe med datoer og tider kan være vanskelig på grunn av ulike formater og tidssoner. Branded types kan hjelpe med å skille mellom forskjellige representasjoner av dato og 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 {
  // Valider at datostrengen er i UTC-format (f.eks. ISO 8601 med 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 {
  // Valider at datostrengen er i lokalt datoformat (f.eks. 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 {
  // Utfør tidssonekonvertering
  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);
}

Dette eksempelet skiller mellom UTC- og lokale datoer, og sikrer at du jobber med riktig dato- og tidsrepresentasjon i ulike deler av applikasjonen din. Runtime-validering sikrer at kun korrekt formaterte datostrenger kan tildeles disse typene.

Beste Praksis for Bruk av Branded Types

For å bruke branded types effektivt i TypeScript, bør du vurdere følgende beste praksis:

Fordeler med Branded Types

Ulemper med Branded Types

Alternativer til Branded Types

Selv om branded types er en kraftig teknikk for å oppnå nominell typing i TypeScript, finnes det alternative tilnærminger du kan vurdere.

Opaque Typer

Opaque typer ligner på branded types, men gir en mer eksplisitt måte å skjule den underliggende typen. TypeScript har ikke innebygd støtte for opaque typer, men du kan simulere dem ved hjelp av moduler og private symboler.

Klasser

Bruk av klasser kan gi en mer objektorientert tilnærming til å definere distinkte typer. Selv om klasser er strukturelt typet i TypeScript, tilbyr de en klarere ansvarsfordeling og kan brukes til å håndheve begrensninger gjennom metoder.

Biblioteker som io-ts eller zod

Disse bibliotekene tilbyr sofistikert runtime-typevalidering og kan kombineres med branded types for å sikre både kompileringstids- og kjøretidssikkerhet.

Konklusjon

TypeScript branded types er et verdifullt verktøy for å forbedre typesikkerhet og kodeklarhet i et strukturelt typesystem. Ved å legge til et "merke" (brand) til en type, kan du håndheve nominell typing og forhindre utilsiktet blanding av strukturelt like, men logisk forskjellige typer. Selv om branded types introduserer noe kompleksitet og overhead, veier fordelene med forbedret typesikkerhet og vedlikeholdbarhet ofte opp for ulempene. Vurder å bruke branded types i scenarioer der du trenger å sikre at en verdi tilhører en spesifikk type, uavhengig av dens struktur.

Ved å forstå prinsippene bak strukturell og nominell typing, og ved å anvende beste praksis som er beskrevet i denne artikkelen, kan du effektivt utnytte branded types for å skrive mer robust og vedlikeholdbar TypeScript-kode. Fra å representere valutaer og ID-er til å håndheve domenespesifikke begrensninger, gir branded types en fleksibel og kraftig mekanisme for å forbedre typesikkerheten i prosjektene dine.

Når du jobber med TypeScript, utforsk de ulike teknikkene og bibliotekene som er tilgjengelige for typevalidering og -håndhevelse. Vurder å bruke branded types i kombinasjon med runtime-valideringsbiblioteker som io-ts eller zod for å oppnå en helhetlig tilnærming til typesikkerhet.