Odkryj branded types w TypeScript, potężną technikę do osiągania typowania nominalnego w strukturalnym systemie typów. Dowiedz się, jak zwiększyć bezpieczeństwo typów i klarowność kodu.
Branded Types w TypeScript: Typowanie nominalne w systemie strukturalnym
Strukturalny system typów TypeScript oferuje elastyczność, ale czasami może prowadzić do nieoczekiwanych zachowań. Branded types (typy naznaczone) dostarczają sposobu na wymuszenie typowania nominalnego, zwiększając bezpieczeństwo typów i klarowność kodu. Ten artykuł szczegółowo omawia branded types, dostarczając praktycznych przykładów i najlepszych praktyk ich implementacji.
Zrozumienie typowania strukturalnego a nominalnego
Zanim zagłębimy się w branded types, wyjaśnijmy różnicę między typowaniem strukturalnym a nominalnym.
Typowanie strukturalne (Duck Typing)
W systemie typowania strukturalnego dwa typy są uważane za kompatybilne, jeśli mają tę samą strukturę (tj. te same właściwości o tych samych typach). TypeScript używa typowania strukturalnego. Rozważ ten przykład:
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
Mimo że Point
i Vector
są zadeklarowane jako odrębne typy, TypeScript pozwala przypisać obiekt Point
do zmiennej Vector
, ponieważ mają tę samą strukturę. Może to być wygodne, ale może również prowadzić do błędów, gdy trzeba rozróżnić logicznie różne typy, które przypadkowo mają ten sam kształt. Na przykład, myśląc o współrzędnych geograficznych (długość/szerokość), które mogą przypadkowo pasować do współrzędnych pikseli na ekranie.
Typowanie nominalne
W systemie typowania nominalnego typy są uważane za kompatybilne tylko wtedy, gdy mają tę samą nazwę. Nawet jeśli dwa typy mają tę samą strukturę, są traktowane jako odrębne, jeśli mają różne nazwy. Języki takie jak Java i C# używają typowania nominalnego.
Potrzeba stosowania Branded Types
Typowanie strukturalne w TypeScript może być problematyczne, gdy trzeba zapewnić, że wartość należy do określonego typu, niezależnie od jej struktury. Na przykład, rozważmy reprezentowanie walut. Możesz mieć różne typy dla USD i EUR, ale oba mogą być reprezentowane jako liczby. Bez mechanizmu do ich rozróżniania, można by przypadkowo wykonać operacje na niewłaściwej walucie.
Branded types rozwiązują ten problem, pozwalając na tworzenie odrębnych typów, które są strukturalnie podobne, ale traktowane jako różne przez system typów. Zwiększa to bezpieczeństwo typów i zapobiega błędom, które w innym przypadku mogłyby zostać przeoczone.
Implementacja Branded Types w TypeScript
Branded types są implementowane przy użyciu typów przecięcia (intersection types) oraz unikalnego symbolu lub literału ciągu znaków. Chodzi o to, aby dodać "znak" (brand) do typu, który odróżnia go od innych typów o tej samej strukturze.
Używanie symboli (zalecane)
Używanie symboli do "brandowania" jest ogólnie preferowane, ponieważ symbole mają gwarancję unikalności.
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);
W tym przykładzie USD
i EUR
to typy naznaczone oparte na typie number
. unique symbol
zapewnia, że te typy są odrębne. Funkcje createUSD
i createEUR
służą do tworzenia wartości tych typów, a funkcja addUSD
akceptuje tylko wartości USD
. Próba dodania wartości EUR
do wartości USD
spowoduje błąd typu.
Używanie literałów ciągu znaków
Można również używać literałów ciągu znaków do "brandowania", chociaż to podejście jest mniej solidne niż używanie symboli, ponieważ literały ciągów znaków nie mają gwarancji unikalności.
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);
Ten przykład osiąga ten sam rezultat co poprzedni, ale używa literałów ciągu znaków zamiast symboli. Chociaż jest to prostsze, ważne jest, aby upewnić się, że literały używane do "brandowania" są unikalne w obrębie Twojej bazy kodu.
Praktyczne przykłady i przypadki użycia
Branded types można zastosować w różnych scenariuszach, w których trzeba wymusić bezpieczeństwo typów wykraczające poza kompatybilność strukturalną.
Identyfikatory (ID)
Rozważmy system z różnymi typami identyfikatorów, takimi jak UserID
, ProductID
i OrderID
. Wszystkie te identyfikatory mogą być reprezentowane jako liczby lub ciągi znaków, ale chcemy zapobiec przypadkowemu mieszaniu różnych typów ID.
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);
Ten przykład pokazuje, jak branded types mogą zapobiec przekazaniu ProductID
do funkcji, która oczekuje UserID
, zwiększając bezpieczeństwo typów.
Wartości specyficzne dla domeny
Branded types mogą być również przydatne do reprezentowania wartości specyficznych dla domeny z ograniczeniami. Na przykład, możesz mieć typ dla wartości procentowych, które zawsze powinny mieścić się w zakresie od 0 do 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);
}
Ten przykład pokazuje, jak wymusić ograniczenie na wartości typu naznaczonego w czasie wykonania. Chociaż system typów nie może zagwarantować, że wartość Percentage
zawsze będzie między 0 a 100, funkcja createPercentage
może wymusić to ograniczenie w czasie wykonania. Można również użyć bibliotek, takich jak io-ts, do wymuszania walidacji typów nazwanych w czasie wykonania.
Reprezentacje daty i czasu
Praca z datami i godzinami może być trudna ze względu na różne formaty i strefy czasowe. Branded types mogą pomóc w rozróżnieniu różnych reprezentacji daty i czasu.
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);
}
Ten przykład rozróżnia daty UTC i lokalne, zapewniając, że pracujesz z poprawną reprezentacją daty i czasu w różnych częściach aplikacji. Walidacja w czasie wykonania zapewnia, że tylko poprawnie sformatowane ciągi znaków daty mogą być przypisane do tych typów.
Dobre praktyki używania Branded Types
Aby efektywnie używać branded types w TypeScript, rozważ następujące dobre praktyki:
- Używaj symboli do brandowania: Symbole zapewniają najsilniejszą gwarancję unikalności, zmniejszając ryzyko błędów typów.
- Twórz funkcje pomocnicze: Używaj funkcji pomocniczych do tworzenia wartości typów nazwanych. Zapewnia to centralny punkt walidacji i gwarantuje spójność.
- Stosuj walidację w czasie wykonania: Chociaż branded types zwiększają bezpieczeństwo typów, nie zapobiegają przypisywaniu nieprawidłowych wartości w czasie wykonania. Używaj walidacji w czasie wykonania, aby wymusić ograniczenia.
- Dokumentuj Branded Types: Jasno dokumentuj cel i ograniczenia każdego typu naznaczonego, aby poprawić utrzymywalność kodu.
- Rozważ implikacje wydajnościowe: Branded types wprowadzają niewielki narzut z powodu typu przecięcia i potrzeby stosowania funkcji pomocniczych. Rozważ wpływ na wydajność w krytycznych sekcjach kodu.
Zalety Branded Types
- Zwiększone bezpieczeństwo typów: Zapobiega przypadkowemu mieszaniu typów strukturalnie podobnych, ale logicznie różnych.
- Poprawiona klarowność kodu: Czyni kod bardziej czytelnym i łatwiejszym do zrozumienia poprzez jawne rozróżnianie typów.
- Mniej błędów: Wyłapuje potencjalne błędy w czasie kompilacji, zmniejszając ryzyko błędów w czasie wykonania.
- Zwiększona utrzymywalność: Ułatwia utrzymanie i refaktoryzację kodu poprzez wyraźne oddzielenie odpowiedzialności.
Wady Branded Types
- Zwiększona złożoność: Dodaje złożoności do bazy kodu, zwłaszcza przy dużej liczbie typów nazwanych.
- Narzut w czasie wykonania: Wprowadza niewielki narzut w czasie wykonania z powodu potrzeby funkcji pomocniczych i walidacji.
- Potencjalny boilerplate: Może prowadzić do powtarzalnego kodu (boilerplate), zwłaszcza przy tworzeniu i walidacji typów nazwanych.
Alternatywy dla Branded Types
Chociaż branded types są potężną techniką do osiągania typowania nominalnego w TypeScript, istnieją alternatywne podejścia, które można rozważyć.
Typy nieprzezroczyste (Opaque Types)
Typy nieprzezroczyste są podobne do branded types, ale zapewniają bardziej jawny sposób na ukrycie typu bazowego. TypeScript nie ma wbudowanego wsparcia dla typów nieprzezroczystych, ale można je symulować za pomocą modułów i prywatnych symboli.
Klasy
Użycie klas może zapewnić bardziej zorientowane obiektowo podejście do definiowania odrębnych typów. Chociaż klasy w TypeScript są typowane strukturalnie, oferują jaśniejsze oddzielenie odpowiedzialności i mogą być używane do wymuszania ograniczeń za pomocą metod.
Biblioteki takie jak `io-ts` czy `zod`
Te biblioteki zapewniają zaawansowaną walidację typów w czasie wykonania i mogą być łączone z branded types, aby zapewnić bezpieczeństwo zarówno w czasie kompilacji, jak i w czasie wykonania.
Podsumowanie
Branded types w TypeScript to cenne narzędzie do zwiększania bezpieczeństwa typów i klarowności kodu w systemie typowania strukturalnego. Dodając "znak" do typu, można wymusić typowanie nominalne i zapobiec przypadkowemu mieszaniu typów strukturalnie podobnych, ale logicznie różnych. Chociaż branded types wprowadzają pewną złożoność i narzut, korzyści płynące z poprawy bezpieczeństwa typów i utrzymywalności kodu często przeważają nad wadami. Rozważ użycie branded types w scenariuszach, w których musisz zapewnić, że wartość należy do określonego typu, niezależnie od jej struktury.
Rozumiejąc zasady typowania strukturalnego i nominalnego oraz stosując najlepsze praktyki przedstawione w tym artykule, możesz skutecznie wykorzystać branded types do pisania bardziej solidnego i łatwiejszego w utrzymaniu kodu TypeScript. Od reprezentowania walut i identyfikatorów po wymuszanie ograniczeń specyficznych dla domeny, branded types zapewniają elastyczny i potężny mechanizm do zwiększania bezpieczeństwa typów w Twoich projektach.
Pracując z TypeScript, odkrywaj różne techniki i biblioteki dostępne do walidacji i egzekwowania typów. Rozważ użycie branded types w połączeniu z bibliotekami do walidacji w czasie wykonania, takimi jak io-ts
czy zod
, aby osiągnąć kompleksowe podejście do bezpieczeństwa typów.