Привет, это Костя из Cloud.ru, я вернулся после долгого перерыва, чтобы снова открыть цикл коротких статей про Typescript. В этот раз поговорим о важном — дистрибуции типов. Зачем она вообще нужна, когда и как применяется, а также как работает. Как обычно интересное — под катом.

Зачем оно вам нужно

Для любителей оригинала и первоисточников — вот документация про это явление. Здесь я собрал самое важное — сделал выжимку и дополнил ее практическими примерами из наших проектов.

Перейдем к сути дела:

type ToArray1<T> = T[]; 
// ToArray1<string | number> => (string | number)[]

type ToArray2<T> = T extends any ? T[] : never;
// ToArray2<string | number> => string[] | number[]

Как вы могли заметить, в примере выше мы получаем разные результаты, хотя действие почти не изменяется. Если мы просто приводим T к массиву, то получаем (string | number)[], а добавив условное выражение T extends any, уже получаем string[] | number[]

Зачем же TS так сделал? Каждый раз, вычисляя условное выражение, TS под капотом выносит юнионы «за скобки». Если вы выполняете (string | number) extends any, то для TS это равносильно string extends any | number extends any

Именно такое поведение и называется дистрибуцией или распределением типов.

Случай дистрибуции

Как вы уже догадались, TS раскрывает юнионы, чтобы облегчить вычисление сложных выражений, ведь совершенно непонятно, по какой логике считать выражения вида (string | 5) extends (string | number | boolean). Однако юнионы могут встречаться не только в условных выражениях, но и в шаблонных строках. Вот пример такого выражения:

type People = 'Andrew' | 'Daria';

type Greeting = `Hello, ${People}!`; // "Hello, Andrew!" | "Hello, Daria!"

Как видите, People, который является юнионом, также раскрывается во время дистрибуции и превращает шаблонную строку в полноценный юнион из всех возможных вариантов строк.

«Какая бесполезная фича» — могли подумать вы, глядя на этот нелепый пример, и как раз давайте уже перейдем к реальной практике.

Пример 1. Попроще

Думаю, этот пример и так всем вам знаком, но не показать его в контексте дистрибуции было бы просто преступлением)

Как вы могли догадаться, тема дистрибуции тесно связана с union типами в TypeScript: статусами заказов, событиями, формами, API-ответами. Почти в любом более-менее реальном TypeScript-проекте есть запрос к бэкенду, который возвращает разные структуры в зависимости от параметров. Или логика, где набор вариантов надо корректно обработать — разделить обработчики, провалидировать входные значения или типизировать массивы. Всё это хочется делать так, чтобы ни на одном этапе не потерять контроль над типами и не дарить пользователям баги. В нашей задаче есть enum статусов заказа. Не все из них можно отменить.

enum OrderStatus {
  Pending = 'pending',
  Processing = 'processing',
  Shipped = 'shipped',
  Cancelled = 'cancelled',
}

Если пользователь может отменить только Pending и Processing, хочется такой же тип для кнопки отмены, чтобы нельзя было передать «неразрешенный» статус. Ниже идеальный случай для Extract/Exclude (стандартных утилит TypeScript, которые внутри уже используют дистрибуцию):

type CancellableStatuses = Exclude<
  OrderStatus,
  OrderStatus.Shipped | OrderStatus.Cancelled
>; 
// -> 'pending' | 'processing'

Причем тут дистрибуция? Так называемые «Встроенные» типы TypeScript на деле — это просто код, который вы сами можете написать. В этом случае все именно так — полный код Extract выглядит довольно элегантно и просто:

type Extract<T, U> = T extends U ? never : T;
// 'pending' | 'processing' | 'shipped' | 'cancelled' extends  'shipped' | 'cancelled'
// ('pending' extends 'shipped' | 'cancelled') | ('shipped' extends 'shipped' | 'cancelled')...
// 'pending' | never
// 'pending'

Теперь, если кто-то завтра добавит новый статус в OrderStatus, его обязательно придется осознанно включить или не включить в CancellableStatuses, ведь TypeScript гарантирует, что не пропустите обновления. Кстати, если хотите получить запрещенные к отмене статусы —просто добавьте еще один Exclude:

type NonCancellableStatus = Exclude<OrderStatus, CancellableStatuses>; 
// -> 'shipped' | 'cancelled'

Пример 2. Пример, вероятно, полукорректный

Во фронтенде бывают самые разные фильтры: текстовые, по дате, по тегам или любые другие.

type Filter = |
  { type: 'text'; value: string } | 
  { type: 'date'; value: Date } | 
  { type: 'tags'; value: string[] }

Давайте представим, что нам понадобилось сохранять значения этих фильтров, возможно, даже в нескольких местах. А для этого нужно уметь получать массив значений, которые соответствуют выбранному нами набору типов. Мы хотим получить примерно такой результат:

// Эти фильтры понадобятся нам для построения таблицы или поиска и тп
const filters = [
  { type: 'text', value: 'foo' },
  { type: 'date', value: new Date() },
] as const;

// А тут мы будем держать наши значения, для их нормализации и сохранения
// Именно в этой строке typescript будет проверять, 
// верно ли мы используем значения
const values: TupleValues<typeof filters> = ['foo', new Date()];

Чтобы написать такой дженрик, мы снова прибегаем к дистрибуции — пишем логику для обработки одного фильтра, а дистрибуция «расширит» за нас его логику на все остальные случаи:

type TupleValues<T extends readonly Filter[]> = {
  [K in keyof T]: // Для каждого индекса кортежа
    T[K] extends Filter ? // Если его значение соответствует фильтру
      T[K]['value']
      : never 
}

TypeScript гарантирует, что значения в массиве будут совпадать по типу и порядку с описанными фильтрами. Ошибиться невозможно, даже если фильтров пару десятков. Дистрибуция вооружает нас «типовым автокомплитом».

Пример 3. Маппинг обработчиков событий по типу

Хотя раньше, пожалуй, это было чаще, пользовательские события и сейчас встречаются регулярно:

type Events = | 
  { type: 'user_created', user: User } |
  { type: 'user_deleted', userId: number } |
  { type: 'user_updated', user: User } 

И теперь нам понадобится написать обработчик для всех этих событий. Но как не ошибиться в типах? Мы могли бы написать длинную цепочку if-else (как мы любим, конечно)), но сегодня я предлагаю попробовать дистрибуцию и написать типизированную мапу для Extract:

type EventHandler<E> = (event: E) => void;  

type EventMap<Ev extends { type: string }> = {
  [K in Ev as K['type']]: EventHandler<Extract<Ev, { type: K['type'] }>> };

type UserEventHandlers = EventMap<Events>; /* {   user_created: (event: { type: 'user_created', user: User }) => void;   user_deleted: (event: { type: 'user_deleted', userId: number }) => void;   user_updated: (event: { type: 'user_updated', user: User }) => void; } / 

Теперь любой промах IDE покажет вам как на ладони, уже на этапе компиляции, и не даст перепутать payload.

Пример 4. Разные схемы валидации для разных полей

Реальная задача во всяких формах — валидировать разные поля по разным правилам, а также возвращать не только флажок «валидно», но и дополнительные данные (например, нормализованный email после проверки):

type Field = { name: 'email'; value: string } 
  | { name: 'age'; value: number } 
  | { name: 'newsletter'; value: boolean };

А вот и условный тип с дистрибуцией:

type ValidationResult<T> = T extends { value: infer V }
  ? V extends string
    ? { valid: boolean; normalized: string }
    : { valid: boolean }
  : never;

type Results = ValidationResult<Field>; 
// -> { valid: boolean; normalized: string } | { valid: boolean }

Для каждого поля в union автоматически выдается «правильный ответ: для строки — ещё и normalized, для остальных — только валидность. Каждое из значений юниона обрабатывается отдельно, а затем «склеивается» в итоговый результат

Вам больше не нужно вручную описывать каждую комбинацию вариантов, достаточно один раз правильно настроить generic — и система сама сделает всю тяжелую работу.

Есть ли вывод

Часть примеров выше сразу можно скопировать и применить в вашем продакшене — все это работает благодаря дистрибуции.

Если хотите больше рецептов про типовые фичи TypeScript или есть конкретные задачи для разбора, буду ждать вас в комментариях!

Комментарии (2)


  1. Alexandroppolus
    06.11.2025 11:08

    Примеры 2 и 3 - это "mapping type", а не дистрибуция (во втором ещё и лишняя проверка T[K], есть же ограничение на T)

    Кстати, в mapping type есть механизм, аналогичный дистрибуции, если итерируемся по ключам "локального типа" в генерике: https://tsplay.dev/mprVMw - здесь мэппинг по отдельности обработал все элементы объединения, причем кортеж сохранил форму, а примитивы не поменялись.


    1. Konstantin_Loginovskikh Автор
      06.11.2025 11:08

      Спасибо тебе, добрый человек, что приходишь и каждый раз выдаешь дельные замечания)
      Вот любопытно, можем ли мы mappng type назвать частным случаем дистрибуции? Механизмы ведь у них довольно похожи, хоть и названия разные