
Представьте ситуацию: вы выкатываете новую фичу, а бэкендер спрашивает вас: «Слушай, а почему вместо событий — пустой объект?». Вы в шоке: «Как так — пустой объект? У меня же TypeScript, всё типизировано, такого не могло быть!». Спойлер — могло.
Меня зовут Денис Платонов, я старший разработчик интерфейсов в Телемосте и я отвечаю за on-premise развёртывание веб-клиента Телемоста в инфраструктуре заказчика. В этой статье я расскажу о том, как безобидный на первый взгляд Omit превратил наш аккуратный тип событий в пустой объект. Заодно разберём, как работает дистрибутивность типов в TypeScript и поделимся, чему нас научил этот кейс.
Базовый сценарий: разбираемся в Omit
Наверняка большинство из вас пишет на TypeScript и хотя бы раз использовали Omit. Разберёмся, как Omit ведёт себя с union-типами.
Допустим, у нас есть тип User с данными пользователя, среди которых есть поле secretInfo, которое нельзя отдавать наружу. Чтобы получить безопасную версию, мы создаём тип PublicUser через Omit:
// Это User, все по классике interface User { id: string; name: string; secretInfo: string; email: string; } // Нужно отправить на клиент, без secretInfo type PublicUser = Omit<User, 'secretInfo'>; // PublicUser: type PublicUser = { id: string; name: string; email: string; } const userOnUI: PublicUser = { id: '123', name: 'Alice', email: 'alice@ya.ru' }; // userOnUI.secretInfo; // ✅ Отлично, тут ошибки компиляции!
На простом объекте всё работает предсказуемо: в User остаются все поля, кроме secretInfo. Но проблемы начинаются, когда мы применяем Omit к union-типам. Представим два типа: тип A с ключами a и b и тип B с ключами c и d. Создаём тип Result, используем Omit a или b и пробуем убрать из этого union поле c.
type A = { a: string; b: string } type B = { c: string, d: string } type Result = Omit<A | B, 'c'>
Ждём, что TypeScript пройдётся по правой ветке union, исключит ключ c, и мы получим объект a или b с ключами a и b или объект с ключом d.
type Result = | { a: string; b: string } | { d: string } // а на выходе — пустой объект type Result = {}
Пусто. Ни a, ни b, ни d — вообще ничего.
На игрушечном примере это скорее забавно: ну схлопнулся тип, ну и ладно. В рабочем коде забавным быть перестаёт примерно сразу.
Давайте для примера возьмём сервис, через который идут пользовательские события: логины и платежи. Каждое описано своим интерфейсом, вместе они собраны в union Event. Дальше по цепочке события нужно передать без служебных полей — без timestamp и type. Первое, что приходит в голову, — накинуть Omit на весь union:
type LoginEvent = { type: 'login'; timestamp: number; userId: string; ip: string; }; type PaymentEvent = { type: 'payment'; timestamp: number; amount: number; currency: string; }; type Event = LoginEvent | PaymentEvent; // Хотим Event без 'timestamp' и 'type' type CustomEvent = Omit<Event, 'timestamp' | 'type'>; // Получили type CustomEvent = {}
И снова получаем пустой объект {}. Ошибки при этом нет: компилятор доволен, тип пустой, и в него теперь пролезает почти любой объект. А пустой тип не просто бесполезен, он опасен.
Этот очищенный event уходит в обработку бизнес-логики. Мы ожидаем, что внутри функции или в месте её вызова IDE подсветит типы и подскажет доступные поля, но на практике автокомплит отваливается: TypeScript теряет контекст и больше не понимает структуру объекта.
По сути мы возвращаемся к обычному JavaScript с бесполезными аннотациями, где с типами приходится работать вслепую.
type CustomEvent = Omit<Event, 'timestamp' | 'type'> // type CustomEvent = {} function processEvent(event: CustomEvent) { // Тело функции } // Пытаемся вызвать метод // ⛔ "Честный" автокомплит отсутствует, IDE бесполезна processEvent({ foo: "baz" })
Что такое пустой объект в TypeScript
Раз пустой объект вылезает так часто, стоит напомнить, как {} вообще работает в TypeScript.
На самом деле тип {} — это не пустой объект. При включённом strictNullChecks он обозначает любое не-nullish значение, то есть всё, кроме null и undefined — от чисел и строк до массивов и функций.
type CustomEvent = {} // Помним, что наш Omit дал это function sendToBackend(data: CustomEvent) { fetch('/api/events', { method: 'POST', body: JSON.stringify(data) }) } // ? ЭТО КОМПИЛИРУЕТСЯ И ОТПРАВИТСЯ НА СЕРВЕР! sendToBackend({ a: 1, b: 'hello', c: true }); sendToBackend(['foo', 'bar']); sendToBackend(new Date()); sendToBackend(42); sendToBackend('hello'); // ⚠️ TypeScript нас НЕ ЗАЩИЩАЕТ! Структура потеряна
В переменную с типом {} можно спокойно присвоить строку, число, массив, функцию или объект — и TypeScript не выдаст ни одной ошибки.
CI/CD и ошибки ИИ
По-настоящему больно становится, когда мы раскатываем этот подход на весь проект. Система типов уходит в режим молчания: функции можно передать вообще всё что угодно. Код пройдет проверки в CI/CD, проект скомпилируется без ошибок, а в пайплайне будут сплошные зелёные галочки.
Дальше подключаются ИИ-ассистенты. Они видят в аргументах {}, не находят никакого контракта и достраивают подсказку по соседнему коду. В итоге они подставляют невалидные поля.
А потом бэкенд получает структуру, которой не ждал. Падают ручки или тихо ломается логика — и при этом ни одного тревожного сигнала по дороге не было. Ситуация превращается в silent failure: TypeScript не защитил от ошибки на этапе компиляции, и баг всплывает уже на проде.
На этом этапе у меня возник вопрос: это баг или фича сознательная модель языка? Поведение выглядело откровенно сломанным, поэтому я вынес кейс в официальный Discord TypeScript.
Оговорюсь: здесь речь только про compile-time проверки. Рантайм-валидаторы вроде Zod — отдельный слой, он поймает такое на входе в приложение, но компилятору ничем не поможет. Лучше использовать такие инструменты как дополнение к проверкам.
Как появляются пустые объекты
Причина такого поведения — в реализации утилитарного типа Omit. В исходниках TypeScript он задан так:
type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }
Под капотом Omit использует комбинацию Pick, Exclude и keyof:
keyof Tсобирает ключи типа.Excludeвыкидывает из них ненужные ключиK.Pickформирует новый тип из оставшегося множества.
Всё выглядит логично, но давайте присмотримся к keyof T.
При вызове keyof (A | B) TypeScript возвращает не все ключи, а пересечение — только те поля, которые гарантированно есть в каждой ветке. Для LoginEvent и PaymentEvent таких два: type и timestamp. То есть именно те, которые мы и просили убрать.
Уникальные поля — userId, ip, amount, currency — просто отбрасываются. И когда мы передаем эти оставшиеся поля в Omit, он вырезает и их — на выходе получается пустой объект {}.

Чтобы понять, как это починить, нужно разобраться с дистрибутивностью типов. Возьмём для примера union-тип A | B и посмотрим на встроенный Partial: он не берёт объединение целиком, а применяется к каждой ветке по отдельности и собирает результаты обратно в union.

Стандартный Omit так делать не умеет. Из-за того, что в его реализации первым делом вызывается keyof T, он сразу схлопывает ключи всего объединения в пересечение и стирает уникальные поля ещё до того, как начнет их исключать.
Заставляем TypeScript распределяться
Чтобы заставить TypeScript обрабатывать каждый элемент union-типа отдельно, достаточно написать свой утилитарный тип — DistributiveOmit.
type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never; type Event = LoginEvent | PaymentEvent; type Result = DistributiveOmit<Event, 'timestamp' | 'type'>;
Проверим работу DistributiveOmit на том же Event:
type CustomEvent = DistributiveOmit<Event, 'timestamp' | 'type'>; // Получаем то что ожидали // Тип разворачивается в type CustomEvent = | { userId: string; ip: string } | { amount: number; currency: string }
Результат: структура типа не теряется, а автокомплит снова подсказывает правильные поля.
Работает это за счёт условного типа T extends any ? Omit<T, K> : never. В TypeScript проверка T extends any на generic-параметре включает дистрибутивность — компилятор прогоняет через Omit отдельно каждую ветку юниона:
Omit<LoginEvent, 'timestamp' | 'type'> | Omit<PaymentEvent, 'timestamp' | 'type'>
Теперь Omit выполняется для каждой ветки по отдельности.
На шаге Omit<LoginEvent, ...> оператор keyof работает с конкретным объектом и видит все его ключи — включая уникальные userId и ip. Exclude удаляет type и timestamp, а Pick собирает итоговый тип. Параллельно та же операция проходит для PaymentEvent, после чего очищенные типы объединяются обратно.
Благодаря DistributiveOmit мы возвращаем нормальное поведение IDE: автокомплит снова работает, а передать невалидный аргумент вместо очищенного события больше не получится.
И ещё важная мысль здесь — про сообщество. Моя история закончилась тем, что мы не просто нашли решение внутри команды, а подробно обсудили этот кейс с мейнтейнерами TypeScript в официальном канале в Discord. В итоге кейс оказался настолько показательным, что его часть попала в официальные материалы по языку.
Почему нельзя просто взять и отфильтровать {}
Первое, что приходит в голову, — а почему бы просто не написать утилиту, которая выкинет {} из итогового union, и жить дальше?
Увы, метод не сработает. Скажу больше — это вообще плохая идея. Для TypeScript {} — не сломанный тип, а фундаментальный: он означает любое «не-nullish» значение. На него опирается и компилятор, и сторонние библиотеки.
Фильтр же не будет разбираться, откуда взялся {}. Он одинаково выкинет и схлопнувшийся CustomEvent, и {}, который поставили осмысленно. Так что утилитой мы рискуем сломать кучу легаси-кода и вдогонку получить непредсказуемое поведение остальных типов.
Так что лучше устранять не симптомы, а первопричину: не дать контракту схлопнуться, вместо того чтобы подчищать за ним пустоту.
Выводы и правила, которые помогут избежать тихих падений в проде
На основе этого кейса мы в команде сформулировали три правила:
Никакого
Omitнадunion— толькоDistributiveOmit. И не «внимательнее на ревью», а готовая утилита в общем модуле, которую импортируют. На внимательность тут рассчитывать нечего: схлопнувшийся тип на ревью не видно, он выглядит как совершенно нормальный код.Тесты на типы. Проверка уровня
tsdилиattestв духе «этот тип не должен превращаться в{}» ловит такое на сборке, а не на проде. Одна строчка на каждый тип, который уходит наружу.keyof T отдельным шагом — повод притормозить. Если
keyof Tсчитается отдельно и результат уходит дальше, как вOmit, ключи схлопываются в пересечение ещё до всякой обработки. Свои и чужие хелперы с таким устройством стоит проверять на union отдельно.
Надеюсь, наш опыт поможет вам избежать падений в проде из-за простого {}. А если вы ловили другие стандартные утилиты TypeScript на подобных молчаливых схлопываниях, делитесь наблюдениями в комментариях.
Alexandroppolus
Очевидно, с Pick та же проблема. Он превращает, например, discriminated union в мешанину: