Привет, это Костя из 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 или есть конкретные задачи для разбора, буду ждать вас в комментариях!
Alexandroppolus
Примеры 2 и 3 - это "mapping type", а не дистрибуция (во втором ещё и лишняя проверка T[K], есть же ограничение на T)
Кстати, в mapping type есть механизм, аналогичный дистрибуции, если итерируемся по ключам "локального типа" в генерике: https://tsplay.dev/mprVMw - здесь мэппинг по отдельности обработал все элементы объединения, причем кортеж сохранил форму, а примитивы не поменялись.
Konstantin_Loginovskikh Автор
Спасибо тебе, добрый человек, что приходишь и каждый раз выдаешь дельные замечания)
Вот любопытно, можем ли мы mappng type назвать частным случаем дистрибуции? Механизмы ведь у них довольно похожи, хоть и названия разные