Я постоянно использую TypeScript в своей работе, и мне нравится создавать сложные и элегантные типы, которые точно моделируют ограничения решаемой мной проблемы. Я считаю, что при достаточной изобретательности (и поиске) я могу реализовать именно то, что мне нужно. Тем не менее, иногда я сталкиваюсь с ситуациями, когда тип, который мне нужен, выходит за рамки текущих возможностей TypeScript, и мне приходится писать утверждения типа, которые, как мне кажется, были бы ненужными, если бы только TypeScript был более выразительным.

А иногда у TypeScript есть выпуск, который добавляет лишь дополнительную мощность для преодоления некоторых ограничений, которые мне пришлось явно исправить. Вся серия выпусков 4.x является одним из примеров того, как сделать возможными прежде невозможные способы построения типов. В честь недавнего выпуска версии 4.1 я хочу воспользоваться некоторыми из новейших функций и показать, какие возможности теперь есть в нашем распоряжении, показав, как мы можем использовать их для создания системы арифметики.

Очевидно, что выполнять арифметические действия в TypeScript несложно, если мы дадим себе полный набор его функций, поэтому я ограничу наше решение тем, что мы можем делать исключительно в рамках системы типов. Это означает, что у нас нет возможности использовать простые операторы, такие как + и -, и все, что мы создаем, должно выполняться с использованием методов TypeScript для объединения типов и управления ими. Другими словами, мы ограничимся тем, что мы можем делать с type SomeType = ... объявлением.

Наша цель - иметь такой тип, как Add, который может принимать два числовых литерала и определять их сумму. Итак, мы хотели бы, чтобы Add<3, 5> сгенерировал для нас литерал 8.

Давайте посмотрим, какие важные функции потребуются для построения нашей арифметической системы.

Первоначальная основа

Чтобы построить арифметику на основе типов, мы будем во многом полагаться на некоторые старые знакомые функции TypeScript:

  • условные типы
  • вывод типа
  • общие ограничения

Нам нужно будет добавить в этот список некоторые функции, добавленные в цикле выпуска 4.x:

  • рекурсия в условных типах
  • вариативные кортежи
  • типы литералов шаблона

Хотя подробное обсуждение этих функций выходит за рамки данной статьи, краткое обсуждение необходимо, чтобы обеспечить некоторые точки соприкосновения, поскольку мы проиллюстрируем их использование в нашей системе.

Условный тип - это просто что-то вроде SomeType extends AnotherType ? TrueType : FalseType. Это способ добавить конструкцию if / else к типу, чтобы мы могли переключаться между двумя разными в зависимости от того, как оценивается условие extends.

Вывод типа - мощный инструмент самоанализа, используемый в выражениях условного типа. Его основная цель - выделить из сложного типа какой-либо другой тип, который его составляет. Надуманным примером может быть извлечение типа (ов) из массива: type Flatten<T> = T extends (infer U)[] ? U : T. Затем мы могли бы вызвать Flatten<[number, string, object]>, чтобы получить базовый тип элементов массива (number | string | object).

Общие ограничения дают нам возможность создавать универсальные типы, указывая при этом, какие типы типов предполагается использовать для создания экземпляров универсальных типов. Итак, type Flatten<T> оставляет T широко открытым для любого возможного типа, в то время как type Flatten<T extends any[]> требует, чтобы T было чем-то, что можно назначить массиву.

Рекурсивные условные типы позволяют одной из ветвей условного типа ссылаться на себя и рекурсивно проходить через логику условного типа произвольное количество раз. Однако, как и вся рекурсия, она должна останавливаться, поэтому одна ветвь должна быть проверкой завершения. До версии 4.1 попытка создать рекурсивный тип приводила к ошибке Type alias 'SomeType' circularly references itself.

Вариативный кортеж дает нам возможность свободно использовать операторы распространения внутри другого кортежа, чтобы создать новый тип кортежа, который напрямую соответствует объединенным типам его компонентов. Итак, мы можем взять кортеж type Booleans = [boolean, boolean] и получить новый кортеж type Derived = [...Booleans, string], имеющий тип [boolean, boolean, string].

Наконец, типы литералов шаблона формируют типы строковых литералов из других типов строковых литералов с использованием синтаксиса литералов шаблона. Мы можем взять такой тип, как type ClickEvent = 'click', и получить его тип обработчика событий type ClickEventHandler = `on${ClickEvent}` ('onclick').

Краткий обзор нашего подхода

На высоком уровне наша цель манипулирования числовыми типами литералов (что, как мы сказали ранее, мы не можем сделать с использованием типичных арифметических операторов) напрямую зависит от нашей способности соотносить их с более легко управляемым типом. Итак, наш фундаментальный подход будет заключаться в следующем:

  1. сопоставить каждый числовой литерал с кортежем этой длины
  2. управлять кортежем, используя его недавно обнаруженные вариативные возможности
  3. отобразить результирующий кортеж обратно в его числовой буквальный аналог

Учитывая, что в основе нашей арифметики лежат кортежи, мы, естественно, вынуждены реализовывать арифметику с натуральными числами. Кортежи не имеют понятия дробной или отрицательной длины, поэтому у нас точно так же нет возможности манипулировать такими числами. Однако такой бессмысленный ввод не должен нас беспокоить, потому что мы увидим, как мы можем гарантировать, что наша система работает только с неотрицательными целыми числами.

Мы будем использовать механизм пунктов 1–3 для реализации только самого основного набора арифметических операций (несмотря на то, что мы могли реализовать еще немало других):

  • добавление
  • вычитание
  • умножение
  • разделение
  • по модулю

Построение системы

Основные утилиты

Фундаментальные типы, которые нам нужны для достижения нашей цели, - это те, которые удовлетворяют пунктам 1 и 3, перечисленным выше в нашем обзоре. Несмотря на их важность для нашего проекта, они достаточно простые. Начнем с создания типа для пункта 3.

type Length<T extends any[]> = 
    T extends { length: infer L } ? L : never;

Чтобы отобразить кортеж обратно в число, мы извлекаем выгоду из того факта, что они, будучи назначаемыми массивам, имеют свойство length, и поэтому мы infer его используем.

Тип для точки 1 (отображение числа в кортеж) немного сложнее.

type BuildTuple<L extends number, T extends any[] = []> = 
    T extends { length: L } ? T : BuildTuple<L, [...T, any]>;

Давайте немного распакуем этот тип. BuildTuple принимает числовой буквальный тип L, который должен быть длиной конечного кортежа, а также массив, подобный типу T, который в конечном итоге будет этим кортежем. Сначала мы проверяем, что наш T кортеж имеет желаемую длину L, и возвращаем его, если это так. Если нет, мы добавляем элемент в кортеж и переходим к другой итерации BuildTuple (хотя на этот раз с расширенным кортежем). Таким образом мы продолжаем увеличивать размер кортежа до желаемой длины.

Приведенные в действие наши типы дают нам следующие результаты:

let length: Length<[number, string, string, boolean]>; // `4`
let tuple: BuildTuple<5>; // `[any, any, any, any, any]`

Основы арифметики

С помощью этих базовых типов мы теперь можем построить наши первые арифметические типы: Add и Subtract.

type Add<A extends number, B extends number> = 
    Length<[...BuildTuple<A>, ...BuildTuple<B>]>;
type Subtract<A extends number, B extends number> = 
    BuildTuple<A> extends [...(infer U), ...BuildTuple<B>]
        ? Length<U>
        : never;

Add - это простой тип, который строит два кортежа, каждый из которых является длиной одного из входных данных, объединяет их и определяет длину результирующего кортежа.

Subtract моделирует выражение A — B (так что A должно быть больше B, чтобы результат был натуральным числом) и выполняет свою работу, создавая большой кортеж длиной A и присваивая его кортежу с переменными параметрами, построенному из кортежа длиной B и некоторый остаток кортежа (наш infer U). Этот U кортеж составляет разницу между значением A и B, поэтому мы возвращаем его длину.

Используя их в коде, мы получаем следующие результаты:

let five: Add<3, 2>; // `5`
let one: Subtract<3, 2>; // `1`

Более сложные утилиты

Для создания типов для Multiply и Divide нам нужны некоторые типы утилит, которые будут более мощными, чем то, что у нас есть в настоящее время. Поскольку умножение - это просто повторное сложение, нам понадобится тип, который может накапливать результаты нескольких итераций нашего типа Add.

type MultiAdd<
    N extends number, A extends number, I extends number
> = I extends 0 ? A : MultiAdd<N, Add<N, A>, Subtract<I, 1>>;

Входные данные для этого типа представляют собой число N, которое мы хотим многократно добавлять, число A, в котором хранятся результаты каждой Add операции, и число I для отслеживания количества оставшихся итераций.

Например, выполнение 8 * 3 будет означать, что 8 добавляется к самому себе 3 раз, и мы должны присвоить эти два числа N и I соответственно. A, будучи аккумулятором, должен начинаться с 0. На каждой итерации мы вычитаем 1 из текущего значения I и добавляем N к текущему значению A. В конце концов, I снизится до 0, а значение, хранящееся в A в этот момент, является произведением двух наших чисел.

(Евклидово) деление аналогично многократному использованию вычитания, поэтому мы можем создать аналогичную утилиту, чтобы справиться с этим. Однако, поскольку нам нужно завершить нашу повторную процедуру вычитания каждый раз, когда A меньше B, ее реализация требует дополнительных утилит.

type EQ<A, B> =
    A extends B
        ? (B extends A ? true : false)
        : false;
type AtTerminus<A extends number, B extends number> = 
    A extends 0
        ? true
        : (B extends 0 ? true : false);
type LT<A extends number, B extends number> = 
    AtTerminus<A, B> extends true
        ? EQ<A, B> extends true
            ? false
            : (A extends 0 ? true : false)
        : LT<Subtract<A, 1>, Subtract<B, 1>>;
type MultiSub<
    N extends number, D extends number, Q extends number
> = LT<N, D> extends true
    ? Q
    : MultiSub<Subtract<N, D>, D, Add<Q, 1>>;

Давайте разберемся, что мы здесь делаем.

EQ просто проверяет, могут ли два типа быть назначены друг другу. Если это так, они должны быть одного типа, поэтому мы заключаем, что они равны. Это означает, что EQ<1, 3> будет false, а EQ<3, 3> - true.

AtTerminus проверяет, являются ли A или B 0, и указывает true, если да. Это сделано для того, чтобы позже, когда мы вычитаем из A и B, мы гарантируем, что не будем повторять предыдущий 0, тем самым сделав одно из двух отрицательным (поскольку они, опять же, не определены для наших целей).

LT - это меньше операции. Он проверяет, достигли ли мы конечной точки (то есть A или B или оба 0), а если нет, многократно вычитает 1 из каждого ввода. Если мы достигли точки завершения, он проверяет, равны ли A и B. Их равенство означает, что A не меньше B (поскольку 4 не меньше 4), поэтому мы возвращаем false. Но если они не равны, то только один из них попадает в 0, и поэтому мы возвращаем true, если A попал.

И, наконец, MultiSub принимает число N, делитель D и частное Q, которое представляет, сколько раз D было вычтено из N на каждой итерации. Как только N становится меньше D, наше вычитание останавливается, и мы возвращаем Q количество повторений.

Например, выполнение 8 / 3 будет означать, что N и D должны быть соответственно назначены 8 и 3. Q будет начинаться с 0 (поскольку ничего не вычиталось, когда мы находимся на первой итерации MultiSub), и каждый раз, когда мы вычитаем 3 из 8, мы увеличиваем Q, пока LT проверка не станет true. На данный момент Q представляет собой частное нашего деления.

Более сложные арифметические типы

Теперь мы находимся в том месте, где можем реализовать наши типы умножения и деления. Поскольку основную часть работы выполняют утилиты, это довольно просто.

type Multiply<A extends number, B extends number> = 
    MultiAdd<A, 0, B>;
type Divide<A extends number, B extends number> = 
    MultiSub<A, B, 0>;
type Modulo<A extends number, B extends number> = 
    LT<A, B> extends true ? A : Modulo<Subtract<A, B>, B>;

Обратите внимание, что Modulo не требует никаких утилит, потому что нам нужно только знать, что осталось после наших повторных упражнений на вычитание, а не сколько раз мы повторяли наш рекурсивный вызов.

Использование этих типов дает следующие примеры:

let twentyEight: Multiply<7, 4>; // `28`
let one: Divide<7, 4>; // `1`
let three: Modulo<7, 4>; // `3`

Обеспечение ввода натуральных чисел

Пока для наших ограничений универсального типа требуется, чтобы входные данные были только числом, а не натуральным числом. Это означает, что если мы попытаемся выполнить какую-либо арифметику, используя что-либо, кроме натурального числа, мы получим ошибку компиляции.

let one: Add<3, -2>;
let onePointSeven: Subtract<3, 1.3>;

Любое из этих объявлений приведет к ошибке и сообщит нам Type instantiation is excessively deep and possibly infinite. Это имеет смысл, учитывая то, что мы сказали выше о том, что длины кортежей являются как дискретными, так и положительными, и поэтому эти классы недопустимых чисел существуют в «пробелах», созданных этими двумя свойствами. Используя указанные выше недопустимые числа, -2 всегда будет позади L начального значения, а каждая итерация BuildTuple только увеличивает разрыв между -2 и L. 1.3 всегда будет передаваться при построении кортежа длины 1 и перехода к длине 2. В любом случае BuildTuple никогда не приказывают прекратить работу и никогда не прекращают.

Теперь, хотя мы могли бы просто позволить компилятору TypeScript выдавать ошибку, если мы предоставляем недопустимые числа, это не идеально; мы должны явно рассматривать эти ситуации и даже не пытаться выполнять арифметические операции, когда мы с ними сталкиваемся.

Мы справимся с этим, используя новый тип литерала шаблона TypeScript. Наша цель - увидеть, совпадают ли наши входные данные с шаблоном с отрицательными или десятичными числами. Итак, давайте создадим наши типы для проверки каждой из этих ситуаций.

type IsPositive<N extends number> = 
    `${N}` extends `-${number}` ? false : true;
type IsWhole<N extends number> = 
    `${N}` extends `${number}.${number}` ? false : true;

Оба этих универсальных типа создаются с помощью некоторого типа N (который из-за общего ограничения гарантирует, что он должен быть числом и, следовательно, может быть интерполирован в тип литерала шаблона), а затем сравнивает тип литерала шаблона, производный от N, с конкретным узоры.

В первом случае шаблон - это все, что начинается со знака минус и за которым следует number. В отличие от вышеупомянутого в разделе Начальная основа, где мы извлекли обработчик событий из явного строкового литерала 'click', это гораздо более общий характер и будет соответствовать любому отрицательному числу. Второй шаблон определяет, совпадает ли N с любыми двумя допустимыми numbers с десятичным разрядом между ними (1.3, поскольку 1 и 3 - допустимые числа).

Кроме того, тип литерала нашего шаблона -${number} будет соответствовать -1 по желанию, но он также будет соответствовать --1. Причина этого в том, что и 1, и -1 могут быть присвоены number, поэтому, когда мы используем number в нашем литерале шаблона, мы, по сути, добавляем знак минус к каждому возможному числу , независимо от того, является ли полученный в результате тип литерала шаблона имеет смысл в нашем контексте. Точно так же наш десятичный шаблон будет соответствовать чему-то вроде 1.3.1, потому что и 1.3, и 1 могут быть присвоены number, и наш шаблон наивно добавляет между ними десятичную точку.

