Перейти к содержимому

18. useId

TypeScript: Типизация Уникальных Идентификаторов (ID) и Брендированные Типы

Заголовок раздела «TypeScript: Типизация Уникальных Идентификаторов (ID) и Брендированные Типы»

Привет, кодеры! Сегодня мы погрузимся в одну из тех тонких, но мощных возможностей TypeScript, которая может спасти ваш код от множества ошибок и сделать его намного надежнее. Речь пойдет о типизации уникальных идентификаторов (ID) и, в частности, о паттерне Брендированных Типов (Branded Types).

Зачем нам это? Представьте, что у вас есть ID пользователя, ID продукта и ID заказа. Все они, скорее всего, обычные строки или числа. В JavaScript вы легко можете случайно передать ID продукта туда, где ожидается ID пользователя, и получить непредсказуемый результат. TypeScript, по умолчанию, видит string как string, и number как number, даже если по логике вашего приложения это совершенно разные сущности. Нам нужно “научить” TypeScript различать эти “одинаковые, но разные” типы.

В контексте фронтенда, например, React имеет хук useId для генерации уникальных ID на стороне клиента для целей доступности (связывание label и input). Это пример потребности в уникальных ID. Но наш урок будет шире: мы научимся создавать и типизировать уникальные ID для любых сущностей в TypeScript-приложениях, будь то на клиенте или сервере, чтобы гарантировать максимальную безопасность типов.

Давайте посмотрим на классическую проблему.

type UserId = string;
type ProductId = string;
function getUser(id: UserId) {
console.log(`Получаем пользователя с ID: ${id}`);
// ... логика получения пользователя
}
function getProduct(id: ProductId) {
console.log(`Получаем продукт с ID: ${id}`);
// ... логика получения продукта
}
const userId: UserId = "user-123";
const productId: ProductId = "product-456";
getUser(userId); // ✅ Отлично
getProduct(productId); // ✅ Отлично
getUser(productId); // ⚠️ TypeScript это пропускает!
getProduct(userId); // ⚠️ TypeScript это пропускает!

Как видите, TypeScript не видит разницы между UserId и ProductId, потому что их базовый тип — string. Это может привести к ошибкам в рантайме, которые сложно отловить.

### 🏷️ Решение: Брендированные Типы (Branded Types)

Заголовок раздела «### 🏷️ Решение: Брендированные Типы (Branded Types)»

Брендированные типы — это мощный паттерн в TypeScript, который позволяет нам создавать “номинальные” типы поверх “структурных”. Проще говоря, мы добавляем к базовому типу (например, string) некий “бренд” (метку), который делает его уникальным для TypeScript.

Как это работает? Мы используем пересечение типов с уникальным символом (symbol) или литеральным типом, чтобы TypeScript мог отличить один “брендированный” тип от другого, даже если их базовые типы совпадают.

Давайте создадим универсальный вспомогательный тип Brand:

// `Brand` - это универсальный тип-утилита для создания брендированных типов.
// T - базовый тип (например, string, number).
// B - "бренд" или "метка", который делает тип уникальным.
type Brand<T, B> = T & { __brand: B };
// Теперь мы можем создать наши уникальные типы ID:
type UserId = Brand<string, 'UserId'>;
type ProductId = Brand<string, 'ProductId'>;
type OrderId = Brand<string, 'OrderId'>;
// Функция для создания UserId из обычной строки (UUID)
function createUserId(uuid: string): UserId {
// Мы знаем, что это UserId, поэтому безопасно приводим тип.
// Важно: эта функция - это точка входа, где мы "надеваем бренд".
return uuid as UserId;
}
// Функция для создания ProductId
function createProductId(uuid: string): ProductId {
return uuid as ProductId;
}
// Функция для создания OrderId
function createOrderId(uuid: string): OrderId {
return uuid as OrderId;
}

Теперь, когда у нас есть брендированные типы, давайте перепишем наши функции:

function getUserData(id: UserId) {
console.log(`Получаем данные пользователя с ID: ${id}`);
// ... логика
}
function getProductData(id: ProductId) {
console.log(`Получаем данные продукта с ID: ${id}`);
// ... логика
}
// Генерация реальных UUID с помощью библиотеки `uuid` (ее нужно установить: npm install uuid)
import { v4 as uuidv4 } from 'uuid';
const newUserId = createUserId(uuidv4());
const newProductId = createProductId(uuidv4());
const newOrderId = createOrderId(uuidv4());
console.log(`Сгенерированные ID:`);
console.log(`User ID: ${newUserId}`);
console.log(`Product ID: ${newProductId}`);
console.log(`Order ID: ${newOrderId}`);
getUserData(newUserId); // ✅ Отлично
getProductData(newProductId); // ✅ Отлично
// Попытка передать ProductId туда, где ожидается UserId - теперь это ошибка компиляции!
// getUserData(newProductId); // ❌ Ошибка: Argument of type 'ProductId' is not assignable to parameter of type 'UserId'.
// Попытка передать OrderId туда, где ожидается ProductId - тоже ошибка!
// getProductData(newOrderId); // ❌ Ошибка: Argument of type 'OrderId' is not assignable to parameter of type 'ProductId'.
// Что если у нас есть "чистая" строка? TypeScript не позволит ее использовать напрямую.
const rawStringId = uuidv4();
// getUserData(rawStringId); // ❌ Ошибка: Argument of type 'string' is not assignable to parameter of type 'UserId'.
// Мы должны явно "пометить" ее:
getUserData(createUserId(rawStringId)); // ✅ Теперь безопасно

