Раздел 4 · TypeScript

Программирование на типах · глубже

middle-senior~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Программирование на типах · глубже

В уроке 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;

Здесь рекурсивный вызов это последняя операция (а не “результат плюс ещё что-то”), и компилятор оптимизирует это лучше.

Поиграй с виджетом ниже. Видна каждая итерация плюс счётчик глубины.

Conditional unfold: Reverse<[1, 2, 3]>

Расщепляем массив на head + ...rest, рекурсивно реверсим rest, прикладываем head в конец.

    depth0/ 1000 (TS-recursion limit)

    Каждый шаг это либо подстановка через 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.