satisfies, as const, branded types и строгий tsconfig
Первые три главы дали механику: типизация, дженерики, вычисления на уровне типов. Эта глава — чистая практика. Здесь собраны четыре приёма, которые отличают проект «с типами» от проекта «с типобезопасностью», плюс настройка компилятора и типизация границы с внешним миром — API-клиента.
В продакшене именно эти вещи ловят баги. Брендированный UserId, который нельзя перепутать с OrderId. satisfies в конфиге деплоя, который поймает опечатку в названии режима. as const в списке роутов, после которого IDE автодополняет только реальные пути. И tsconfig, который не даст твоему коллеге протащить any в ревью.
satisfies против as
Заголовок раздела «satisfies против as»Оба оператора говорят компилятору «доверься мне», но по-разному. as — приведение: «считай это типом T и замолчи». Оно может расширять тип, сужать его и даже лгать (as unknown as Target). satisfies — проверка: «проверь, что это выражение совместимо с T, но сохрани его точный тип». Оператор появился в TypeScript 4.9 — мотивацию и примеры из команды компилятора смотри в TypeScript 4.9 Release Notes.
const config = { mode: 'production', // без аннотаций: { mode: string } workers: 4,} satisfies Record<string, string | number>;
// config.mode: 'production' — литеральный тип сохранился!// А опечатка в значении поймана: satisfies сравнивает с Record<string, string | number>// и режим 'production' туда входит, а 'produciton' — тоже, оба string.// Но union из допустимых режимов — уже другая история:
const strictConfig = { mode: 'production',} satisfies { mode: 'production' | 'staging' | 'development' };// 'produciton' — ошибка компиляцииРазница критична для объектов, где ты хочешь и проверку, и точный вывод:
// ПЛОХО: as расширит тип до string — потеряли точностьconst modes = ['dev', 'prod'] as string[];
// ХОРОШО: satisfies проверит массивность, элементы останутся литераламиconst routes = ['/', '/dashboard', '/settings'] as const satisfies readonly string[];// readonly ['/', '/dashboard', '/settings'] — и проверено, и точноsatisfies особенно силён для конфигурационных объектов, которые сразу проверяются по схеме (часто — по типу из zod-схемы), но дальше используются с точными литеральными типами.
as const и readonly-вывод
Заголовок раздела «as const и readonly-вывод»as const — директива компилятору: «выведи из этого выражения максимально узкий readonly тип». Для литералов — литеральные типы, для массивов и объектов — readonly на все уровни. Эффект называется const assertion и был введён в TypeScript 3.4 — формальное описание есть в release notes 3.4:
const STATUS = { ok: 200, created: 201, notFound: 404,} as const;// { readonly ok: 200; readonly created: 201; readonly notFound: 404 }
const ROUTES = ['/', '/dashboard', '/settings'] as const;// readonly ['/', '/dashboard', '/settings']
type StatusCode = (typeof STATUS)[keyof typeof STATUS]; // 200 | 201 | 404type Route = (typeof ROUTES)[number]; // '/' | '/dashboard' | '/settings'Паттерн «объект как const + выборка union» заменяет enum в большинстве сценариев. Route теперь тип, который нельзя нарушить: передать 'dashbord' в функцию, принимающую Route, — ошибка компиляции.
При этом readonly-вывод — поверхностная подсказка: as const глубокий. Все вложенные массивы и объекты тоже становятся readonly, в отличие от Readonly<T>, который, как мы помним из прошлой главы, действует лишь на первый уровень.
Branded types: типизация ID и денег
Заголовок раздела «Branded types: типизация ID и денег»Структурная типизация говорит: number — это number. Но в домене userId: 42 и orderId: 42 — разные сущности, и их перепутать — баг. Branded types — паттерн, вводящий «искусственную» разницу на уровне типов без рантайм-стоимости:
// Бренд — фантомное свойство, существующее только в типахtype Brand<T, B> = T & { __brand: B };
type UserId = Brand<number, 'UserId'>;type OrderId = Brand<number, 'OrderId'>;type Email = Brand<string, 'Email'>;
declare const userId: UserId;declare const orderId: OrderId;
function getUser(id: UserId): void { /* ... */ }
getUser(userId); // окgetUser(orderId); // ошибка: OrderId ≠ UserIdgetUser(42); // ошибка: number ≠ UserId
// Создание — только через фабрику с валидацией (или cast, где допустимо)function createEmail(raw: string): Email { if (!raw.includes('@')) { throw new Error('Невалидный email'); } return raw as Email; // здесь as оправдан: валидация уже в рантайме}Ключевой момент: бренд — только в типах. В рантайме это обычный number/string, никакой обёртки и накладных расходов. Платишь один раз при создании (валидация/каст), получаешь защиту на всём пути данных.
Для денег бренд работает вместе с дисциплиной хранения — минорные единицы (копейки/центы) в целых числах:
type MinorUnits = Brand<number, 'MinorUnits'>; // копейкиtype MajorUnits = Brand<number, 'MajorUnits'>; // рубли
function rublesFromMinor(k: MinorUnits): MajorUnits { return (k / 100) as MajorUnits;}
// price: MinorUnits + метод toDisplay() — и никогда не сложишь// копейки с рублями молчаenum против as const
Заголовок раздела «enum против as const»enum — рантайм-конструкция: генерирует объект с обратным маппингом числовых значений. Это неожиданные байты в бандле и сюрпризы при сериализации. Поведение enum в деталях описано в TypeScript Handbook: Enums — полезно прочесть, чтобы понимать, с чем споришь:
enum Status { Active, Banned,}
// В рантайме Status — объект: { 0: 'Active', 1: 'Banned', Active: 0, Banned: 1 }const s = JSON.stringify(Status.Active); // "0" — число, не строка!С as const поведение предсказуемее: чистые литеральные типы, нулевой рантайм-код, сериализация — строки:
const STATUS = { Active: 'active', Banned: 'banned',} as const;
type Status = (typeof STATUS)[keyof typeof STATUS]; // 'active' | 'banned'const s = JSON.stringify(STATUS.Active); // "\"active\""Когда enum всё же уместен: битовые флаги (enum Flags { Read = 1, Write = 2 }), когда нужен числовой маппинг для протокола, и легаси-интеграции. В новом коде по умолчанию — as const.
Строгий tsconfig: разбор флагов
Заголовок раздела «Строгий tsconfig: разбор флагов»Типы работают, когда компилятор настроен строго. Полный справочник всех флагов с пояснениями и рекомендациями — TSConfig Reference (сгенерирован из исходников компилятора, всегда актуален). Вот боевой минимум с пояснением, что именно ловит каждый флаг:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true, "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "skipLibCheck": true }}strict — мастер-выключатель, включающий: noImplicitAny (запрет неявного any — главный), strictNullChecks (null/undefined проверяются отдельно, а не совместимы со всем), strictFunctionTypes (контравариантность параметров), strictBindCallApply, strictPropertyInitialization (поля класса инициализируются или имеют дефолт), noImplicitThis, alwaysStrict.
noUncheckedIndexedAccess — гвоздь программы: индексация arr[i], obj[key] даёт T | undefined. Без него TS врёт, что индекс всегда валиден. В проде это ловит целый класс рантайм-ошибок за счёт обязательных проверок:
declare const ports: number[];
// Без флага: number — ложь, может быть undefined// С флагом: number | undefined — честноconst p = ports[10];if (p !== undefined) { usePort(p);}exactOptionalPropertyTypes — различает email?: string и email: string | undefined. При включении передавать явный undefined в опциональное поле — ошибка. Ломает часть старых библиотек, на новых проектах — включать сразу.
noImplicitOverride — метод, перекрывающий родительский, обязан иметь ключевое слово override. Защита от случайной перезаписи при рефакторинге иерархий.
noFallthroughCasesInSwitch — запрет проваливания между case без break/return. Дублирует assertNever-паттерн для дискриминированных union-ов.
noPropertyAccessFromIndexSignature — обращение к свойствам, описанным индексной сигнатурой, только через квадратные скобки: obj['key'], не obj.key. Отделяет «задокументированные» поля от «любых строк».
Типизация API-клиента и генерация из OpenAPI/Prisma
Заголовок раздела «Типизация API-клиента и генерация из OpenAPI/Prisma»Типы — это контракт. На границе с внешним миром (HTTP API, БД) контракт лучше не писать руками, а генерировать из источника правды.
Ручной типобезопасный клиент
Заголовок раздела «Ручной типобезопасный клиент»Перед генерацией — пойми механику. Дженерик-клиент, выводящий тип ответа из пути:
// Контракт эндпоинтов: путь → параметры + ответinterface Endpoints { '/users': { params: { page?: number }; response: UserList }; '/users/:id': { params: { id: number }; response: User };}
async function apiGet<P extends keyof Endpoints>( path: P, params: Endpoints[P]['params'],): Promise<Endpoints[P]['response']> { const url = path.replace(':id', String((params as { id?: number }).id ?? '')) + ('page' in params ? `?page=${params.page}` : '');
const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as Endpoints[P]['response'];}
const users = await apiGet('/users', { page: 1 }); // UserListconst user = await apiGet('/users/:id', { id: 42 }); // User// await apiGet('/usres', {}); — опечатка в пути: ошибка компиляцииТипы здесь проверяют путь, параметры и возвращаемое значение. Но помни: типы исчезают при компиляции. Рантайм-данные могут быть чем угодно — поэтому в проде поверх ответа валидируют схемой (zod) или доверяют генерации из OpenAPI.
Генерация из OpenAPI
Заголовок раздела «Генерация из OpenAPI»OpenAPI-схема (Swagger) — описание эндпоинтов, параметров и схем ответов. Генератор openapi-typescript создаёт типы из этой схемы:
# Установка и генерация типов в types/api.d.tsnpm install -D openapi-typescriptnpx openapi-typescript https://api.example.com/openapi.json -o src/types/api.d.tsПолучаешь типы paths['/users']['get']['responses']['200']['content']['application/json'] — точные ответы по каждому пути и методу. Ошибка 400/500 тоже типизирована. Обновление схемы на сервере → перегенерация → клиент сразу видит изменения контракта.
Генерация из Prisma
Заголовок раздела «Генерация из Prisma»Prisma — ORM, где схема БД — источник правды. Генератор типов создаёт клиент с точными типами моделей:
model User { id Int @id @default(autoincrement()) email String @unique posts Post[]}npx prisma generateimport { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const user = await prisma.user.findUnique({ where: { id: 1 }, include: { posts: true },});// user: { id: number; email: string; posts: Post[] } | null — вывод точный,// включая include-ассоциации; изменение схемы → ошибки компиляции в запросахМиграция, добавившая поле в User, немедленно ломает все места, где это поле не обрабатывается. Это и есть типобезопасность на границе с данными.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»1. as вместо валидации на границе.
const data = JSON.parse(await res.text()) as User;// Компилятор доволен. Рантайм получает любой мусор.Защита: zod-схема с z.infer для типа, или генерация из OpenAPI + валидация ответов в dev-режиме.
2. Бренд через as без валидации.
raw as Email в любой точке кода обнуляет смысл бренда. Бренд — только через фабрику с проверкой (createEmail) или на границе после валидации схемой. Иначе через полгода as UserId размножится по коду, как раньше размножался any.
3. as const на мутабельном конфиге.
const config = { retries: 3 } as const;config.retries = 5; // ошибка — забыли, что as const замораживаетas const — для констант. Для изменяемого конфига — отдельный интерфейс и satisfies.
4. enum в API-контракте.
Числовой enum сериализуется числом, а при десериализации JSON — остаётся числом. Если сервер ждёт строку 'active', а клиент шлёт 0 — молчаливая поломка. Для API — строковые литералы из as const.
5. Отключение strict-флагов «временно».
// @ts-ignore и strict: false «на время миграции» живут годами. Правило: каждое отключение — с комментарием и тикетом; strict: false не коммитить в main.
6. Дублирование типов сервера и клиента руками.
Ручные interface User на фронте и бэке рассинхронизируются. Источник правды — один: OpenAPI для HTTP, Prisma для БД, общий пакет типов в монорепо.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»1. Чем satisfies отличается от as?
satisfies проверяет совместимость выражения с типом, сохраняя точный выведенный тип выражения; as — приведение, которое может расширять/сужать тип и отключает проверку на несовместимость. satisfies для проверки конфигов и литералов без потери литеральности; as — когда программист реально знает лучше компилятора (бренды, DOM, JSON.parse после валидации).
2. Что делает as const на уровне типов?
Выводит максимально узкий readonly-тип: литералы вместо примитивов, readonly-модификаторы на всех уровнях вложенности. Результат можно индексировать для получения union литералов: (typeof CONST)[keyof typeof CONST].
3. Как устроены branded types и почему это ноль в рантайме?
Пересечение примитива с фантомным свойством: type Email = string & { __brand: 'Email' }. Свойство не существует в рантайме — только в типах, поэтому значение остаётся обычной строкой. Создание — через фабрику с валидацией и единственный as внутри неё.
4. Когда enum уместен, а когда лучше as const?
enum — для битовых флагов и числовых протоколов; в остальном as const предпочтительнее: ноль рантайм-кода, предсказуемая сериализация, типы-литералы. Строковый enum и as const почти эквивалентны по типам, но enum добавляет объект в бандл.
5. Что ловит noUncheckedIndexedAccess?
Индексный доступ (arr[i], obj[key], Map.get) даёт T | undefined вместо T, заставляя проверять наличие перед использованием. Ловит выход за границы массива, отсутствие ключей в словарях, пустые результаты Map — целый класс рантайм-ошибок.
6. Как типизировать API-клиент без дублирования контракта?
Генерация из OpenAPI (openapi-typescript) — типы путей, параметров, ответов по каждому методу. Внутри — дженерик-клиент с Endpoints-мапой, выводящий тип по ключу пути. Для БД — Prisma generate. Ручные интерфейсы только для внутренних структур, не контрактов.
7. Чем exactOptionalPropertyTypes меняет семантику ??
Различает «поле отсутствует» и «поле есть, но значение undefined». При включении obj.key = undefined для key?: string — ошибка; нужен явный union string | undefined. Ловит лишние undefined в API-пейлоадах и конфигах.
Практика
Заголовок раздела «Практика»- Возьми конфиг деплоя из своего проекта (порты, хосты, фича-флаги) и перепиши его через
satisfiesпо интерфейсу с union-литералами для среды ('dev' | 'staging' | 'prod'); добавь опечатку в среду и убедись, что она поймана. - Внедри branded types:
UserId,OrderId,MinorUnits(копейки) с фабриками валидации. Рефактори функциюtransfer(fromId, toId, amount), где раньше шли триnumber, — убедись, что перестановка аргументов теперь невозможна. - Включи в своём проекте
noUncheckedIndexedAccessиexactOptionalPropertyTypes; пройди по всем ошибкам, чиня проверками на undefined и корректными опциональными полями. Запиши в отчёт: сколько мест было потенциально опасным. - Сгенерируй типы из любого публичного OpenAPI (GitHub, Petstore): установи
openapi-typescript, скачай схему, сгенерируй типы и напишиapiGetиз примера, проверив автодополнение путей и типов ответов. - Замени числовой enum статусов на объект с
as constи union-тип; проверь сериализацию в JSON до и после — зафиксируй разницу.
Критерий результата: ни одного as вне фабрик брендов и границ с валидацией; включены strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes; клиент API выводит типы без ручных дублирующих интерфейсов.
Что почитать
Заголовок раздела «Что почитать»- TypeScript 4.9 Release Notes — satisfies — официальное введение оператора.
- TypeScript Handbook: Enums и TypeScript ESLint: no-enum — аргументы за и против.
- Total TypeScript — Branded Types — паттерн в деталях, включая вывод брендов из схем.
- openapi-typescript — генерация типов из OpenAPI с примерами.
- Prisma Client — type safety — как устроен генератор типов Prisma.