Вот это уже совсем другое дело! TypeScript теперь строго следит за тем, какой тип ID используется, значительно уменьшая вероятность ошибок.

Брендированные типы становятся еще мощнее в сочетании с другими функциями TypeScript, такими как дженерики и утилитарные типы.

Часто данные хранятся в Record или Map, где ключами являются ID.

interface User {
id: UserId;
name: string;
email: string;
}
interface Product {
id: ProductId;
name: string;
price: number;
}
// Пример хранилища пользователей
const usersById: Record<UserId, User> = {
[createUserId(uuidv4())]: { id: createUserId(uuidv4()), name: "Алексей", email: "[email protected]" },
[createUserId(uuidv4())]: { id: createUserId(uuidv4()), name: "Мария", email: "[email protected]" },
};
// Пример хранилища продуктов
const productsById: Map<ProductId, Product> = new Map();
productsById.set(createProductId(uuidv4()), { id: createProductId(uuidv4()), name: "Ноутбук", price: 1200 });
productsById.set(createProductId(uuidv4()), { id: createProductId(uuidv4()), name: "Мышь", price: 25 });
function findUserById(id: UserId): User | undefined {
return usersById[id];
}
function findProductById(id: ProductId): Product | undefined {
return productsById.get(id);
}
const foundUser = findUserById(createUserId(uuidv4())); // Можно использовать только UserId
// const foundProduct = findProductById(createUserId(uuidv4())); // ❌ Ошибка компиляции

Мы можем создать универсальные функции, которые работают с любыми брендированными ID, сохраняя при этом безопасность типов.

// Универсальная функция для получения элемента из Record по его брендированному ID
function getEntityById<T extends Brand<string, any>, E extends { id: T }>(
entities: Record<T, E>,
id: T
): E | undefined {
return entities[id];
}
// Теперь мы можем использовать ее для наших пользователей и продуктов
const user = getEntityById(usersById, createUserId(uuidv4()));
// const product = getEntityById(productsById, createProductId(uuidv4())); // Ошибка: productsById это Map, не Record
// Corrected version for products:
const productsRecord: Record<ProductId, Product> = {};
productsById.forEach(p => productsRecord[p.id] = p);
const product = getEntityById(productsRecord, createProductId(uuidv4()));
console.log(user?.name);
console.log(product?.name);
// При этом, если мы попытаемся передать неверный тип ID:
// getEntityById(usersById, createProductId(uuidv4())); // ❌ Ошибка!
  1. Забыли “пометить” ID:

    const someApiReturnsId: string = uuidv4();
    // getUserData(someApiReturnsId); // ❌ Ошибка: 'string' не присваивается 'UserId'.
    // Решение:
    getUserData(createUserId(someApiReturnsId)); // ✅

    Всегда используйте ваши функции create...Id для получения брендированных типов.

  2. Излишнее приведение типа (as any): Не стоит превращать брендированные типы обратно в any или string без крайней необходимости и понимания рисков. Это обходит систему типов. Если вам нужна “чистая” строка, создайте явную функцию для извлечения:

    function getRawId(id: UserId): string {
    return id; // Это безопасно, так как UserId расширяет string
    }
    const rawIdForLogging = getRawId(newUserId);
    console.log(`Raw ID: ${rawIdForLogging}`);
  3. Неправильное использование Brand: Убедитесь, что вы используете уникальный “бренд” (B в Brand<T, B>) для каждого типа ID, иначе TypeScript снова будет их путать. Brand<string, 'ID'> и Brand<string, 'ID'> будут считаться одним и тем же типом.

Ваша задача — реализовать систему управления сущностями с использованием брендированных типов.

  1. Создайте брендированные типы для следующих сущностей:

    • PostId (для постов в блоге)
    • CommentId (для комментариев к постам)
    • TagId (для тегов) Все ID должны быть строками (UUID).
  2. Реализуйте базовые интерфейсы для Post, Comment, Tag, включив соответствующие брендированные ID.

  3. Создайте функции-генераторы типа createPostId, createCommentId, createTagId, которые принимают строку (например, от uuid библиотеки) и возвращают брендированный тип.

  4. Разработайте простое “хранилище” для постов и комментариев:

    • posts: Record<PostId, Post>
    • comments: Record<CommentId, Comment> Используйте ваши брендированные ID в качестве ключей и в самих объектах.
  5. Напишите следующие функции, которые строго типизированы:

    • addPost(post: Omit<Post, 'id'>): Post – создает новый пост с уникальным ID и добавляет его в хранилище.
    • addCommentToPost(postId: PostId, commentText: string): Comment – создает новый комментарий, связывает его с постом по postId и добавляет в хранилище комментариев.
    • getCommentsForPost(postId: PostId): Comment[] – возвращает все комментарии для данного postId.
  6. Продемонстрируйте использование ваших функций, генерируя ID, добавляя посты и комментарии, и пытаясь случайно передать неверный тип ID, чтобы убедиться, что TypeScript выдает ошибку компиляции.

// Подсказка: для генерации UUID
// import { v4 as uuidv4 } from 'uuid';
// const newUuid = uuidv4();

Брендированные типы — это мощный инструмент для эмуляции номинальной типизации в TypeScript, который по своей сути является структурным. Используйте их везде, где логически схожие, но функционально разные типы могут быть перепутаны. Это особенно ценно для ID, URL, путей файлов, кодов валют и других строковых или числовых идентификаторов, которые несут в себе семантическую информацию. Чем раньше вы внедрите этот паттерн, тем безопаснее и читабельнее будет ваш код!

Попробуйте примеры в интерактивном редакторе: