Как Omit {T, K} растворил типы, или что такое дистрибутивность типов в TypeScript

Представьте ситуацию: вы выкатываете новую фичу, а бэкендер спрашивает вас: «Слушай, а почему вместо событий — пустой объект?». Вы в шоке: «Как так — пустой объект? У меня же 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 на подобных молчаливых схлопываниях, делитесь наблюдениями в комментариях.
Автор: Zlaylink

