Типобезопасные паттерны
Прикладные решения, переносящие проверки в компиляцию — реестры типов «ключ → payload», типизированные event emitter и WebSocket-протоколы, обёртка над fetch с выводом по эндпоинту, плагинные системы, обобщённые репозитории, типизированные цепочки middleware в Express и process.env.
12 вопросов
JuniorТеорияОчень частоПочему любое чтение process.env.API_URL имеет тип string | undefined и как с этим работать безопасно?
Почему любое чтение process.env.API_URL имеет тип string | undefined и как с этим работать безопасно?
У process.env индексная сигнатура над string | undefined: компилируется любое имя, а чтение может ничего не дать. Разберите его на старте, проверьте обязательные ключи, отдайте типизированный конфиг. Дополнение ProcessEnv до string лишь утверждает их, отодвигая падение.
Типичные ошибки
- ✗Дополнять
ProcessEnv, объявляя ключи какstring, — это утверждение, а не проверка - ✗Читать переменные окружения вразнобой в глубине кода вместо одного разбора на старте
- ✗Ждать, что компилятор знает, какие переменные реально выставлены при развёртывании
Уточняющие вопросы
- →Чем валидатор схемы лучше рукописной проверки каждого обязательного ключа?
- →Что сломается, если два модуля читают
process.envпри импорте в разном порядке?
JuniorТеорияОчень частоКак типизировать request и response обработчика маршрута в веб-фреймворке Express?
Как типизировать request и response обработчика маршрута в веб-фреймворке Express?
Express отдаёт обработчик как RequestHandler<Params, ResBody, ReqBody, Query>: аннотируют эти слоты обобщения, а не параметры колбэка. Получаются типизированные req.params, req.body и res.json. В рантайме не проверяется ничего: тип тела — утверждение.
Типичные ошибки
- ✗Ждать, что Express выведет
req.paramsиз строки маршрута - ✗Считать, что типизированный
req.bodyпроверяется в рантайме - ✗Аннотировать параметры колбэка вместо слотов обобщения обработчика
Уточняющие вопросы
- →Где на самом деле проверять
req.body, чтобы объявленный тип перестал быть ложью? - →Почему порядок аргументов типа в
Request<P, ResBody, ReqBody, Query>так часто путают?
JuniorТеорияЧастоКак объявить интерфейс, сопоставляющий имена типам payload, и индексировать его через keyof?
Как объявить интерфейс, сопоставляющий имена типам payload, и индексировать его через keyof?
Объявите интерфейс, ключи которого — имена, а типы свойств — payload. Тогда keyof Events — объединение имён, поэтому параметр можно ограничить ровно этими ключами, а Events[K] для K extends keyof Events даёт payload именно этого ключа.
Типичные ошибки
- ✗Думать, что
keyof Tдаёт типы свойств, а не их имена - ✗Скатываться к
Record<string, unknown>, что стирает связь имени с payload - ✗Считать, что интерфейс нельзя индексировать обобщённым параметром
K extends keyof T
Уточняющие вопросы
- →Как сделать payload одного ключа необязательным, чтобы вызывающий мог совсем опустить аргумент?
- →Что даёт
Events[keyof Events]и когда это объединение полезно?
MiddleДизайнЧастоВ приложении один общий emitter, которым пользуется десяток модулей. Сейчас on и emit принимают имя-string и payload-any, поэтому переименованное событие или payload без одного поля падают молча: обработчик либо не вызывается вовсе, либо получает undefined там, где ждал id. Вы хотите ограничить имя события реально существующими событиями и привязать payload каждого обработчика к имени, на которое он подписан, — без приведений на местах вызова и без дублирования между списком имён и списком форм payload. Опишите, как вы типизируете emitter, какими станут сигнатуры on и emit и что увидит вызывающий, отправив известное имя с неверным payload.
В приложении один общий emitter, которым пользуется десяток модулей. Сейчас on и emit принимают имя-string и payload-any, поэтому переименованное событие или payload без одного поля падают молча: обработчик либо не вызывается вовсе, либо получает undefined там, где ждал id. Вы хотите ограничить имя события реально существующими событиями и привязать payload каждого обработчика к имени, на которое он подписан, — без приведений на местах вызова и без дублирования между списком имён и списком форм payload. Опишите, как вы типизируете emitter, какими станут сигнатуры on и emit и что увидит вызывающий, отправив известное имя с неверным payload.
Сделайте emitter обобщённым по реестру «имя события → payload». on и emit становятся обобщёнными по K extends keyof E и принимают name: K и payload типа E[K]: имя ограничено ключами реестра, а неверный payload — ошибка в этом аргументе.
Типичные ошибки
- ✗Держать имена и формы payload в двух объявлениях, которые расходятся
- ✗Типизировать payload объединением всех форм, заставляя каждый обработчик сужать его заново
- ✗Считать, что перегрузки — единственный способ менять тип payload по имени события
Уточняющие вопросы
- →Как типизировать событие без payload, чтобы
emit('close')не требовал второго аргумента? - →Что изменится, если обработчики могут быть асинхронными и
emitобязан дождаться их всех?
MiddleКодЧастоДать обработчику увидеть user, которого добавил в запрос auth-middleware
Дать обработчику увидеть user, которого добавил в запрос auth-middleware
Не дополняйте глобальный Request. Внедрённое свойство — это пересечение Request & { user: User }. withAuth возвращает обычный RequestHandler, который кладёт user и зовёт внутренний обработчик с суженным запросом: только он видит user, и видит обязательным.
Типичные ошибки
- ✗Глобально дополнять
Request, из-за чего каждый обработчик верит в наличиеuser - ✗Делать
userнеобязательным и сыпать утверждения не-null в каждом обработчике - ✗Регистрировать в роутере обработчик, требующий
AuthedRequest, напрямую
Уточняющие вопросы
- →Как сложить две такие обёртки, чтобы обработчик видел и
user, иtenant? - →Почему глобальное дополнение ослабляет гарантию даже при необязательном свойстве?
MiddleДизайнЧастоСервисному слою нужен один репозиторий, переиспользуемый десятком сущностей — users, orders, invoices, — у каждой своя форма строки и свой тип id. Сейчас на каждую сущность заведён почти одинаковый класс Repository, и на подходе четырнадцатый. Нужен единый репозиторий, у которого findById, insert и update проверяются против конкретной сущности: вставка строки orders в репозиторий users или фильтр по отсутствующей у сущности колонке не должны компилироваться. Опишите, как вы типизируете репозиторий по сущностям, как выглядят сигнатуры методов и где в типы входит форма строки сущности.
Сервисному слою нужен один репозиторий, переиспользуемый десятком сущностей — users, orders, invoices, — у каждой своя форма строки и свой тип id. Сейчас на каждую сущность заведён почти одинаковый класс Repository, и на подходе четырнадцатый. Нужен единый репозиторий, у которого findById, insert и update проверяются против конкретной сущности: вставка строки orders в репозиторий users или фильтр по отсутствующей у сущности колонке не должны компилироваться. Опишите, как вы типизируете репозиторий по сущностям, как выглядят сигнатуры методов и где в типы входит форма строки сущности.
Заклиньте репозиторий на реестр сущностей «имя → тип строки» и сделайте класс обобщённым по K extends keyof Entities. Сигнатуры выводятся из Entities[K]: тип id, вставляемая строка, колонки фильтра. Чужая строка или неизвестная колонка не скомпилируются.
Типичные ошибки
- ✗Параметризовать только типом строки, оставляя имя таблицы непроверяемой
string - ✗Типизировать фильтры как
Record<string, unknown>вместо вывода колонок из строки - ✗Считать, что один класс не может менять тип параметра метода под сущность
Уточняющие вопросы
- →Как выразить, что
insertвозвращает строку с уже заполненным сгенерированнымid? - →Чем этот подход платит, когда одной сущности нужен запрос, которого нет у остальных?
MiddleКодЧастоОбёртка над fetch, у которой тип результата выводится по эндпоинту
Обёртка над fetch, у которой тип результата выводится по эндпоинту
Один реестр маршрутов. request обобщён по K extends keyof Routes, принимает params: Routes[K]['params'] и возвращает Promise<Routes[K]['result']> — оба индексные доступы по ключу эндпоинта, поэтому вывод ведёт один литерал, без приведений.
Типичные ошибки
- ✗Заставлять вызывающего задавать тип результата явно вместо вывода из маршрута
- ✗Хвататься за перегрузки там, где один обобщённый параметр-ключ уже меняет оба типа
- ✗Считать, что аргумент-строковый литерал не может задать параметр типа
Уточняющие вопросы
- →Как разрешить вызывать маршрут с
params: voidвсего одним аргументом? - →Куда добавить рантайм-проверку, чтобы
resultперестал быть непроверенным обещанием?
MiddleДизайнИногдаВы добавляете систему плагинов в сборщик. Плагин может реализовать любое подмножество хуков инструмента, а формы хуков по-настоящему разные: transform(code: string, id: string): string, resolve(spec: string): string | null и done(stats: Stats): void. Сторонние плагины — обычные объекты. Автор плагина должен получить ошибку, если у реализованного хука не те параметры или тип возврата, а хост — обойти зарегистрированные обработчики одного хука, где каждый обработчик правильно типизирован, — без Record<string, Function> и без приведений внутри хоста. Опишите, как вы типизируете реестр хуков, объект плагина, регистрацию и рассылку в хосте.
Вы добавляете систему плагинов в сборщик. Плагин может реализовать любое подмножество хуков инструмента, а формы хуков по-настоящему разные: transform(code: string, id: string): string, resolve(spec: string): string | null и done(stats: Stats): void. Сторонние плагины — обычные объекты. Автор плагина должен получить ошибку, если у реализованного хука не те параметры или тип возврата, а хост — обойти зарегистрированные обработчики одного хука, где каждый обработчик правильно типизирован, — без Record<string, Function> и без приведений внутри хоста. Опишите, как вы типизируете реестр хуков, объект плагина, регистрацию и рассылку в хосте.
Реестр хуков сопоставляет каждому имени сигнатуру обработчика. Плагин — это Partial<Hooks>: любое подмножество, каждый хук сверяется со своей сигнатурой. Держите регистрацию и рассылку обобщёнными по K extends keyof Hooks: обработчики хука выходят типа Hooks[K].
Типичные ошибки
- ✗Типизировать таблицу хуков как
Record<string, Function>, выбрасывая все сигнатуры - ✗Требовать номинальный базовый класс там, где
Partialреестра уже проверяет каждый хук - ✗Приводить типы внутри хоста, потому что рассылку не сделали обобщённой по имени хука
Уточняющие вопросы
- →Как позволить хуку быть асинхронным в одних плагинах и синхронным в других?
- →Что изменится, если результат хука нужно передавать в обработчик следующего плагина?
MiddleКодИногдаВывести объединение WebSocket-сообщений и карту обработчиков из одного реестра
Вывести объединение WebSocket-сообщений и карту обработчиков из одного реестра
Отображённый тип, индексированный через keyof, выводит из реестра объединение { type: K } & Messages[K]; второй по тем же ключам выводит карту обработчиков, где каждый принимает своё сообщение. dispatch делает switch по разметке, и новое сообщение не скомпилируется.
Типичные ошибки
- ✗Пересекать
keyofс объединением payload, сочетая каждое имя с каждой формой - ✗Писать объединение руками рядом с реестром, из-за чего они расходятся
- ✗Считать, что отображённый тип не может дать объединение объектных типов
Уточняющие вопросы
- →Почему
dispatchстановится ошибкой компиляции сразу после добавления сообщения в реестр? - →Как расширить решение, если наборы сообщений сервера и клиента различаются?
SeniorДизайнИногдаКонвейер запроса собирается цепочкой — chain.use(requestId).use(auth).use(tenant).handle(...). Каждый шаг кладёт что-то в общий контекст: requestId добавляет id, auth добавляет user, tenant добавляет org, выведенный из этого user. Сейчас контекст — один интерфейс, где все ключи необязательные, поэтому каждый шаг читает ctx.user!, и ничто не мешает зарегистрировать tenant раньше auth: код компилируется и падает в рантайме на undefined. Вы хотите, чтобы каждый шаг видел — типизированным и обязательным — ровно то, что добавили шаги до него, и ничего сверх; чтобы итоговый обработчик видел всю накопленную сумму, и никто не переобъявлял её руками; и чтобы неверный порядок был ошибкой компиляции, а не звонком в три часа ночи. Никакого глобального дополнения типов, никакого any, никаких приведений. Опишите, как вы типизируете use и саму цепочку и что в итоге видит обработчик.
Конвейер запроса собирается цепочкой — chain.use(requestId).use(auth).use(tenant).handle(...). Каждый шаг кладёт что-то в общий контекст: requestId добавляет id, auth добавляет user, tenant добавляет org, выведенный из этого user. Сейчас контекст — один интерфейс, где все ключи необязательные, поэтому каждый шаг читает ctx.user!, и ничто не мешает зарегистрировать tenant раньше auth: код компилируется и падает в рантайме на undefined. Вы хотите, чтобы каждый шаг видел — типизированным и обязательным — ровно то, что добавили шаги до него, и ничего сверх; чтобы итоговый обработчик видел всю накопленную сумму, и никто не переобъявлял её руками; и чтобы неверный порядок был ошибкой компиляции, а не звонком в три часа ночи. Никакого глобального дополнения типов, никакого any, никаких приведений. Опишите, как вы типизируете use и саму цепочку и что в итоге видит обработчик.
Протяните контекст через тип: цепочка обобщена по C, а use, обобщённый по добавке своего шага, возвращает цепочку C & Extra. Шаг видит лишь тот C, на котором его зарегистрировали: ключ, добавленный позже, не скомпилируется, а обработчик получает всю сумму.
Типичные ошибки
- ✗Объявлять один интерфейс контекста со всеми необязательными ключами, чтобы шаг читал
ctx.user! - ✗Считать, что тип цепочки не способен накапливать добавку каждого следующего шага
- ✗Глобально дополнять тип запроса, из-за чего шаг читает ключ, который никто до него не добавил
Уточняющие вопросы
- →Как позволить шагу объявить, что ему нужен
user, чтобы регистрация доauthне прошла? - →Что происходит с накопленным типом, если шаг кладёт свой ключ лишь на части запросов?
SeniorДизайнРедкоСервисный слой сейчас связан вручную, и вы вводите паттерн внедрения зависимостей DI container. На столе два варианта. Первый — на классах: сервисы это классы, параметры конструктора помечены декораторами, а контейнер понимает, что конструировать, по метаданным, которые оставляет флаг компилятора emitDecoratorMetadata и вычитывает библиотека рефлексии reflect-metadata. Второй — на функциях: обычный интерфейс-реестр сопоставляет каждому токену тип, который тот отдаёт, а каждый сервис регистрируется фабричным замыканием, само достающим свои зависимости из контейнера. Работать будут оба. Сравните их именно по типизации: что компилятор реально доказывает в каждом, что происходит, когда зависимость — интерфейс, а не класс, что возвращает вызов get или resolve и как каждый ведёт себя, если токен так и не привязали. Скажите, что бы вы отгрузили и чем за это платите.
Сервисный слой сейчас связан вручную, и вы вводите паттерн внедрения зависимостей DI container. На столе два варианта. Первый — на классах: сервисы это классы, параметры конструктора помечены декораторами, а контейнер понимает, что конструировать, по метаданным, которые оставляет флаг компилятора emitDecoratorMetadata и вычитывает библиотека рефлексии reflect-metadata. Второй — на функциях: обычный интерфейс-реестр сопоставляет каждому токену тип, который тот отдаёт, а каждый сервис регистрируется фабричным замыканием, само достающим свои зависимости из контейнера. Работать будут оба. Сравните их именно по типизации: что компилятор реально доказывает в каждом, что происходит, когда зависимость — интерфейс, а не класс, что возвращает вызов get или resolve и как каждый ведёт себя, если токен так и не привязали. Скажите, что бы вы отгрузили и чем за это платите.
Рефлексия видит только ссылки на классы: интерфейс стирается и всё равно требует токена, а get<T>(token) возвращает названный вызывающим T без проверки. Реестр заставляет resolve возвращать S[K] и требует фабрику на каждый токен — ценой явной проводки.
Типичные ошибки
- ✗Считать, что метаданные декораторов разрешат интерфейс, у которого нет рантайм-значения
- ✗Доверять
get<T>(token)как проверенному, хотяT— просто то, что назвал вызывающий - ✗Думать, что реестр «токен → тип» нельзя индексировать, и потому приводить каждую выдачу
Уточняющие вопросы
- →Как заставить компилятор отвергнуть сервис, чья фабрика забыла одну из своих зависимостей?
- →Что ломается, когда один токен обязан разрешаться на каждый запрос, а не однажды как singleton?
SeniorКодРедкоПроверять имена событий и их payload на компиляции в наследнике EventEmitter
Проверять имена событий и их payload на компиляции в наследнике EventEmitter
Сделайте наследника обобщённым по реестру «имя события → кортеж аргументов» и перекройте on и emit обобщёнными по K extends keyof E: event: K, ...args: E[K]. Неизвестное имя или неверный payload не компилируются, а приведение остаётся внутри класса.
Типичные ошибки
- ✗Считать, что перекрытие не вправе сузить типы параметров базовых
onиemit - ✗Типизировать один общий для всех событий payload вместо кортежа на каждое имя
- ✗Глобально дополнять
EventEmitter, из-за чего все emitter в процессе делят одну карту событий
Уточняющие вопросы
- →Как согласовать
onceиoff, чтобы снятый по ссылке слушатель по-прежнему проходил проверку типов? - →Что изменится, если имя сопоставлено одному объекту-payload, а не кортежу аргументов?