Арифметика на уровне типов
В TypeScript нет чисел на уровне типов, поэтому N кодируют как кортеж длины N — конкатенация это сложение, infer с начала это вычитание, а сравнение длин это порядок; всё ограничено и медленно по построению.
Вы типизируете библиотеку матриц фиксированного размера: 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 BuildTuple<2> -> [unknown, unknown]
- 2 BuildTuple<3> -> [unknown, unknown, unknown]
- 3 Спредим оба в один кортеж -> [unknown, unknown, unknown, unknown, unknown]
- 4 Читаем ['length'] совместного кортежа -> 5
- 5 Сужаем через & number к литеральному типу 5
- 01Как числа представляются на уровне типов и как реализовать Add и Subtract на этом представлении?
- 02Как сравнить два числа на уровне типов и как распарсить числовой литерал из строки?
- 03Почему арифметика на длинах кортежей и ограничена, и медленна, и для чего она реально хороша?
У арифметики на уровне типов одна центральная идея: поскольку в TypeScript нет чисел на уровне типов, представляйте N как кортеж длины N. BuildTuple растит кортеж хвостово-рекурсивно; Add конкатенирует и читает ["length"]; Subtract выводит остаток после префикса длины B (недобор в never); GreaterThan проверяет отношения префикс-надмножества; а `${infer N extends number}` парсит числовые литералы из строк. Вся схема корректна для малых фиксированных размеров и полезна для типизированных API фиксированной длины и валидации арности массива — но ограничена около лимита рекурсии ~1000 и материализует реальные кортежи, которые компилятор постоянно переоценивает. Теперь, когда Vec<3> и Vec<4> не складываются, вы знаете, что компилятор считает элементы кортежей; а когда он тормозит — знаете почему.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.