17. Branded Types
TypeScript: Броня. Урок 16: Branded Types (Номинальные типы)
Заголовок раздела «TypeScript: Броня. Урок 16: Branded Types (Номинальные типы)»Branded types (также называемые nominal types или opaque types) - это техника создания типов, которые структурно идентичны, но семантически различны. TypeScript использует структурную типизацию, но иногда нужна номинальная типизация - когда два типа с одинаковой структурой не должны быть взаимозаменяемы. Branded types решают эту проблему.
Проблема структурной типизации
Заголовок раздела «Проблема структурной типизации»TypeScript использует структурную типизацию - типы совместимы, если их структура совпадает:
// Проблема: эти типы взаимозаменяемыtype UserId = string;type PostId = string;type Email = string;
function getUser(id: UserId): User { /* ... */ }function getPost(id: PostId): Post { /* ... */ }
const userId: UserId = "user_123";const postId: PostId = "post_456";
// Все эти вызовы допустимы, но семантически неверны!getUser(postId); // ✗ Логически неправильно, но TypeScript разрешаетgetUser(email); // ✗ Логически неправильно, но TypeScript разрешаетgetPost(userId); // ✗ Логически неправильно, но TypeScript разрешаетСоздание Branded Types
Заголовок раздела «Создание Branded Types»Branded types добавляют “бренд” - уникальное символьное свойство, которое делает типы несовместимыми:
// Паттерн брендированияtype Brand<K, T> = K & { readonly __brand: T };
// Создание branded типовtype UserId = Brand<string, 'UserId'>;type PostId = Brand<string, 'PostId'>;type Email = Brand<string, 'Email'>;
// Теперь типы несовместимы!function getUser(id: UserId): User { /* ... */ }function getPost(id: PostId): Post { /* ... */ }
// const userId: UserId = "user_123"; // ✗ Ошибка! Нужна явная конверсия// const postId: PostId = "post_456"; // ✗ Ошибка!
// Правильный способ - через функции-конструкторыfunction createUserId(id: string): UserId { // Здесь может быть валидация if (!id.startsWith('user_')) { throw new Error('Invalid user ID format'); } return id as UserId;}
function createPostId(id: string): PostId { if (!id.startsWith('post_')) { throw new Error('Invalid post ID format'); } return id as PostId;}
function createEmail(email: string): Email { if (!email.includes('@')) { throw new Error('Invalid email format'); } return email as Email;}
// Использованиеconst userId = createUserId("user_123");const postId = createPostId("post_456");
getUser(userId); // ✓ Правильно// getUser(postId); // ✗ Ошибка типов!// getPost(userId); // ✗ Ошибка типов!Branded Numbers
Заголовок раздела «Branded Numbers»// Брендирование числовых типовtype PositiveNumber = Brand<number, 'Positive'>;type NegativeNumber = Brand<number, 'Negative'>;type Percentage = Brand<number, 'Percentage'>; // 0-100type Timestamp = Brand<number, 'Timestamp'>;
// Конструкторы с валидациейfunction positive(n: number): PositiveNumber { if (n <= 0) { throw new Error('Number must be positive'); } return n as PositiveNumber;}
function negative(n: number): NegativeNumber { if (n >= 0) { throw new Error('Number must be negative'); } return n as NegativeNumber;}
function percentage(n: number): Percentage { if (n < 0 || n > 100) { throw new Error('Percentage must be between 0 and 100'); } return n as Percentage;}
function timestamp(ms: number): Timestamp { return ms as Timestamp;}
// Использованиеfunction calculateDiscount( price: PositiveNumber, discount: Percentage): PositiveNumber { const amount = price * (discount / 100); return positive(price - amount);}
const price = positive(100);const discount = percentage(20);const finalPrice = calculateDiscount(price, discount);
// const invalid = calculateDiscount(positive(100), positive(150)); // ✗ Типы несовместимыПрактический пример: Единицы измерения
Заголовок раздела «Практический пример: Единицы измерения»// Система единиц измеренияtype Meters = Brand<number, 'Meters'>;type Kilometers = Brand<number, 'Kilometers'>;type Seconds = Brand<number, 'Seconds'>;type MetersPerSecond = Brand<number, 'MetersPerSecond'>;
// Конструкторыconst meters = (n: number): Meters => n as Meters;const kilometers = (n: number): Kilometers => n as Kilometers;const seconds = (n: number): Seconds => n as Seconds;const metersPerSecond = (n: number): MetersPerSecond => n as MetersPerSecond;
// Конверсииfunction kmToMeters(km: Kilometers): Meters { return meters(km * 1000);}
function metersToKm(m: Meters): Kilometers { return kilometers(m / 1000);}
// Вычисления с единицамиfunction calculateSpeed(distance: Meters, time: Seconds): MetersPerSecond { return metersPerSecond(distance / time);}
function calculateDistance(speed: MetersPerSecond, time: Seconds): Meters { return meters(speed * time);}
// Использованиеconst distance = meters(1000);const time = seconds(10);const speed = calculateSpeed(distance, time);// metersPerSecond(100)
const distanceKm = metersToKm(distance);// kilometers(1)
// const invalid = calculateSpeed(kilometers(1), seconds(10)); // ✗ Ошибка типов!// Нужна явная конверсия:const correct = calculateSpeed(kmToMeters(kilometers(1)), seconds(10)); // ✓Жизненный пример: URL Types
Заголовок раздела «Жизненный пример: URL Types»// Type-safe URLstype AbsoluteURL = Brand<string, 'AbsoluteURL'>;type RelativeURL = Brand<string, 'RelativeURL'>;type ApiEndpoint = Brand<string, 'ApiEndpoint'>;
// Валидация и созданиеfunction absoluteURL(url: string): AbsoluteURL { try { new URL(url); // Проверка через браузерный API return url as AbsoluteURL; } catch { throw new Error(`Invalid absolute URL: ${url}`); }}
function relativeURL(url: string): RelativeURL { if (url.startsWith('http://') || url.startsWith('https://')) { throw new Error('Relative URL cannot be absolute'); } if (!url.startsWith('/')) { throw new Error('Relative URL must start with /'); } return url as RelativeURL;}
function apiEndpoint(path: string): ApiEndpoint { if (!path.startsWith('/api/')) { throw new Error('API endpoint must start with /api/'); } return path as ApiEndpoint;}
// Type-safe функцииfunction fetchAbsolute(url: AbsoluteURL): Promise<Response> { return fetch(url);}
function fetchApi(endpoint: ApiEndpoint): Promise<Response> { const baseUrl = 'https://api.example.com'; return fetch(baseUrl + endpoint);}
function navigate(url: AbsoluteURL | RelativeURL): void { window.location.href = url;}
// Использованиеconst homeUrl = relativeURL('/home');const apiUrl = apiEndpoint('/api/users');const externalUrl = absoluteURL('https://google.com');
navigate(homeUrl); // ✓fetchApi(apiUrl); // ✓fetchAbsolute(externalUrl); // ✓
// navigate(apiUrl); // ✗ ApiEndpoint не совместим с RelativeURL// fetchApi(homeUrl); // ✗ RelativeURL не совместим с ApiEndpointBranded Arrays и Collections
Заголовок раздела «Branded Arrays и Collections»// Брендированные коллекцииtype NonEmptyArray<T> = Brand<T[], 'NonEmpty'>;type SortedArray<T> = Brand<T[], 'Sorted'>;type UniqueArray<T> = Brand<T[], 'Unique'>;
// Конструкторыfunction nonEmptyArray<T>(arr: T[]): NonEmptyArray<T> { if (arr.length === 0) { throw new Error('Array cannot be empty'); } return arr as NonEmptyArray<T>;}
function sortedArray<T>(arr: T[], compareFn?: (a: T, b: T) => number): SortedArray<T> { const sorted = [...arr].sort(compareFn); return sorted as SortedArray<T>;}
function uniqueArray<T>(arr: T[]): UniqueArray<T> { const unique = Array.from(new Set(arr)); return unique as UniqueArray<T>;}
// Функции, работающие с брендированными массивамиfunction head<T>(arr: NonEmptyArray<T>): T { return arr[0]; // Гарантированно существует}
function binarySearch<T>(arr: SortedArray<T>, value: T): number { // Можем использовать binary search, т.к. массив отсортирован // implementation return -1;}
// Использованиеconst numbers = nonEmptyArray([1, 2, 3]);const first = head(numbers); // ✓ Безопасно
// const empty: number[] = [];// const invalid = head(empty); // ✗ Ошибка типов!
const sorted = sortedArray([3, 1, 2]);const index = binarySearch(sorted, 2); // ✓
// const unsorted = [3, 1, 2];// binarySearch(unsorted, 2); // ✗ Нужен SortedArrayС Generic Constraints
Заголовок раздела «С Generic Constraints»// Branded generic типыtype Validated<T, Brand extends string> = T & { readonly __brand: Brand };
// Создание валидаторовinterface Validator<T, Brand extends string> { validate(value: T): Validated<T, Brand>;}
class EmailValidator implements Validator<string, 'Email'> { validate(value: string): Validated<string, 'Email'> { if (!value.includes('@') || !value.includes('.')) { throw new Error('Invalid email'); } return value as Validated<string, 'Email'>; }}
class PhoneValidator implements Validator<string, 'Phone'> { validate(value: string): Validated<string, 'Phone'> { if (!/^\+?[0-9]{10,15}$/.test(value)) { throw new Error('Invalid phone number'); } return value as Validated<string, 'Phone'>; }}
// Generic функция для валидацииfunction createValidator<T, Brand extends string>( validate: (value: T) => boolean, errorMsg: string): (value: T) => Validated<T, Brand> { return (value: T): Validated<T, Brand> => { if (!validate(value)) { throw new Error(errorMsg); } return value as Validated<T, Brand>; };}
// Использованиеconst emailValidator = new EmailValidator();const phoneValidator = new PhoneValidator();
const phone = phoneValidator.validate('+79991234567');
// Кастомный валидаторtype Username = Validated<string, 'Username'>;
const validateUsername = createValidator<string, 'Username'>( (val) => val.length >= 3 && val.length <= 20 && /^[a-zA-Z0-9_]+$/.test(val), 'Username must be 3-20 characters, alphanumeric and underscores only');
const username = validateUsername('john_doe123'); // ✓// const invalid = validateUsername('ab'); // ✗ Throws errorRuntime проверки
Заголовок раздела «Runtime проверки»// Комбинирование branded types с runtime валидациейtype JsonString = Brand<string, 'JsonString'>;
function parseJson(json: JsonString): unknown { // Здесь мы уверены, что строка валидна return JSON.parse(json);}
function toJsonString(value: unknown): JsonString { try { const json = JSON.stringify(value); // Проверяем, что можем парсить обратно JSON.parse(json); return json as JsonString; } catch (e) { throw new Error('Failed to create JSON string'); }}
// Использованиеconst obj = { name: 'Alice', age: 30 };const json = toJsonString(obj);const parsed = parseJson(json);
// const invalid = '{ invalid json }';// parseJson(invalid); // ✗ Ошибка типов - нужен JsonStringКлючевые моменты
Заголовок раздела «Ключевые моменты»- Branded types обеспечивают номинальную типизацию в структурной системе TypeScript
- Паттерн:
type Brand<K, T> = K & { readonly __brand: T } - Требуют явных конструкторов и конверсий между типами
- Предотвращают случайное смешивание семантически разных данных
- Идеальны для ID, URLs, единиц измерения, валидированных данных
- Валидация может быть встроена в конструкторы
- Runtime overhead отсутствует - бренды существуют только на уровне типов
- Улучшают type safety без изменения runtime поведения
- Особенно полезны в больших кодовых базах для предотвращения ошибок
- Комбинируются с другими продвинутыми техниками TypeScript