Русский

Изучите брендированные типы 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, придерживайтесь следующих лучших практик:

Преимущества брендированных типов

Недостатки брендированных типов

Альтернативы брендированным типам

Хотя брендированные типы являются мощной техникой для достижения номинальной типизации в TypeScript, существуют альтернативные подходы, которые вы можете рассмотреть.

Непрозрачные типы (Opaque Types)

Непрозрачные типы похожи на брендированные, но предоставляют более явный способ скрыть базовый тип. В TypeScript нет встроенной поддержки непрозрачных типов, но их можно симулировать с помощью модулей и приватных символов.

Классы

Использование классов может обеспечить более объектно-ориентированный подход к определению различных типов. Хотя классы в TypeScript типизируются структурно, они предлагают более четкое разделение ответственностей и могут использоваться для принудительного применения ограничений через методы.

Библиотеки, такие как `io-ts` или `zod`

Эти библиотеки предоставляют сложные средства валидации типов во время выполнения и могут сочетаться с брендированными типами для обеспечения безопасности как на этапе компиляции, так и во время выполнения.

Заключение

Брендированные типы TypeScript — это ценный инструмент для повышения безопасности типов и ясности кода в структурной системе типов. Добавляя "бренд" к типу, вы можете принудительно использовать номинальную типизацию и предотвращать случайное смешивание структурно схожих, но логически разных типов. Хотя брендированные типы вносят некоторую сложность и накладные расходы, преимущества улучшенной безопасности типов и поддерживаемости кода часто перевешивают недостатки. Рассмотрите возможность использования брендированных типов в сценариях, где вам нужно гарантировать, что значение принадлежит определенному типу, независимо от его структуры.

Понимая принципы, лежащие в основе структурной и номинальной типизации, и применяя лучшие практики, изложенные в этой статье, вы сможете эффективно использовать брендированные типы для написания более надежного и поддерживаемого кода на TypeScript. От представления валют и идентификаторов до принудительного применения специфичных для домена ограничений, брендированные типы предоставляют гибкий и мощный механизм для повышения безопасности типов в ваших проектах.

Работая с TypeScript, изучайте различные доступные техники и библиотеки для валидации и принудительного применения типов. Рассмотрите возможность использования брендированных типов в сочетании с библиотеками валидации во время выполнения, такими как io-ts или zod, чтобы достичь комплексного подхода к безопасности типов.