Поскольку наш N тип, ограниченный значением number, не может корректно создавать двойное отрицание или несколько десятичных знаков, эти крайние случаи не будут вызывать проблем в нашей системе, и мы можем спокойно забыть об их существовании. Это было сделано только для полноты картины.

Простые примеры этих типов дают нам

let negativeNumber: IsPositive<-13>; // `false`
let positiveNumber: IsPositive<5>; // `true`
let float: IsWhole<1.3>; // `false`
let round: IsWhole<13>; // `true`

Теперь мы можем опираться на них, чтобы проверить действительность числа.

type IsValid<N extends number> = 
    IsPositive<N> extends true
        ? (IsWhole<N> extends true ? true : false)
        : false;
type AreValid<A extends number, B extends number> = 
    IsValid<A> extends true
        ? (IsValid<B> extends true ? true : false)
        : false;

Используя наш AreValid тип, мы можем создавать безопасные версии всех наших арифметических операций, заключая их в условный тип, который never выключается, если любой из наших входных данных недействителен.

type SafeAdd<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Add<A, B> : never;
type SafeSubtract<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Subtract<A, B> : never;
type SafeMultiply<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Multiply<A, B> : never;
type SafeDivide<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Divide<A, B> : never;
type SafeModulo<A extends number, B extends number> = 
    AreValid<A, B> extends true ? Modulo<A, B> : never;

Это дает нам результаты, которые мы хотели бы видеть:

let sum: Add<3, 4>; // `7`
let badSum: Add<3, 4.5>; // `never`
let anotherBadSum: Add<3, -4>; // `never`

Ограничения нашей системы

Хотя возможность реализовать натуральные числа и арифметику в TypeScript теперь возможна, есть несколько предостережений.

Во-первых, у рекурсивных условных типов есть предел глубины рекурсии. Это очевидное ограничение, потому что без создания экземпляра типа, такого как BuildTuple<-1>, компилятор зависнет, поскольку мы никогда не найдем кортеж, расширяющий { length: -1 }, и наша рекурсия никогда не закончится. Этот предел имеет реальное значение для нашей собственной арифметической системы: мы не можем напрямую создать кортеж, длина которого больше 46 (попытка выполнить BuildTuple<47> приводит к «чрезмерно глубокой» ошибке, которую мы видели ранее). Из-за этого ограничения большинство наших типов не могут генерировать числа сколько-нибудь значительного размера.

Во-вторых, поскольку эти рекурсивные условные выражения становятся более сложными, их вычисление также может быть дорогостоящим. Слишком большой и сложный рекурсивный тип может замедлить работу компилятора. Даже в примечаниях к выпуску для версии 4.1 говорится, что их следует использовать с осторожностью, чтобы избежать снижения производительности.

Наконец, один небольшой недостаток во всей нашей системе - это Subtract. Хотя он не должен принимать пару чисел с отрицательной разницей, на самом деле он принимает, а не с ожидаемым результатом. Создание экземпляра Subtract как Subtract<1, 2> дает результирующий тип не -1 (который, как мы знали, мы все равно не можем сгенерировать), а number. И, к сожалению, единственный способ защититься от этого, как мы сделали для наших Safe* типов, - это гарантировать, что A всегда меньше B. Но с нашим LT типом, зависящим от Subtract для определения такого порядка, мы в конечном итоге застряли, поскольку реализация LT внутри Subtract дала бы нам бесконечно циклическую ссылку. Поэтому нам просто нужно смириться с тем фактом, что мы не можем полностью предотвратить недопустимые входные данные для Subtract.

Заключение

Мы показали, как мы можем использовать новейшие функции TypeScript для построения натуральных чисел и арифметической системы. Наша реализация лишь поверхностно коснулась того, что возможно, и мы упустили много интересных вещей (проверка на простоту, относительная простота, генерация делителей, и т. Д.), которые читатель мог бы понять. вверх и поэкспериментируйте с.

Я оставляю ссылку на площадку TypeScript, где вся система выложена и готова к работе на случай, если кто-то захочет расширить функциональность, которую мы уже создали, а также Gist с тем же содержанием.