open atlas
↑ К треку
Система типов TypeScript вглубь TS · 05 · 04

Арифметика на уровне типов

В TypeScript нет чисел на уровне типов, поэтому N кодируют как кортеж длины N — конкатенация это сложение, infer с начала это вычитание, а сравнение длин это порядок; всё ограничено и медленно по построению.

TS Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Вы типизируете библиотеку матриц фиксированного размера: Vec<3> плюс Vec<3> — нормально, но Vec<3> плюс Vec<4> должно быть ошибкой компиляции, а dot(Vec<3>, Vec<3>) должен возвращать number только когда длины совпадают. Чтобы выразить «3 + 4» или «3 меньше 4?», компилятору нужна арифметика — но в TypeScript нет + на уровне типов. Приём, на котором построен весь арифметический канон type-challenges: число N представляется кортежем длины N, а математика делается ростом, усечением и измерением кортежей. Работает примерно до тысячи — и роскошно медленно.

Представление: число — это длина кортежа

Спросите себя: если 3 + 4 нельзя написать как выражение типа, как вообще проверить на этапе компиляции, что два вектора одной размерности? Весь приём умещается в одну идею. На уровне типов нет оператора +. Стандартная кодировка: число N — это любой кортеж с length, равным N. Постройте его, хвостово-рекурсивно добавляя unknown, пока длина не совпадёт:

type BuildTuple<N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N ? Acc : BuildTuple<N, [unknown, ...Acc]>;

type Three = BuildTuple<3>;
//   ^? type Three = [unknown, unknown, unknown]
type Len = Three["length"];
//   ^? type Len = 3

Это в точности хвостово-рекурсивный Build из урока 1. Вся арифметическая библиотека сводится к: преобразовать числа в кортежи, манипулировать кортежами, считать ["length"] обратно.

Add: конкатенация двух кортежей

Сложение A + B — это построить оба кортежа, спредить их в один и считать совместную длину:

type Add<A extends number, B extends number> =
  [...BuildTuple<A>, ...BuildTuple<B>]["length"] & number;

type Seven = Add<3, 4>;
//   ^? type Seven = 7

& number сужает ["length"] (в общем случае типизированную как number) обратно к литералу. Конкатенация — это + на уровне типов.

Subtract: вывести разницу с начала

Вычитание A - B строит кортеж для A, затем срезает B элементов с начала, матча префикс BuildTuple<B> и выводя остаток:

type Subtract<A extends number, B extends number> =
  BuildTuple<A> extends [...BuildTuple<B>, ...infer Rest]
    ? Rest["length"]
    : never;   // A < B: такого префикса нет, переполнение вниз -> never

type Two = Subtract<5, 3>;
//   ^? type Two = 2
type Under = Subtract<3, 5>;
//   ^? type Under = never   (отрицательные числа непредставимы)

Паттерн [...BuildTuple<B>, ...infer Rest] говорит «кортеж, начинающийся с B элементов, где Rest — то, что после». Rest["length"] — это A - B. Для отрицательных нет представления, поэтому недобор проваливается в never — это намеренный сигнал, а не баг.

Compare: гонка длин

GreaterThan<A, B> идёт по обоим кортежам синхронно; кто опустеет первым — тот меньше:

type GreaterThan<A extends number, B extends number> =
  BuildTuple<B> extends [...BuildTuple<A>, ...infer _Rest]
    ? false        // B не короче A  ->  A <= B
    : true;        // B НЕ является префикс-надмножеством A  ->  A > B

type G1 = GreaterThan<5, 3>;   // ^? type G1 = true
type G2 = GreaterThan<3, 5>;   // ^? type G2 = false
type G3 = GreaterThan<4, 4>;   // ^? type G3 = false

Парсинг числовых литералов из строк

Когда вы пишете тип роутера, вытаскивающий :42 из строки пути, вам нужен обратно числовой тип, а не строка. ${number} в шаблонном литерале матчит числовую строку, но захваченный infer всё ещё строка. Чтобы получить числовой тип, выведите его, а затем сразу скоэрсите через ограниченный infer:

type ParseInt<S extends string> =
  S extends `${infer N extends number}` ? N : never;

type P = ParseInt<"42">;
//   ^? type P = 42   (`infer N extends number` делает приведение string->number)

infer N extends number (TS 4.8+) одновременно захватывает и приводит: он пытается интерпретировать совпавшую подстроку как числовой литеральный тип. Так типы роутера/путей вытаскивают сегменты вроде :42 в числа.

Почему это работает

Почему это ограничено и медленно? Каждое число материализуется как реальный кортеж этой длины, а BuildTuple<N> выполняет N хвостово-рекурсивных шагов. Add<400, 400> строит два 400-элементных кортежа и конкатенирует 800-элементный — и компилятор делает это на каждой ссылке, на каждом нажатии клавиши в редакторе. Потолок длины кортежа сидит около лимита рекурсии (~1000 с хвостовыми вызовами), поэтому арифметика выше нескольких сотен либо падает с «excessively deep», либо заметно тормозит проверку типов. Это инструмент корректности для малых фиксированных размеров, а не общий калькулятор.

Викторина

Почему `Subtract<3, 5>` разрешается в `never`, а не в `-2`?

Расставь шаги по порядку

Расставьте вычисление Add<2, 3> от кодирования чисел до итогового числового типа.

  1. 1 BuildTuple<2> -> [unknown, unknown]
  2. 2 BuildTuple<3> -> [unknown, unknown, unknown]
  3. 3 Спредим оба в один кортеж -> [unknown, unknown, unknown, unknown, unknown]
  4. 4 Читаем ['length'] совместного кортежа -> 5
  5. 5 Сужаем через & number к литеральному типу 5
Вспомните перед уходом
  1. 01
    Как числа представляются на уровне типов и как реализовать Add и Subtract на этом представлении?
  2. 02
    Как сравнить два числа на уровне типов и как распарсить числовой литерал из строки?
  3. 03
    Почему арифметика на длинах кортежей и ограничена, и медленна, и для чего она реально хороша?
Итог

У арифметики на уровне типов одна центральная идея: поскольку в TypeScript нет чисел на уровне типов, представляйте N как кортеж длины N. BuildTuple растит кортеж хвостово-рекурсивно; Add конкатенирует и читает ["length"]; Subtract выводит остаток после префикса длины B (недобор в never); GreaterThan проверяет отношения префикс-надмножества; а `${infer N extends number}` парсит числовые литералы из строк. Вся схема корректна для малых фиксированных размеров и полезна для типизированных API фиксированной длины и валидации арности массива — но ограничена около лимита рекурсии ~1000 и материализует реальные кортежи, которые компилятор постоянно переоценивает. Теперь, когда Vec<3> и Vec<4> не складываются, вы знаете, что компилятор считает элементы кортежей; а когда он тормозит — знаете почему.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.