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

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

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

  1. keyof T собирает ключи типа.

  2. Exclude выкидывает из них ненужные ключи K.

  3. Pick формирует новый тип из оставшегося множества.

Всё выглядит логично, но давайте присмотримся к keyof T

При вызове keyof (A | B) TypeScript возвращает не все ключи, а пересечение — только те поля, которые гарантированно есть в каждой ветке. Для LoginEvent и PaymentEvent таких два: type и timestamp. То есть именно те, которые мы и просили убрать.

Уникальные поля — userId, ip, amount, currency — просто отбрасываются. И когда мы передаем эти оставшиеся поля в Omit, он вырезает и их — на выходе получается пустой объект {}.

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

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

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

Стандартный 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

Источник

Оставить комментарий