Условные и mapped-типы, keyof и утилиты
Дженерики из прошлой главы — это переменные. Теперь берём переменные и строим из них вычисления на уровне типов: условные типы как if, mapped-типы как цикл по ключам, template literal types как интерполяция строк. Это тот момент, когда TypeScript перестаёт быть «аннотациями» и становится языком метапрограммирования, в котором ты пишешь код, исполняемый компилятором во время проверки.
В краткой версии ты уже видел Nullable<T>, Unwrap<T> и встроенные утилиты. Здесь откроем капот: как именно устроены Partial и Pick, почему Partial «съедает» методы объекта, как работает as внутри mapped-типа для переписывания ключей, и когда условный тип распределяется по union, а когда — нет.
Условные типы: if на уровне типов
Заголовок раздела «Условные типы: if на уровне типов»Условный тип — это T extends U ? X : Y, вычисляемый компилятором при каждой инстанциации:
// Наш любимый пример из главы про дженерикиtype Unwrap<T> = T extends Array<infer U> ? U : T;
type A = Unwrap<number[]>; // numbertype B = Unwrap<string>; // stringextends здесь означает «совместимо ли T с U» в структурном смысле. Если да — результат X, иначе Y. С infer в правой части это превращается в сопоставление с образцом.
Дистрибутивность — главная ловушка
Заголовок раздела «Дистрибутивность — главная ловушка»Если T в левой части extends стоит «голым» (просто параметр), а правый аргумент — union, условный тип распределяется по каждому элементу:
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string>; // string[]type B = ToArray<string | number>; // string[] | number[] — распределилось!Без дистрибутивности получилось бы (string | number)[] — один массив смешанного типа. Чтобы отключить распределение, оберни T в кортеж:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type C = ToArrayNonDist<string | number>; // (string | number)[]Дистрибутивность — поведение по умолчанию именно для голого параметра типа. Внутри более сложных выражений (например, Array<T> extends ...) она не работает. Запомни этот факт — половина «магии» в чужих типах объясняется именно распределением по union. Официальный разбор — в TypeScript Handbook: Conditional Types, раздел distributive conditional types.
never: «пустая ветка» условного типа
Заголовок раздела «never: «пустая ветка» условного типа»Когда ни одна ветка не подходит, результат — never. Это позволяет фильтровать union:
type Exclude<T, U> = T extends U ? never : T;type Extract<T, U> = T extends U ? T : never;
type All = 'a' | 'b' | 'c' | 42;type Letters = Exclude<All, number>; // 'a' | 'b' | 'c'type OnlyC = Extract<All, 'c'>; // 'c'never в union «растворяется»: string | never = string. Поэтому отброшенные ветки просто исчезают из результата.
keyof и индексные типы
Заголовок раздела «keyof и индексные типы»keyof T — union всех ключей объекта (строки, числа, символы). В комбинации с дженериком — основа всех типобезопасных утилит:
interface Server { host: string; port: number; tls: boolean;}
type Keys = keyof Server; // 'host' | 'port' | 'tls'
// Индексный доступ: тип значения по ключуtype HostType = Server['host']; // stringtype AllTypes = Server[keyof Server]; // string | number | boolean — union всех значенийServer[keyof Server] — мощный приём: union всех типов значений объекта. Им пользуются валидаторы и мапперы, чтобы сказать «любое значение из этого объекта».
Для массивов keyof даёт числовые ключи и методы, поэтому работай с keyof осторожно на коллекциях. Для фильтрации «только данных» существуют приёмы с mapped-типами, см. ниже.
Mapped types: цикл по ключам
Заголовок раздела «Mapped types: цикл по ключам»Mapped-тип перебирает ключи и строит новый объект. Синтаксис — in по union ключей. Вся механика модификаторов и key remapping описана в TypeScript Handbook: Mapped Types:
type Nullable<T> = { [K in keyof T]: T[K] | null };
interface Config { host: string; port: number;}
type NullableConfig = Nullable<Config>;// { host: string | null; port: number | null }keyof T разворачивается в union ключей, in идёт по каждому, T[K] — индексный доступ к типу значения. Это цикл, результат — объект.
Модификаторы: ?, readonly, + и -
Заголовок раздела «Модификаторы: ?, readonly, + и -»Mapped-типы могут добавлять или убирать модификаторы у каждого свойства:
type Partial2<T> = { [K in keyof T]?: T[K] }; // добавить ?type Required2<T> = { [K in keyof T]-?: T[K] }; // убрать ? (-?)
type Readonly2<T> = { readonly [K in keyof T]: T[K] }; // добавить readonlytype Mutable2<T> = { -readonly [K in keyof T]: T[K] }; // убрать readonly (-)-? — «минус опциональность», применяется и к явным undefined в значении. -readonly снимает защиту от записи. Плюс можно явно писать (+?, +readonly), но он подразумевается по умолчанию.
Переписывание ключей через as
Заголовок раздела «Переписывание ключей через as»Самая мощная возможность mapped-типов — переписать ключи через as:
// Ключи-геттеры: getHost, getPort, ...type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];};
type ServerGetters = Getters<Server>;// { getHost: () => string; getPort: () => number; getTls: () => boolean }
// Фильтрация ключей: убрать всё, начинающееся с underscoretype Public<T> = { [K in keyof T as K extends `_${string}` ? never : K]: T[K];};
interface Internal { id: number; _secret: string; name: string;}
type PublicOnly = Public<Internal>; // { id: number; name: string }В выражении as можно: применять template literal types (см. ниже), использовать условные типы для отсева (? never : K — ключ исчезает), преобразовывать регистр. Это закрывает 90% задач «мне нужен тип, как X, но с другими ключами/фильтром».
Template literal types
Заголовок раздела «Template literal types»Типы-строки, построенные интерполяцией — на уровне типов. Строковые утилиты (Capitalize, Uppercase и друзья) и ограничения интерполяции рассмотрены в TypeScript Handbook: Template Literal Types:
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';type Endpoint = `/${string}`;type Route = `${Method} ${Endpoint}`; // 'GET /users' | 'POST /users' | ...
// Утилиты для строк в типах: Capitalize, Uppercase, Lowercase, Uncapitalizetype EventName = 'click' | 'focus';type HandlerName = `on${Capitalize<EventName>}`; // 'onClick' | 'onFocus'Их сила — в комбинации с mapped-типами и as. Паттерн «роуты API как union литералов»:
const routes = ['/users', '/users/:id', '/health'] as const;type Route = (typeof routes)[number]; // union литералов
// И типобезопасный объект обработчиков ровно под эти путиtype Handlers = { [K in Route]: (req: Request) => Response };Плюс as const и mapped-тип — и у тебя объект, в который нельзя записать обработчик для несуществующего пути. Об этом подробнее в следующей главе.
Утилиты: внутреннее устройство
Заголовок раздела «Утилиты: внутреннее устройство»Все встроенные утилиты — обычные типы в lib.d.ts, написанные на том же синтаксисе. Открой их определение в IDE (Ctrl+клик) — и они перестанут быть магией.
Partial и Required
Заголовок раздела «Partial и Required»// Реальное определение из lib.d.ts:type Partial<T> = { [P in keyof T]?: T[P] };type Required<T> = { [P in keyof T]-?: T[P] };Просто mapped-тип с модификатором опциональности. Partial — для черновиков, форм, патчей; Required — когда из API пришло «всё может отсутствовать», а бизнес-логике нужны гарантии.
Pick и Omit
Заголовок раздела «Pick и Omit»// Реальное определение:type Pick<T, K extends keyof T> = { [P in K]: T[P] };type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;Pick — mapped-тип по подмножеству ключей. Ограничение K extends keyof T делает второй параметр типобезопасным: передать несуществующий ключ — ошибка. Omit — через комбинацию Pick и Exclude: исключаем ключи из union и забираем остаток.
interface User { id: number; name: string; email: string; passwordHash: string;}
type PublicUser = Omit<User, 'passwordHash'>; // для ответа APItype UserPreview = Pick<User, 'id' | 'name'>; // для списка
// Попытка Omit<User, 'passwrd'> — ошибка: опечатка поймана сразу// Реальное определение:type Record<K extends keyof any, T> = { [P in K]: T };Словарь: ключ из K, значение T. Ограничение keyof any = string | number | symbol. Классика — мапа сущностей по ID и словари переводов:
type UsersById = Record<number, User>;type LocaleMessages = Record<'en' | 'ru', { welcome: string }>;ReturnType и Parameters
Заголовок раздела «ReturnType и Parameters»// Реальное определение:type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;Условный тип с infer на типе функции. Ограничение отсекает всё, что не функция. Практика — синхронизация типов без дублирования:
async function fetchUser(id: number): Promise<User> { /* ... */}
type FetchUserReturn = ReturnType<typeof fetchUser>; // Promise<User>type FetchUserArgs = Parameters<typeof fetchUser>; // [id: number]
// Тип handler под функцию — аргументы совпадут автоматическиtype Handler<F extends (...args: never[]) => unknown> = (result: Awaited<ReturnType<F>>) => void;Awaited
Заголовок раздела «Awaited»// Упрощённое определение:type Awaited<T> = T extends null | undefined ? T : T extends object & { then(onfulfilled: infer F): unknown } ? F extends (value: infer V) => unknown ? Awaited<V> : never : T;Разбор на части: если T — thenable (объект с методом then), вытаскиваем тип значения колбэка onfulfilled и рекурсивно ждём его — поэтому Awaited<Promise<Promise<number>>> даёт number, а не Promise<number>. Если не thenable — возвращаем как есть. Это тот самый тип, который использует async/await под капотом при выводе возвращаемого значения.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»1. Ожидание глубокости от Partial/Readonly.
interface State { user: { name: string };}
const draft: Partial<State> = {};// draft.user?.name = 'x'; — нельзя: Partial только верхний уровень,// user остался { name: string }, просто опциональнымРешение — DeepPartial<T> через рекурсию (практика).
2. Путаница с дистрибутивностью.
type Wrap<T> = T extends any ? [T] : never;type A = Wrap<string | number>; // [string] | [number]Если нужен один кортеж от всего union — оберни обе стороны: [T] extends [any] ? [T] : never даст [string | number].
3. keyof по union или any.
keyof any — string | number | symbol, не конкретные ключи. keyof (A | B) — пересечение ключей. Если keyof «не работает» — проверь, не union ли у тебя вместо объекта.
4. Omit с опечаткой в ключе.
type X = Omit<User, 'pasword'>; // ошибка? Нет — Omit принимает любой keyof T...Ой. В точном определении Omit<T, K extends keyof T> — опечатка 'pasword' действительно ошибка. Но если обернуть в свой алиас без ограничения — нет. Проверяй, что в проекте используется настоящий Omit.
5. Template literal types на non-literal union.
type K = string;type T = `get${K}`; // просто string — растворился литералЕсли K — string, интерполяция не создаёт бесконечный union, а коллапсирует в string. Работает только с литеральными union.
6. Переусердствование с типами.
Не пиши тип, который проще заменить явным интерфейсом. Сложные conditional/mapped — для библиотек и обобщённых обвязок. В прикладном коде простой interface читается лучше десяти вложенных infer.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»1. Чем отличается Partial<T> от { [K in keyof T]?: T[K] }?
Ничем — это его точное определение из lib.d.ts. Вопрос проверяет, заглядывал ли ты в исходники встроенных типов. То же с Required (-?) и Pick (mapped по подмножеству ключей).
2. Что такое дистрибутивность условных типов и как её отключить?
Для «голого» параметра типа в левой части extends условный тип применяется к каждому элементу union отдельно, а результат объединяется. Отключить — обернуть обе стороны в кортежи: [T] extends [U]. Это даёт поведение «всего union как единого значения».
3. Как устроен Record и чем он отличается от объекта с индексной сигнатурой?
Record<K, T> = mapped-тип { [P in K]: T } с ограничением K extends keyof any. Практически эквивалентен { [key in K]: T }. Отличие от { [key: string]: T }: Record требует конкретный union ключей, индексная сигнатура допускает любую строку.
4. Как работает Omit внутри?
Pick<T, Exclude<keyof T, K>>: из union всех ключей keyof T исключаем K через дистрибутивный Exclude (условный тип, возвращающий never для совпадений), остаток передаём в Pick. Отсюда и ограничение K extends keyof T — иначе Exclude просто вернёт keyof T без изменений.
5. Что делает Awaited и почему он рекурсивен?
Извлекает тип значения из thenable/Promise, рекурсивно применяя себя к результату — чтобы схлопнуть Promise<Promise<T>> в T. Рекурсия нужна, потому что await во вложенных промисах разворачивает все уровни, и тип должен повторять это поведение.
6. Что такое as в mapped-типах и для чего фильтровать ключи через never?
as переписывает ключ: [K in keyof T as NewKey]: .... Если NewKey вычисляется в never, свойство исчезает из результата — это способ фильтрации (убрать приватные ключи, переименовать по шаблону).
7. Когда условный тип не дистрибутивен?
Когда параметр не «голый»: стоит внутри другой конструкции — Array<T> extends ..., { x: T } extends ..., или обёрнут в кортеж [T] extends [U]. Тогда T рассматривается как единое значение.
Практика
Заголовок раздела «Практика»- Реализуй
DeepReadonly<T>: рекурсивно делает все вложенные объекты и массивы readonly. Проверь на{ a: { b: number[] } }— вложенный массив тоже должен статьreadonly number[]. - Напиши
FlattenKeys<T>: из{ a: { b: { c: number } } }делает{ 'a.b.c': number }. Подсказка: mapped-тип сasдля конкатенации ключей и рекурсия на вложенных объектах. - Собери
RequireKeys<T, K>—Pickпо обязательным ключам +Partialостального, но так, чтобы ключиKбыли-?, а остальные остались опциональными. - Возьми тип
typeof fetchUserиз примера и постройHandlerчерезReturnType/Parameters, затем типизируй обёрткуwithLogging(fn)без единой явной аннотации аргументов. - Напиши
UnionToIntersection<U>через условный тип сinferв позиции функции (подсказка:(U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never). Разбери, почему это работает.
Критерий результата: каждый тип компилируется, проверяется на примерах с // ^? или явными алиасами, вложенные структуры обрабатываются рекурсивно, нет any в реализации.
Что почитать
Заголовок раздела «Что почитать»- TypeScript Handbook: Mapped Types — модификаторы и key remapping.
- TypeScript Handbook: Conditional Types — infer, дистрибутивность.
- TypeScript Handbook: Template Literal Types — интерполяция и утилиты для строк.
- Type Challenges — сотни задач на уровне типов, от easy до hell.
- TypeScript lib.d.ts на GitHub — первоисточник всех встроенных утилит.