Продвинутый TypeScript: паттерны типизации от junior до senior
Строгая конфигурация, размеченные объединения с проверкой полноты, дженерики с ограничениями, as const и satisfies, брендированные типы, сопоставленные и условные типы, валидация внешних данных и признаки избыточной типизации.
Большинство команд используют TypeScript как «JavaScript с аннотациями»: типы у параметров функций, интерфейсы для ответов API, иногда any там, где разбираться некогда. Такой TypeScript уже полезен, но использует малую часть возможностей языка. Настоящая ценность появляется, когда типы начинают описывать правила предметной области: система не позволяет передать идентификатор заказа вместо идентификатора клиента, забыть обработать новый статус или обратиться к полю, которого нет у конкретного варианта данных. Ниже — паттерны, которые отличают уверенное использование TypeScript от базового: от строгой конфигурации и размеченных объединений до условных и сопоставленных типов, брендированных идентификаторов и связи типов с проверкой данных во время выполнения. Для каждого — зачем он нужен в прикладном коде и где его применение становится избыточным. Фундамент: строгая конфигурация Самые продвинутые типы бесполезны, если компилятор разрешает всё. Для нового проекта разумный минимум в tsconfig.json : { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true } } strict включает группу проверок, главная из которых — строгая проверка null и undefined . noUncheckedIndexedAccess учитывает, что элемента массива или словаря по ключу может не быть: items[0] имеет тип Item | undefined . Это ловит множество реальных ошибок. exactOptionalPropertyTypes различает «поле отсутствует» и «поле равно undefined ». Для существующего проекта включать строгие опции лучше постепенно, по одной, исправляя найденные ошибки. Размеченные объединения Самый полезный паттерн прикладного TypeScript. Данные, которые могут находиться в разных состояниях, описываются объединением вариантов с общим полем-признаком. type Payment = | { status: 'pending'; createdAt: Date } | { status: 'paid'; paidAt: Date; receiptUrl: string } | { status: 'failed'; reason: string; retryable: boolean } | { status: 'refunded'; refundedAt: Date; amount: number }; function describe(p: Payment): string { switch (p.status) { case 'pending': return 'Ожидает оплаты'; case 'paid': return `Оплачен, чек: ${p.receiptUrl}`; case 'failed': return `Ошибка: ${p.reason}`; case 'refunded': return `Возврат ${p.amount} ₽`; } } Внутри каждой ветки TypeScript знает, какие поля доступны: receiptUrl есть только у оплаченного платежа. Нельзя случайно прочитать причину ошибки у успешного платежа. Проверка полноты Когда в объединение добавляют новый статус, все места, где его нужно обработать, должны перестать компилироваться. function assertNever(value: never): never { throw new Error(`Необработанный вариант: ${JSON.stringify(value)}`); } function isFinal(p: Payment): boolean { switch (p.status) { case 'paid': case 'refunded': return true; case 'pending': case 'failed': return false; default: return assertNever(p); } } Если добавить статус 'disputed' , вызов assertNever(p) получит значение не типа never , и компилятор укажет на каждое место, где новый статус не обработан. Состояния запроса Тот же паттерн устраняет классическую путаницу флагов isLoading , error и data , которые могут оказаться в противоречивом сочетании. type RemoteData T = | { state: 'idle' } | { state: 'loading' } | { state: 'error'; error: Error } | { state: 'success'; data: T }; Обобщённые типы с ограничениями Дженерики позволяют писать функции, которые работают с разными типами, сохраняя связь между входом и выходом. function groupBy T, K extends PropertyKey ( items: readonly T[], key: (item: T) = K, ): Record K, T[] { const result = {} as Record K, T[] ; for (const item of items) { (result[key(item)] ??= []).push(item); } return result; } const byStatus = groupBy(orders, (o) = o.status); // тип: Record 'new' | 'paid' | 'shipped', Order[] Ограничение K extends PropertyKey гарантирует, что ключ можно использовать как свойство объекта. Если функция возвращает литеральные значения статуса, ключи результата сохраняют точный тип. Ключи объекта function pick T, K extends keyof T (obj: T, keys: readonly K[]): Pick T, K { const out = {} as Pick T, K ; for (const k of keys) out[k] = obj[k]; return out; } const preview = pick(user, ['id', 'name']); // { id: string; name: string } Литеральные типы, as const и satisfies as const превращает значение в максимально узкий неизменяемый тип, а оператор satisfies проверяет соответствие типу, не расширяя выведенный тип значения. const ROLES = ['admin', 'manager', 'viewer'] as const; type Role = (typeof ROLES)[number]; // 'admin' | 'manager' | 'viewer' const permissions = { admin: ['read', 'write', 'delete'], manager: ['read', 'write'], viewer: ['read'], } satisfies Record Role, readonly string[] ; Если забыть одну из ролей или опечататься в названии, satisfies укажет на ошибку, а тип permissions сохранит точные ключи для автодополнения. Этот подход удобнее перечислений enum : объединение строковых литералов не генерирует дополнительного кода и естественно совместимо с данными из JSON. Брендированные типы Идентификаторы клиентов, заказов и счетов обычно строки. Для компилятора они взаимозаменяемы, и передача одного вместо другого — реальная и трудноуловимая ошибка. declare const brand: unique symbol; type Brand T, B extends string = T { readonly [brand]: B }; type ClientId = Brand string, 'ClientId' ; type OrderId = Brand string, 'OrderId' ; function getOrder(id: OrderId) { /* ... */ } const clientId = 'c_123' as ClientId; getOrder(clientId); // ошибка компиляции Бренд существует только на уровне типов и не влияет на данные во время выполнения. Значения нужного бренда создаются в одном месте — при чтении из базы данных или проверке входных данных — через функцию, которая выполняет проверку формата. Тот же приём полезен для сумм в разных валютах, уже проверенных строк вроде email и значений в разных единицах измерения. Сопоставленные типы Сопоставленные типы создают новый тип, преобразуя каждое свойство существующего. type FormErrors T = { [K in keyof T]?: string }; type Nullable T = { [K in keyof T]: T[K] | null }; // Переименование ключей через шаблонные литералы type Setters T = { [K in keyof T as `set${Capitalize string K }`]: (value: T[K]) = void; }; type ProfileSetters = Setters { name: string; age: number } ; // { setName: (value: string) = void; setAge: (value: number) = void } Встроенные утилиты — Partial , Required , Readonly , Pick , Omit , Record — построены на этом механизме. Знание того, как они устроены, позволяет строить собственные, когда встроенных недостаточно. Условные типы и infer Условный тип выбирает результат в зависимости от того, соответствует ли тип условию. Ключевое слово infer позволяет извлечь часть типа. type Awaited2 T = T extends Promise infer U ? U : T; type ElementOf T = T extends readonly (infer E)[] ? E : never; // Тип данных, которые возвращает функция загрузки type LoaderData F = F extends (...args: any[]) = Promise infer R ? R : never; Встроенные ReturnType , Parameters , Awaited и NonNullable — условные типы. В прикладном коде условные типы полезны прежде всего для вывода типов из уже существующих функций и конфигураций, чтобы не описывать их вручную и не допускать расхождений. Распределение по объединениям Условный тип над голым параметром применяется к каждому члену объединения отдельно. type OnlyStrings T = T extends string ? T : never; type R = OnlyStrings 'a' | 1 | 'b' ; // 'a' | 'b' Если такое поведение не нужно, параметр оборачивают в кортеж: [T] extends [string] . Шаблонные литеральные типы type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; type ApiRoute = `/api/${'orders' | 'clients'}`; type Endpoint = `${HttpMethod} ${ApiRoute}`; // 'GET /api/orders' | 'POST /api/orders' | ... type EventName T extends string = `on${Capitalize T }`; Полезны для описания событий, ключей переводов, маршрутов и CSS-классов с ограниченным набором значений. При большом числе комбинаций такие типы могут замедлять компилятор, поэтому их размер стоит держать под контролем. Сужение типов: защитники и утверждения function isPaid(p: Payment): p is Extract Payment, { status: 'paid' } { return p.status === 'paid'; } const receipts = payments.filter(isPaid).map((p) = p.receiptUrl); function assertDefined T (value: T, name: string): asserts value is NonNullable T { if (value == null) throw new Error(`${name} не задан`); } Функция-защитник сообщает компилятору, какой тип у значения после проверки. Функция-утверждение прерывает выполнение, если условие не выполнено, и сужает тип в последующем коде. Важно: TypeScript верит этим функциям на слово, поэтому их логика должна быть корректной. Типы и данные во время выполнения Главная граница TypeScript: типы исчезают после компиляции. Ответ API, данные формы, содержимое файла или переменные окружения могут не соответствовать объявленному интерфейсу, и приведение as User ничего не проверяет. Решение — схема валидации, из которой выводится тип. Библиотеки вроде Zod или Valibot позволяют описать структуру один раз и получить и проверку во время выполнения, и статический тип. import { z } from 'zod'; const LeadSchema = z.object({ name: z.string().min(1), email: z.string().email(), budget: z.enum(['small', 'medium', 'large']).optional(), }); type Lead = z.infer typeof LeadSchema ; export function parseLead(input: unknown): Lead { return LeadSchema.parse(input); // бросает ошибку при несоответствии } Правило: всё, что приходит извне системы, имеет тип unknown , пока не прошло проверку. Внутри системы после проверки можно доверять типам. Это же место — естественная точка создания брендированных значений. Когда типы становятся проблемой Типовая акробатика. Условный тип на десять строк, который понимает один человек в команде, — технический долг. Если тип сложнее логики, которую он описывает, стоит упростить. any как выход. any отключает проверки не только в месте использования, но и везде, куда значение дальше передаётся. Для неизвестных данных используйте unknown . Приведение типов вместо проверки. as скрывает ошибку, а не исправляет её. Каждое приведение — место, где стоит спросить, нельзя ли сузить тип проверкой. Дублирование. Интерфейс ответа API, описанный вручную рядом со схемой базы данных и схемой валидации, рано или поздно разойдётся с ними. Выводите типы из одного источника: схемы, сгенерированного клиента API, ORM. Медленная компиляция. Глубоко рекурсивные и комбинаторные типы замедляют компилятор и подсказки в редакторе. Если проверка типов стала заметно медленной, это сигнал упростить типы. Путь от базового к уверенному TypeScript Включить strict и убрать any из нового кода. Описывать состояния размеченными объединениями с проверкой полноты. Заменить перечисления и строковые константы на литеральные типы с as const и satisfies . Проверять внешние данные схемами и выводить из них типы. Писать обобщённые утилиты с ограничениями там, где код действительно переиспользуется. Применять брендированные типы для идентификаторов и значений, путаница которых опасна. Использовать условные и сопоставленные типы для вывода типов из существующего кода, а не для демонстрации возможностей. Типизированные веб-приложения на TypeScript, React и Node.js мы разрабатываем в рамках разработки веб-проектов . О принципах читаемого кода — в статье о Clean Code , о проверке кода командой — в материале о code review . Частые вопросы Нужен ли TypeScript небольшому проекту? Если проект будет жить дольше нескольких месяцев или над ним работает больше одного человека — да. Затраты на аннотации окупаются уже при первых изменениях существующего кода: компилятор показывает все места, которые нужно обновить. interface или type? Для описания формы объектов оба варианта взаимозаменяемы в большинстве случаев. type необходим для объединений, условных и сопоставленных типов, interface поддерживает объединение объявлений и привычен для публичных контрактов библиотек. Главное — единообразие в проекте. Стоит ли использовать enum? В прикладном коде объединения строковых литералов с as const обычно удобнее: они не генерируют