Разгледайте брандираните типове в TypeScript – мощна техника за номинално типизиране. Научете как да подобрите типовата безопасност и яснотата на кода.
TypeScript Branded Types: Номинално типизиране в структурна система
Структурната система от типове на TypeScript предлага гъвкавост, но понякога може да доведе до неочаквано поведение. Брандираните типове (Branded types) предоставят начин за налагане на номинално типизиране, подобрявайки типовата безопасност и яснотата на кода. Тази статия разглежда подробно брандираните типове, като предоставя практически примери и най-добри практики за тяхното имплементиране.
Разбиране на структурно спрямо номинално типизиране
Преди да се потопим в брандираните типове, нека изясним разликата между структурно и номинално типизиране.
Структурно типизиране (Duck Typing)
В система със структурно типизиране два типа се считат за съвместими, ако имат еднаква структура (т.е. едни и същи свойства със същите типове). TypeScript използва структурно типизиране. Разгледайте този пример:
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
Въпреки че Point
и Vector
са декларирани като отделни типове, TypeScript позволява присвояването на обект от тип Point
на променлива от тип Vector
, защото те споделят една и съща структура. Това може да бъде удобно, но също така може да доведе до грешки, ако трябва да разграничите логически различни типове, които случайно имат еднаква форма. Например, координати за географска ширина/дължина, които случайно могат да съвпаднат с координати на пиксели на екрана.
Номинално типизиране
В система с номинално типизиране типовете се считат за съвместими само ако имат едно и също име. Дори ако два типа имат еднаква структура, те се третират като различни, ако имат различни имена. Езици като Java и C# използват номинално типизиране.
Нуждата от брандирани типове
Структурното типизиране на TypeScript може да бъде проблематично, когато трябва да се уверите, че дадена стойност принадлежи към определен тип, независимо от нейната структура. Например, представете си представяне на валути. Може да имате различни типове за USD и EUR, но и двата могат да бъдат представени като числа. Без механизъм за разграничаването им, бихте могли случайно да извършите операции с грешната валута.
Брандираните типове решават този проблем, като ви позволяват да създавате различни типове, които са структурно сходни, но се третират като различни от системата от типове. Това подобрява типовата безопасност и предотвратява грешки, които иначе биха могли да се промъкнат.
Имплементиране на брандирани типове в TypeScript
Брандираните типове се имплементират с помощта на intersection типове и уникален символ или стринг литерал. Идеята е да се добави „бранд“ към тип, който го отличава от други типове със същата структура.
Използване на символи (Препоръчително)
Използването на символи за брандиране е предпочитано, защото символите са гарантирано уникални.
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);
В този пример USD
и EUR
са брандирани типове, базирани на типа number
. unique symbol
гарантира, че тези типове са различни. Функциите createUSD
и createEUR
се използват за създаване на стойности от тези типове, а функцията addUSD
приема само стойности от тип USD
. Опитът да се добави стойност от тип EUR
към стойност от тип USD
ще доведе до грешка в типа.
Използване на стринг литерали
Можете също да използвате стринг литерали за брандиране, въпреки че този подход е по-малко надежден от използването на символи, тъй като не е гарантирано, че стринг литералите са уникални.
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);
Този пример постига същия резултат като предишния, но използва стринг литерали вместо символи. Макар и по-просто, е важно да се гарантира, че стринг литералите, използвани за брандиране, са уникални в рамките на вашата кодова база.
Практически примери и случаи на употреба
Брандираните типове могат да бъдат приложени в различни сценарии, където трябва да наложите типова безопасност извън рамките на структурната съвместимост.
Идентификатори (IDs)
Разгледайте система с различни видове идентификатори, като UserID
, ProductID
и OrderID
. Всички тези идентификатори може да се представят като числа или низове, но искате да предотвратите случайното смесване на различни типове идентификатори.
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);
Този пример демонстрира как брандираните типове могат да предотвратят предаването на ProductID
на функция, която очаква UserID
, като по този начин се подобрява типовата безопасност.
Стойности, специфични за домейна
Брандираните типове могат да бъдат полезни и за представяне на специфични за домейна стойности с ограничения. Например, може да имате тип за проценти, които винаги трябва да са между 0 и 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);
}
Този пример показва как да се наложи ограничение върху стойността на брандиран тип по време на изпълнение. Въпреки че системата от типове не може да гарантира, че стойността на Percentage
винаги е между 0 и 100, функцията createPercentage
може да наложи това ограничение по време на изпълнение. Можете също да използвате библиотеки като io-ts, за да наложите валидация на брандирани типове по време на изпълнение.
Представяне на дата и час
Работата с дати и часове може да бъде сложна поради различните формати и часови зони. Брандираните типове могат да помогнат за разграничаване между различните представяния на дата и час.
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);
}
Този пример разграничава UTC и локални дати, като гарантира, че работите с правилното представяне на дата и час в различни части на вашето приложение. Валидацията по време на изпълнение гарантира, че само правилно форматирани низове за дата могат да бъдат присвоени на тези типове.
Най-добри практики за използване на брандирани типове
За да използвате ефективно брандираните типове в TypeScript, вземете предвид следните най-добри практики:
- Използвайте символи за брандиране: Символите предоставят най-силната гаранция за уникалност, намалявайки риска от грешки в типовете.
- Създавайте помощни функции: Използвайте помощни функции за създаване на стойности от брандирани типове. Това осигурява централна точка за валидация и гарантира последователност.
- Прилагайте валидация по време на изпълнение: Въпреки че брандираните типове подобряват типовата безопасност, те не предотвратяват присвояването на неправилни стойности по време на изпълнение. Използвайте валидация по време на изпълнение, за да наложите ограничения.
- Документирайте брандираните типове: Ясно документирайте целта и ограниченията на всеки брандиран тип, за да подобрите поддръжката на кода.
- Обмислете въздействието върху производителността: Брандираните типове въвеждат малък оверхед поради intersection типа и нуждата от помощни функции. Обмислете въздействието върху производителността в критични за нея секции на вашия код.
Предимства на брандираните типове
- Подобрена типова безопасност: Предотвратява случайното смесване на структурно сходни, но логически различни типове.
- Подобрена яснота на кода: Прави кода по-четим и лесен за разбиране, като изрично разграничава типовете.
- Намалени грешки: Улавя потенциални грешки по време на компилация, намалявайки риска от бъгове по време на изпълнение.
- Увеличена поддръжка: Прави кода по-лесен за поддръжка и рефакториране, като осигурява ясно разделение на отговорностите.
Недостатъци на брандираните типове
- Повишена сложност: Добавя сложност към кодовата база, особено при работа с много брандирани типове.
- Оверхед по време на изпълнение: Въвежда малък оверхед по време на изпълнение поради нуждата от помощни функции и валидация.
- Потенциал за повтарящ се код (boilerplate): Може да доведе до повтарящ се код, особено при създаването и валидирането на брандирани типове.
Алтернативи на брандираните типове
Въпреки че брандираните типове са мощна техника за постигане на номинално типизиране в TypeScript, съществуват алтернативни подходи, които може да обмислите.
Непрозрачни типове (Opaque Types)
Непрозрачните типове са подобни на брандираните, но предоставят по-изричен начин за скриване на основния тип. TypeScript няма вградена поддръжка за непрозрачни типове, но можете да ги симулирате с помощта на модули и частни символи.
Класове
Използването на класове може да предостави по-обектно-ориентиран подход за дефиниране на различни типове. Въпреки че класовете са структурно типизирани в TypeScript, те предлагат по-ясно разделение на отговорностите и могат да се използват за налагане на ограничения чрез методи.
Библиотеки като io-ts
или zod
Тези библиотеки предоставят сложна валидация на типове по време на изпълнение и могат да бъдат комбинирани с брандирани типове, за да се гарантира безопасност както по време на компилация, така и по време на изпълнение.
Заключение
Брандираните типове в TypeScript са ценен инструмент за подобряване на типовата безопасност и яснотата на кода в структурна система от типове. Като добавите „бранд“ към даден тип, можете да наложите номинално типизиране и да предотвратите случайното смесване на структурно сходни, но логически различни типове. Въпреки че брандираните типове въвеждат известна сложност и оверхед, ползите от подобрената типова безопасност и поддръжка на кода често надвишават недостатъците. Обмислете използването на брандирани типове в сценарии, в които трябва да се уверите, че дадена стойност принадлежи към определен тип, независимо от нейната структура.
Разбирайки принципите зад структурното и номиналното типизиране и прилагайки най-добрите практики, очертани в тази статия, можете ефективно да използвате брандираните типове, за да пишете по-надежден и лесен за поддръжка TypeScript код. От представянето на валути и идентификатори до налагането на специфични за домейна ограничения, брандираните типове предоставят гъвкав и мощен механизъм за подобряване на типовата безопасност във вашите проекти.
Докато работите с TypeScript, изследвайте различните налични техники и библиотеки за валидация и налагане на типове. Обмислете използването на брандирани типове в комбинация с библиотеки за валидация по време на изпълнение като io-ts
или zod
, за да постигнете всеобхватен подход към типовата безопасност.