Изучите брендированные типы TypeScript — мощную технику для номинальной типизации в структурной системе. Узнайте, как повысить безопасность типов и ясность кода.
Брендированные типы TypeScript: номинальная типизация в структурной системе
Структурная система типов TypeScript предлагает гибкость, но иногда может приводить к неожиданному поведению. Брендированные типы предоставляют способ принудительно использовать номинальную типизацию, повышая безопасность типов и ясность кода. В этой статье подробно рассматриваются брендированные типы, приводятся практические примеры и лучшие практики их реализации.
Понимание структурной и номинальной типизации
Прежде чем углубляться в брендированные типы, давайте проясним разницу между структурной и номинальной типизацией.
Структурная типизация ("утиная типизация")
В структурной системе типов два типа считаются совместимыми, если они имеют одинаковую структуру (т.е. те же свойства с теми же типами). TypeScript использует структурную типизацию. Рассмотрим этот пример:
interface Point {
x: number;
y: number;
}
interface Vector {
x: number;
y: number;
}
const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // Допустимо в TypeScript
console.log(vector.x); // Вывод: 10
Несмотря на то, что Point
и Vector
объявлены как разные типы, TypeScript позволяет присваивать объект Point
переменной Vector
, потому что у них одинаковая структура. Это может быть удобно, но также может привести к ошибкам, если вам нужно различать логически разные типы, которые случайно имеют одинаковую форму. Например, когда координаты широты/долготы могут случайно совпадать с экранными пиксельными координатами.
Номинальная типизация
В номинальной системе типов типы считаются совместимыми, только если у них одинаковое имя. Даже если два типа имеют одинаковую структуру, они считаются разными, если у них разные имена. Языки, такие как Java и C#, используют номинальную типизацию.
Необходимость в брендированных типах
Структурная типизация TypeScript может быть проблематичной, когда вам нужно убедиться, что значение принадлежит определенному типу, независимо от его структуры. Например, рассмотрим представление валют. У вас могут быть разные типы для USD и EUR, но оба они могут быть представлены как числа. Без механизма их различения вы могли бы случайно выполнить операции с неверной валютой.
Брендированные типы решают эту проблему, позволяя создавать различные типы, которые структурно схожи, но рассматриваются системой типов как разные. Это повышает безопасность типов и предотвращает ошибки, которые в противном случае могли бы проскользнуть.
Реализация брендированных типов в TypeScript
Брендированные типы реализуются с помощью типов-пересечений и уникального символа или строкового литерала. Идея заключается в том, чтобы добавить "бренд" к типу, который отличает его от других типов с той же структурой.
Использование символов (рекомендуется)
Использование символов для брендирования обычно предпочтительнее, поскольку символы гарантированно уникальны.
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("Всего USD:", totalUSD);
// Раскомментирование следующей строки вызовет ошибку типа
// 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("Всего USD:", totalUSD);
// Раскомментирование следующей строки вызовет ошибку типа
// const invalidOperation = addUSD(usd1, eur1);
Этот пример достигает того же результата, что и предыдущий, но с использованием строковых литералов вместо символов. Хотя это проще, важно убедиться, что строковые литералы, используемые для брендирования, уникальны в вашей кодовой базе.
Практические примеры и сценарии использования
Брендированные типы могут применяться в различных сценариях, где вам необходимо обеспечить безопасность типов за пределами структурной совместимости.
Идентификаторы (ID)
Рассмотрим систему с различными типами идентификаторов, такими как UserID
, ProductID
и OrderID
. Все эти идентификаторы могут быть представлены как числа или строки, но вы хотите предотвратить случайное смешивание разных типов 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 } {
// ... получаем данные пользователя
return { name: "Alice" };
}
function getProduct(id: ProductID): { name: string, price: number } {
// ... получаем данные продукта
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);
console.log("Продукт:", product);
// Раскомментирование следующей строки вызовет ошибку типа
// 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('Процент должен быть в диапазоне от 0 до 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("Цена со скидкой:", discountedPrice);
// Раскомментирование следующей строки вызовет ошибку во время выполнения
// 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 {
// Проверяем, что строка даты в формате UTC (например, ISO 8601 с Z)
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
throw new Error('Неверный формат даты UTC');
}
return dateString as UTCDate;
}
function createLocalDate(dateString: string): LocalDate {
// Проверяем, что строка даты в формате локальной даты (например, ГГГГ-ММ-ДД)
if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
throw new Error('Неверный формат локальной даты');
}
return dateString as LocalDate;
}
function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
// Выполняем преобразование часового пояса
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:", utcDate);
console.log("Локальная дата:", localDate);
} catch (error) {
console.error(error);
}
Этот пример различает даты в UTC и локальные даты, гарантируя, что вы работаете с правильным представлением даты и времени в разных частях вашего приложения. Валидация во время выполнения гарантирует, что этим типам могут быть присвоены только правильно отформатированные строки дат.
Лучшие практики использования брендированных типов
Чтобы эффективно использовать брендированные типы в TypeScript, придерживайтесь следующих лучших практик:
- Используйте символы для брендирования: Символы обеспечивают самую надежную гарантию уникальности, снижая риск ошибок типизации.
- Создавайте вспомогательные функции: Используйте вспомогательные функции для создания значений брендированных типов. Это обеспечивает централизованную точку для валидации и гарантирует согласованность.
- Применяйте валидацию во время выполнения: Хотя брендированные типы повышают безопасность типов, они не предотвращают присвоение неверных значений во время выполнения. Используйте валидацию во время выполнения для принудительного применения ограничений.
- Документируйте брендированные типы: Четко документируйте назначение и ограничения каждого брендированного типа для улучшения поддерживаемости кода.
- Учитывайте влияние на производительность: Брендированные типы вносят небольшие накладные расходы из-за типа-пересечения и необходимости во вспомогательных функциях. Учитывайте влияние на производительность в критически важных участках вашего кода.
Преимущества брендированных типов
- Повышенная безопасность типов: Предотвращает случайное смешивание структурно схожих, но логически разных типов.
- Улучшенная ясность кода: Делает код более читабельным и понятным за счет явного различения типов.
- Сокращение ошибок: Обнаруживает потенциальные ошибки на этапе компиляции, снижая риск ошибок во время выполнения.
- Улучшенная поддерживаемость: Упрощает поддержку и рефакторинг кода, обеспечивая четкое разделение ответственностей.
Недостатки брендированных типов
- Повышенная сложность: Добавляет сложность в кодовую базу, особенно при работе с большим количеством брендированных типов.
- Накладные расходы во время выполнения: Вносит небольшие накладные расходы из-за необходимости во вспомогательных функциях и валидации во время выполнения.
- Возможность появления шаблонного кода: Может приводить к появлению шаблонного (boilerplate) кода, особенно при создании и валидации брендированных типов.
Альтернативы брендированным типам
Хотя брендированные типы являются мощной техникой для достижения номинальной типизации в TypeScript, существуют альтернативные подходы, которые вы можете рассмотреть.
Непрозрачные типы (Opaque Types)
Непрозрачные типы похожи на брендированные, но предоставляют более явный способ скрыть базовый тип. В TypeScript нет встроенной поддержки непрозрачных типов, но их можно симулировать с помощью модулей и приватных символов.
Классы
Использование классов может обеспечить более объектно-ориентированный подход к определению различных типов. Хотя классы в TypeScript типизируются структурно, они предлагают более четкое разделение ответственностей и могут использоваться для принудительного применения ограничений через методы.
Библиотеки, такие как `io-ts` или `zod`
Эти библиотеки предоставляют сложные средства валидации типов во время выполнения и могут сочетаться с брендированными типами для обеспечения безопасности как на этапе компиляции, так и во время выполнения.
Заключение
Брендированные типы TypeScript — это ценный инструмент для повышения безопасности типов и ясности кода в структурной системе типов. Добавляя "бренд" к типу, вы можете принудительно использовать номинальную типизацию и предотвращать случайное смешивание структурно схожих, но логически разных типов. Хотя брендированные типы вносят некоторую сложность и накладные расходы, преимущества улучшенной безопасности типов и поддерживаемости кода часто перевешивают недостатки. Рассмотрите возможность использования брендированных типов в сценариях, где вам нужно гарантировать, что значение принадлежит определенному типу, независимо от его структуры.
Понимая принципы, лежащие в основе структурной и номинальной типизации, и применяя лучшие практики, изложенные в этой статье, вы сможете эффективно использовать брендированные типы для написания более надежного и поддерживаемого кода на TypeScript. От представления валют и идентификаторов до принудительного применения специфичных для домена ограничений, брендированные типы предоставляют гибкий и мощный механизм для повышения безопасности типов в ваших проектах.
Работая с TypeScript, изучайте различные доступные техники и библиотеки для валидации и принудительного применения типов. Рассмотрите возможность использования брендированных типов в сочетании с библиотеками валидации во время выполнения, такими как io-ts
или zod
, чтобы достичь комплексного подхода к безопасности типов.