Типобезопасные паттерны
Все паттерны этой темы — вариации одного хода: взять проверку, которая иначе всплыла бы багом в рантайме (переименованное событие, ответ API без одного поля, чужой id в репозитории), и переложить её на компилятор. Механизм у них общий. Объявляется один тип-реестр, сопоставляющий строковый ключ его значению — interface Registry { "user.created": UserCreatedPayload; "order.paid": OrderPaidPayload }, — а всё остальное выводится из него: keyof Registry даёт объединение допустимых ключей, Registry[K] — значение конкретного ключа, а обобщённая сигнатура <K extends keyof Registry>(key: K, value: Registry[K]) превращает несовпадающую пару в ошибку компиляции. Один источник истины, ноль дублирования между «списком имён» и «списком форм», которые в наивном коде живут порознь и расходятся.
Ловушка, общая для всей темы, — граница с внешним миром. Типы в TypeScript стираются: до рантайма не доживает ни реестр, ни keyof, ни один параметр типа. Поэтому обёртка над fetch, чей тип результата «выводится по эндпоинту», на деле лишь переименовывает any, который вернул res.json(); протокол WebSocket, размеченный по полю type, — это утверждение о том, что придёт по проводу, а не его проверка; дополнение NodeJS.ProcessEnv до string — прямая ложь, если переменная не выставлена. Компилятор проверяет согласованность внутри кода — что emit и его обработчик не разъедутся, — но не может проверить данные, которых он не видел. Честная версия каждого паттерна валидирует ровно на входе (см. Валидацию в рантайме) либо отдаёт unknown и заставляет вызывающего сузить тип самому. Сами по себе эти решения — прикладное применение mapped-, условных и template-literal-типов из Продвинутых типов; здесь важно не как они устроены, а куда их прикладывают.
Карта темы
- Реестры типов «ключ → payload» — один
interface, сопоставляющий строковый ключ его payload, и вывод всего остального черезkeyofиRegistry[K]. - Обёртка над fetch с выводом по эндпоинту — почему
res.json()возвращаетanyи чем типизированная обёртка отличается от честной валидации на границе. - Типизация request/response — реестр эндпоинтов, вывод тела и params из строки маршрута и почему типизированный
req.bodyв Express — лишь утверждение. - Типизированный event emitter — обобщение по реестру событий, сигнатуры
on/emitи variadic-кортеж для событий без payload. - Типизация протокола — вывод размеченного объединения сообщений из реестра,
switchс проверкой наneverи корреляция запрос/ответ. - Типизация middleware — накопление контекста в типе через обёртку против глобального дополнения
Request, навсегда делающегоreq.userнеобязательным. - Обобщённые репозитории —
Omit/Partial/Pickкак защита от чужого id и вывод типа проекцииselectпо выбранным ключам. - Плагинные системы — два механизма расширения (declaration merging против обобщённой композиции) и их компромисс.
- Типизация process.env — почему любое чтение даёт
string | undefinedи почему разбор схемой на старте честнее, чем дополнениеProcessEnv.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Объявить реестр как Record<string, unknown> | keyof над строковой индексной сигнатурой даёт string — связь имени с payload стёрта |
Думать, что keyof Registry даёт объединение payload | keyof возвращает имена (ключи), а не типы свойств; payload берётся отдельным Registry[K] |
Верить, что обёртка api<T>(url) проверяет ответ | Параметр типа лишь переименовывает any из res.json() — в рантайме проверки нет |
Считать типизированный req.body проверенным | Это утверждение; без валидатора на входе тело может быть любым |
| Аннотировать параметры колбэка Express напрямую | Типы не подхватятся — форма идёт в слоты RequestHandler<Params, ResBody, ReqBody, Query> |
| Держать имена событий и формы payload в двух объявлениях | Они расходятся; нужен один реестр E и индексный доступ E[K] |
| Типизировать payload emitter объединением всех форм | Каждый обработчик вынужден сужать его заново охранником типа |
Строить объединение как { type: keyof M } & M[keyof M] | Сочетает каждое имя с каждым payload — разметка перестаёт различать |
Забыть ветку default с присваиванием в never | Новое сообщение протокола не будет обнаружено при компиляции |
Дополнить глобальный Express.Request полем user? | Каждый обработчик «видит» его, а необязательность лечится россыпью req.user! |
Параметризовать репозиторий только типом строки Repository<T> | Имя таблицы остаётся непроверяемой string — чужая таблица скомпилируется |
Типизировать фильтр как Record<string, unknown> | Колонки не выводятся из строки — фильтр по несуществующей колонке компилируется |
Типизировать таблицу хуков как Record<string, Function> | Все сигнатуры выброшены — плагин ничем не проверяется |
Дополнить NodeJS.ProcessEnv до string | Утверждение, а не проверка; при незаданной переменной рантайм всё равно даст undefined |
Читать process.env вразнобой в глубине кода | Исход зависит от порядка импортов вместо одного разбора и проверки на старте |
Полагать, что as на границе с res.json() что-то проверяет | as стирается — это обещание, которого рантайм не сдержит |
Значение для собеседований
Тему спрашивают, чтобы отделить кандидата, который на «типизируй мне это» тянется к перегрузкам, дублированию и any, от того, кто видит за девятью задачами один приём: один реестр плюс индексный доступ по обобщённому ключу. Первый на каждое новое событие дописывает пару перегрузок вручную; второй объявляет interface, а keyof и E[K] делают остальное. Второй, более тонкий срез — понимание границы: сильный кандидат сам скажет, что «тип результата выводится по эндпоинту» и «протокол размечен по type» — это утверждения о данных, а стирание типов означает, что проверить их может только валидатор в рантайме, а не аннотация.
Что обычно проверяют:
- Что
keyof Registry— это объединение имён, аRegistry[K]— значение ключа, и как один обобщённыйKсвязывает пару. - Почему
res.json()— этоPromise<any>и чемapi<T>отличается отschema.parse(await res.json()). - Где на самом деле проверять
req.body, чтобы объявленный тип перестал быть ложью. - Как из одного реестра вывести и объединение сообщений, и карту обработчиков, и зачем ветка
never. - Почему глобальное дополнение
Requestослабляет гарантию даже при необязательномuser, и что даёт накопление контекста в типе. - Как
Omit/Partial/Pickне дают задать серверный id и как проекцияselectсужает тип результата. - Разницу между расширением через declaration merging и обобщённой композицией плагинов.
- Почему
process.env.FOO— этоstring | undefinedи почему разбор схемой на старте честнее дополненияProcessEnv.
Типичный неверный ответ: «Обёртка api<User>(url) делает ответ типобезопасным». Отсюда весь разговор и разворачивается: параметр типа T не порождает никакой проверки — as T внутри обёртки стирается, а res.json() как был any, так и остаётся, только теперь под именем User. Обёртка не перенесла ни одной проверки в компилятор, она лишь переклеила ярлык. Второй классический провал — «типизированный req.body защищает от плохого запроса»: в рантайме Express не сверяет тело ни с чем, объявленный тип — это обещание, а не валидация, и единственное место, где обещание становится проверкой, — явный вызов схемы на входе.