Программирование на типах · глубже
В уроке 02 мы научились писать функции на типах, mapped-типы и выводить через infer. Здесь делаем шаг вперёд. Разбираем управляющие конструкции (условные типы), циклы (рекурсия), парсинг строк (шаблонные литералы), пять странностей объединений и дополнительные трюки mapped-типов (переименование ключей, переключение модификаторов).
Финал: полная типобезопасная navigate('/users/:id/posts/:postId', { id, postId }). Та же сквозная задача, что в уроке 02, но полностью автоматическая благодаря шаблонным литералам и рекурсии.
Резюмирую, на что опирается урок:
- type-level-typescript главы 5-9 (платная часть, рекомендую купить именно ради них);
- TS Handbook, разделы Conditional / Mapped / Template Literal как справочник;
- Matt Pocock, Type Transformations workshop, бесплатный репозиторий на гитхабе.
Урок плотный. Читай с открытой вкладкой Playground.
1. Условные типы
Базовая форма: T extends U ? X : Y. Это if на типах.
type IsString<T> = T extends string ? 'yes' : 'no';
type A = IsString<'hi'>; // 'yes'
type B = IsString<42>; // 'no'
type C = IsString<unknown>; // 'no' (unknown не extends string)
extends тут не наследование, а проверка совместимости. Внутри можно использовать infer для извлечения сегмента из образца.
type ReturnTypeOf<F> = F extends (...args: never[]) => infer R ? R : never;
type R1 = ReturnTypeOf<() => string>; // string
type R2 = ReturnTypeOf<(x: number) => boolean>; // boolean
infer создаёт новую переменную в области правой ветки. Если образец совпал, переменная связана; если нет, идёт ветка else.
Распределение. Если в условном типе слева объединение, проверка “распадается” по каждому члену и склеивается обратно в объединение.
type ToArray<T> = T extends unknown ? T[] : never;
type Distributed = ToArray<string | number>;
// сначала: ToArray<string> | ToArray<number>
// потом: string[] | number[]
Это не баг, это самая мощная фича условных типов. Через распределение можно фильтровать объединение, превращать в кортеж и так далее.
type Exclude_<T, U> = T extends U ? never : T;
type Without42 = Exclude_<1 | 2 | 42, 42>; // 1 | 2
Если распределение не нужно, обёртка в кортеж блокирует его.
type ToArrayNoDistr<T> = [T] extends [unknown] ? T[] : never;
type R = ToArrayNoDistr<string | number>; // (string | number)[]
Это идиома “обернуть и проверить”, к которой возвращаются часто.
as const и ограничения параметров. В уроке 01 мы видели, что const x = ['a', 'b'] as const сохраняет литералы. То же самое в ограничении: <const T extends ...> (TS 5.0+) сохраняет литеральность аргументов без as const на каждом вызове.
function tag<const T extends readonly string[]>(items: T): T {
return items;
}
const r = tag(['a', 'b']); // r: readonly ['a', 'b']
Без const T бы расширился до string[], и литералы потерялись бы.
Глава: type-level-typescript.com/conditional-types.
2. Рекурсивные типы и циклы
В типовом коде нет for и while. Нет даже if-else по значению. Любое повторение это рекурсия: тип ссылается на себя, базовый случай останавливает.
Базовая форма (видели в уроке 02):
type Reverse<T extends unknown[]> = T extends [infer H, ...infer R]
? [...Reverse<R>, H]
: [];
Шаги: образец ловит голову и хвост, рекурсивный вызов реверсит хвост, голова приклеивается в конец. Базовый случай это пустой кортеж.
Map / Filter / Reduce на типах строятся одинаково.
type Map<T extends unknown[], F> = T extends [infer H, ...infer R]
? [Apply<F, H>, ...Map<R, F>]
: [];
type Filter<T extends unknown[], P> = T extends [infer H, ...infer R]
? Apply<P, H> extends true
? [H, ...Filter<R, P>]
: Filter<R, P>
: [];
(Apply<F, X> это применение функции высшего порядка через URI-трюк, см. fp-ts. На уровне TypeScript ты пишешь его как трюк через interface плюс Apply['return']. Подробности у Matt Pocock.)
Лимит глубины. TypeScript ограничивает рекурсию примерно 1000 шагами и падает с сообщением “Type instantiation is excessively deep and possibly infinite”. На коротких массивах ты не упрёшься. На длинных строках или тысячеэлементном кортеже упрёшься.
Чтобы выживать на грани, переписывай в хвостовую рекурсию через аккумулятор.
type Reverse<T extends unknown[], Acc extends unknown[] = []> =
T extends [infer H, ...infer R]
? Reverse<R, [H, ...Acc]>
: Acc;
Здесь рекурсивный вызов это последняя операция (а не “результат плюс ещё что-то”), и компилятор оптимизирует это лучше.
Поиграй с виджетом ниже. Видна каждая итерация плюс счётчик глубины.
Расщепляем массив на head + ...rest, рекурсивно реверсим rest, прикладываем head в конец.
Каждый шаг это либо подстановка через infer, либо
переход к рекурсивному вызову, либо базовый случай. Когда
типов слишком много или массив длинный, TS падает с
"Type instantiation is excessively deep" около 1000 шагов.
Если упёрся, ищи tail-recursive переписывание (например, через
accumulator).
Глава: type-level-typescript.com/loops-with-recursive-types.
3. Шаблонные литеральные типы
Строки как тип это интересно. На уровне типа можно конкатенировать, расщеплять, парсить.
type Greeting<Name extends string> = `Hello, ${Name}!`;
type T = Greeting<'world'>; // 'Hello, world!'
Через infer внутри шаблона вытаскиваем сегмент.
type ExtractName<S> = S extends `Hello, ${infer N}!` ? N : never;
type N = ExtractName<'Hello, world!'>; // 'world'
Парсинг роута через рекурсию: разбираем по сегментам, накапливаем параметры.
type ExtractParams<S extends string> =
S extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: S extends `${string}:${infer Param}`
? Param
: never;
type P1 = ExtractParams<'/users/:id'>; // 'id'
type P2 = ExtractParams<'/users/:id/posts/:postId'>; // 'id' | 'postId'
type P3 = ExtractParams<'/home'>; // never
P3 нарочно даёт never. Роут без параметров, и функция на типах честно говорит “ничего не извлекла”.
Полный безопасный navigate:
type Route = '/home' | '/login' | '/users/:id' | '/users/:id/posts/:postId';
type ParamsFor<R extends string> = [ExtractParams<R>] extends [never]
? Record<string, never>
: { [K in ExtractParams<R> & string]: string };
declare function navigate<R extends Route>(
route: R,
params: ParamsFor<R>,
): void;
navigate('/home', {}); // ок
navigate('/users/:id', { id: '42' }); // ок
navigate('/users/:id/posts/:postId', { id: '1', postId: '2' }); // ок
// navigate('/users/:id', {}); // ошибка
// navigate('/users/:id/posts/:postId', { id: '1' }); // ошибка
// navigate('/users/:wrong', { wrong: 'x' }); // ошибка: '/users/:wrong' не Route
Обёртка [ExtractParams<R>] extends [never] нужна, потому что T extends never ? ... : ... без обёртки распадается через распределение и даёт never в ветке then. Помни этот трюк, он встречается часто.
Глава: type-level-typescript.com/template-literal-types.
Кейс · типизированный String.split()
Закрепим всё разом на живом примере. Стандартная сигнатура String.prototype.split возвращает string[]: для '1.2.3'.split('.') компилятор знает только “какой-то массив строк”, хотя длина и содержимое известны уже на этапе компиляции. Напишем тип Split, который вычисляет результат точно. По дороге соберём в одну точку рекурсию с аккумулятором из секции 2, шаблонные литералы и распределение, плюс выучим две новые идиомы.
Наивная версия:
type Split<
T extends string,
Sep extends string,
Buff extends string[] = [],
> = T extends `${infer Start}${Sep}${infer Rest}`
? Split<Rest, Sep, [...Buff, Start]>
: string extends T
? [...Buff, ...string[]]
: [...Buff, T];
Сначала проверка на наличие разделителя. Если он есть, шаблон вытаскивает часть до разделителя и часть после: часть до уходит в буфер-аккумулятор, часть после в рекурсивный вызов. Это та самая хвостовая рекурсия из секции 2, рекурсивный вызов последний. Когда разделитель не найден, остаётся два случая: если T это общий string (проверка string extends T), добиваем буфер бесконечным хвостом ...string[], иначе кладём остаток как есть.
С литералами тип работает отлично, с шаблонными строками хуже:
type T1 = Split<'1.2.3', '.'>; // ['1', '2', '3'] ок
type T2 = Split<'1.2' | '4.5', '.'>; // ['1', '2'] | ['4', '5'] ок
type T3 = Split<string, '.'>; // string[] ок
type T4 = Split<`1.2.${string}`, '.'>; // ['1', '2', ...string[]] ок
type T5 = Split<`1.2.3${string}`, '.'>; // ['1', '2', `3${string}`] неверно
T2 работает благодаря распределению из секции 1: объединение распадается, каждый член обрабатывается отдельно. А вот T5 врёт. Хвост `3${string}` может содержать сколько угодно разделителей, например '1.2.3x.4y' подходит под этот шаблон и даёт на runtime пять элементов, а не три. Наивный тип верит, что хвост это ровно один элемент.
Чтобы починить, нужно отличать конечный литерал от шаблона с интерполяцией. Вот проверка:
type IsLiteralString<T extends string> =
{} extends Record<T, unknown> ? false : true;
type L1 = IsLiteralString<'qwe'>; // true
type L2 = IsLiteralString<string>; // false
type L3 = IsLiteralString<`qwe${string}`>; // false
type L4 = IsLiteralString<`qwe${number}`>; // false
Трюк опирается на поведение Record. Record<'qwe', unknown> это объект с обязательным ключом qwe, пустой {} ему не присваивается, получаем true. А Record<string, unknown> это индексная сигнатура: обязательных ключей нет, {} подходит, получаем false. Шаблон с интерполяцией (`qwe${string}`) компилятор превращает в шаблонную индексную сигнатуру, ключи у неё тоже не обязательные, и {} снова проходит.
Теперь при добавлении очередной части в кортеж проверяем её на литеральность. Если часть “бесконечная”, честно наполняем результат неизвестными строками:
type Split<
T extends string,
Sep extends string,
Buff extends string[] = [],
> = T extends `${infer Start}${Sep}${infer Rest}`
? Split<
Rest,
Sep,
IsLiteralString<Start> extends true
? [...Buff, Start]
: [...Buff, ...string[]]
>
: IsLiteralString<T> extends true
? [...Buff, T]
: [...Buff, ...string[]];
type T5 = Split<`1.2.3${string}`, '.'>; // ['1', '2', ...string[]] теперь честно
Осталась одна шероховатость. В BatSchool (и в любом строгом конфиге, см. урок 01) включён noUncheckedIndexedAccess, и для string[] доступ result[0] даёт string | undefined. Но у split есть гарантия времени исполнения: он всегда возвращает хотя бы один элемент, даже ''.split('.') это ['']. Закодируем гарантию в типе:
type AtLeastOneRequiredElement<A extends unknown[]> = '0' extends keyof A
? A
: [A[number], ...A];
У кортежа ключи это литералы индексов, поэтому '0' extends keyof ['a'] истинно и кортеж возвращается как есть. У string[] ключа-литерала '0' нет (там только number), значит массив пустой или бесконечный, и мы заменяем его на кортеж с одним обязательным элементом впереди: AtLeastOneRequiredElement<string[]> это [string, ...string[]].
Итоговый Split:
type Split<
T extends string,
Sep extends string,
Buff extends string[] = [],
> = T extends `${infer Start}${Sep}${infer Rest}`
? Split<
Rest,
Sep,
IsLiteralString<Start> extends true
? [...Buff, Start]
: [...Buff, ...string[]]
>
: AtLeastOneRequiredElement<
IsLiteralString<T> extends true ? [...Buff, T] : [...Buff, ...string[]]
>;
type S1 = Split<'1 2 3', ' '>; // ['1', '2', '3']
type S2 = Split<`1 2 ${string} 3`, ' '>; // ['1', '2', ...string[], '3']
type S3 = Split<`1 2 ${string} 3`, '===='>; // [string, ...string[]]
type S4 = Split<'1 2 3', '===='>; // ['1 2 3']
Как подключить это к настоящему split? Два пути. Первый: отдельная функция-обёртка, которая приводит результат к нашему типу.
const split = <T extends string, Sep extends string>(
str: T,
sep: Sep,
) => str.split(sep) as Split<T, Sep>;
const parts = split('1.2.3', '.'); // ['1', '2', '3']
Второй: перегрузить метод прямо в интерфейсе String через типизацию this.
declare global {
interface String {
split<This extends string, Sep extends string>(
this: This,
sep: Sep,
): string extends This ? string[] : Split<This, Sep>;
}
}
Параметр this здесь объявляет тип значения, на котором вызван метод: дженерик This связывается с конкретным литералом строки. Если это общий string, оставляем родную сигнатуру, иначе подключаем Split.
Глобальная аугментация удобнее, '1.2.3'.split('.') работает из коробки. Но будь аккуратен, тут легко выстрелить себе в ногу. Перегрузка меняет тип split во всём проекте, включая чужой код, а наш тип не покрывает ни второй аргумент limit, ни разделитель-регэксп. Любая такая недоработка становится проблемой всего кодбейса. Плюс тяжёлый рекурсивный тип на горячем методе может заметно замедлить проверку проекта (секция 7 ровно об этом). Для библиотеки или общего кода выбирай обёртку.
Бонус для любопытных: IsLiteralString проверяет одну строку, но идею можно обобщить до любых примитивов и объединений.
type IsOnlyLiterals<
T extends string | number | bigint | boolean | null | undefined | symbol,
> = (
T extends unknown
? {} extends Record<`${Exclude<T, symbol>}` | Extract<T, symbol>, unknown>
? false
: true
: never
) extends true
? true
: false;
type O1 = IsOnlyLiterals<123 | 'asd'>; // true
type O2 = IsOnlyLiterals<123 | `qwe${string}`>; // false
type O3 = IsOnlyLiterals<number>; // false
Здесь работает распределение: каждый член объединения проверяется отдельно (T extends unknown ? ... : never запускает его). Не-строковые примитивы приводятся к строке интерполяцией `${...}`, а symbol интерполировать нельзя, поэтому он исключается из шаблона и добавляется в ключи напрямую через Extract. Финальная проверка extends true схлопывает результат: если хотя бы один член вернул false, объединение true | false не пройдёт сравнение, и итог будет false.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.