TypeScript: продвинутые типы и их скрытые возможности

Эта статья посвящена менее известным возможностям TypeScript, делающим его еще более динамичным.

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

Во второй части поговорим о скрытых возможностях TypeScript. Вы узнаете, как с помощью продвинутых типов можно реализовать логику сложения и вычитания числовых литералов. Никакого JavaScript  —  только типы. Если вы уже в курсе общеизвестных фактов об использовании вышеупомянутых продвинутых типов, переходите сразу ко второй части.

Свойства-аксессоры

При работе с типами, описывающими объекты со вложенными структурами, используется синтаксис свойств-аксессоров для создания новых типов на основе свойств и подсвойств родительского типа. Рассмотрим данный тип, представляющий ответ API, содержащий список пользователей в свойстве data:

type ApiResponse = {
data: Array<{
firstName: string
lastName: string
age: number
}>
meta: {
page: number
count: number
}
}

Используем синтаксис свойства-аксессора для создания нового типа, представляющего список пользователей, получив доступ к свойству data:

type UserList = ApiResponse["data"]

// то же самое, что
// => type UserList = Array<{ firstName: string, lastName: string, age: number }>

Пойдем еще дальше: применим синтаксис массива-аксессора и создадим тип для отдельного пользователя.

type User = UserList[number]

// то же самое, что
// type User = { firstName: string, lastName: string, age: number }

Это довольно распространенный способ использования свойств-аксессоров. Гораздо интереснее, когда они комбинируются с дженериками.

При ограничении аргумента generic-типа определенным набором типов, можно использовать синтаксис свойства-аксессора для доступа к свойствам типов, входящим в этот набор. Например, можно ограничить аргумент типа до any[] (который включает все типы массивов) и получить доступ к свойствам, имеющимся у любого массива.

В приведенном ниже примере показано, как получить доступ к свойству length, которое создает числовой литеральный тип, равный количеству элементов в массиве X:

type Length<X extends any[]> = X["length"]

type Result1 = Length<[]>         //  => 0
type Result2 = Length<[1, 2, 3]>  // => 3

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

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

Вторая идея заключается в возможности провести аналогию между синтаксисом типа TS и синтаксисом функции JavaScript, имитирующей функциональность TS:

// тип
type Length<X extends any[]> = X["length"]

// функция
const length = (array: any[]) => array.length

Обратите внимание на то, как различные токены типа соотносятся с токенами функции JS:

  • имя типа → имя функции;
  • аргумент типа → аргумент функции;
  • ограничение аргумента типа → тип аргумента функции;
  • определение типа → тело функции.

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

Условные типы

Условные типы позволяют вводить логику if/else в определения типов и делать их более динамичными. В TypeScript всегда используется тернарный синтаксис для определения условных операторов. Они начинаются с указания самого условия с применением нотации SomeType extends SomeOtherType, за которой следуют ветки этого условия true и false.

Вот очень простой условный тип, который будет создавать литеральные типы true и false в зависимости от получаемого параметра типа:

type IsOne<X> = X extends 1 ? true : false

type Result1 = IsOne<1>      // => true
type Result2 = IsOne<2>      // => false
type Result2 = IsOne<"1">    // => false

Можно также вложить несколько тернарных операторов для описания более сложных условий. В следующем примере определяется тип, который извлекает тип элемента из некоторых типов массивов:

type ItemType<A> = A extends Array<number>
  ? number
  : A extends Array<string>
  ? string
  : A extends Array<boolean>
  ? boolean
  : unknown

type Result1 = ItemType<Array<number>>  // => number
type Result2 = ItemType<Array<string>>  // => string
type Result3 = ItemType<Array<{}>>      // => unknown

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

Вывод типов

Условные типы полезны сами по себе, но они становятся особенно мощными, когда используются вместе с ключевым словом infer. Это ключевое слово позволяет определить переменную в рамках ограничений extends, что впоследствии дает возможность ссылаться на эту переменную или возвращать ее из определения типа.

Рассмотрим вывод типов в действии: переопределим ItemType, чтобы он стал универсальным и поддерживал любой тип массива:

type ItemType<A> = A extends Array<infer I> ? I : unknown

type Result1 = ItemType<number[]>      // => number
type Result2 = ItemType<string[]>      // => string

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

Комбинация условных типов и ключевого слова infer появляется во многих утилитах, поставляемых в комплекте с TS. Одним из таких типов является Awaited. Он “разворачивает” промис и возвращает тип значения, в которое будет резолвиться промис. Точная реализация Awaited, учитывающая различные типы промисов, довольно сложна. Но упрощенная версия будет очень похожа на то, что было сделано с помощью ItemType:

type Unwrap<T> = T extends Promise<infer I> ? I : never

type Result = Unwrap<Promise<{ status: number }>>  // => { status: number }

Еще один интересный тип, поставляемый в комплекте с TS,  —  ReturnType. Он принимает в качестве аргумента тип функции в виде выражения и создает тип из возвращаемого типа этой функции:

type ReturnType<T extends (...args: any) => any> = 
  T extends (...args: any) => infer R 
    ? R 
    : any

type Result = ReturnType<(s: string) => number>  // => number

В этой реализации ключевое слово extends используется дважды. То, что слева, гарантирует возможность передавать только типы функций, а то, что справа, определяет условие. Это условие сопоставляет аргумент T с сигнатурой функции, отбрасывая типы ее аргументов и сохраняя только возвращаемый тип R.

Рекурсивные типы

Рекурсивный тип  —  это тип, который ссылается сам на себя в своем определении. Рекурсивные типы находят множество применений, но одно из самых распространенных  —  это замена циклов. В TypeScript нет специального ключевого слова для создания циклов, но мы можем обойти это ограничение, используя рекурсию для многократного выполнения определенной логики.

Чтобы увидеть рекурсию в действии, снова обратимся к ItemType, только на этот раз он будет работать с N-мерными массивами (массивами массивов):

type ItemType<A> = A extends Array<infer I>
  ? ItemType<I>
  : A

type Result1 = ItemType<number[]>      // => number
type Result2 = ItemType<number[][][]>  // => number

Стоит отметить, что мы ссылаемся на ItemType внутри его определения. Это позволяет проверить, является ли сам тип элемента массива массивом, “отслаивая” слои массивов и передавая результат обратно в ItemType. Только когда мы добираемся до внутреннего типа элемента, условие становится false, и мы выходим из цикла.

Скрытые возможности TS

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

Сейчас мы реализуем типы Add и Sub, которые будут принимать по 2 числовых аргумента и либо складывать, либо вычитать их. Эти типы не будут иметь никакого практического применения, но они предоставят много возможностей для творческого использования полученных знаний.

Сложение

Начнем с создания типа Add:

type Add<A extends number, B extends number> = // для определения

type Result = Add<3, 2>   // => 5

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

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

Чтобы реализовать тип Add, выполним следующие шаги.

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

Вот визуальное представление этого алгоритма:

Сложение с использованием массивов

Начнем с преобразования числовых литералов в кортежи. В JavaScript существует много способов сделать это, но, учитывая ограничения TS и отсутствие подходящих циклов, придется использовать рекурсию для достижения желаемого результата. Немного позже мы перейдем к реализации типа, но сначала посмотрим на код JavaScript, имитирующий логику, которую будем реализовывать:

const toTuple = (n: number, arr: any[] = []) => {
  if (arr.length === n) {
    return arr
  } else {
    const newTuple = [...arr, 0]
    return toTuple(n, newTuple)
  }
}

toTuple(3)    // => [0, 0, 0]

Начинаем с пустого массива и продолжаем добавлять в него новые элементы и рекурсивно вызывать функцию, пока не получим ожидаемое количество элементов в массиве. Значение 0 в данном случае используется произвольно, поскольку нам не важны значения элементов массива, а важно только их общее количество.

Теперь, разобравшись в том, как должна работать логика, мы можем создать тип ToTuple (используя при этом созданный ранее тип Length):

type ToTuple<N extends number, T extends any[] = []> =
Length<T> extends N
? T
: ToTuple<N, [...T, 0]>

Далее нам потребуется способ конкатенации двух кортежей. Этого можно легко достичь, разложив оба кортежа в новый:

type Concat<A extends any[], B extends any[]> = [...A, ...B]

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

type Add<A extends number, B extends number> = Length<
  Concat<ToTuple<A>, ToTuple<B>>
>

type Result1 = Add<1, 1>    // => 2
type Result2 = Add<2, 5>    // => 7

Вычитание

Сигнатура типа Sub будет следующей:

type Sub<A extends number, B extends number> = // для определения

type Result = Sub<6, 2>   // => 4

Для этого типа также будем опираться на массивы, но логика будет другой. Чтобы вычесть массив B из массива A, выполним следующие шаги.

  1. Если это так, выведем оставшуюся часть.
  2. Получим длину остатка.

И вот наглядное представление:

Вычитание с использованием массивов

Снова используем типы ToTuple и Length из предыдущего примера, поэтому результирующий тип будет выглядеть следующим образом:

type Sub<A extends number, B extends number> = 
  ToTuple<A> extends [...ToTuple<B>, ...infer U]
    ? Length<U>
    : unknown

type Result1 = Sub<5, 2>  // => 3
type Result2 = Sub<3, 3>  // => 0
type Result3 = Sub<2, 5>  // => unknown

Как видите, первые два случая работают, как и предполагалось. Однако проблема возникает, если массив B больше массива A. Следуя основному свойству вычитания (2 - 5 = -(5 - 2)), поменяем местами A и B и изменим знак результата на отрицательный:

type Sub<A extends number, B extends number> = 
ToTuple<A> extends [...ToTuple<B>, ...infer U]
? Length<U>
: Negative<Sub<B, A>>

У нас еще нет типа Negative, поэтому определим его. Для начала можно легко добавить знак минус, используя интерполяцию строк:

type Negative<N extends number> = `-${N}`

type Result = Negative<5> // => "-5"

В результате получаем строковый литеральный тип. Чтобы преобразовать его в число, воспользуемся новой возможностью, доступной в TypeScript >= 4.8. Она позволяет применять ограничение extends для переменных infer-типов в условных типах:

type ToNumber<T> = T extends `${infer N extends number}` 
  ? N 
  : never

type Negative<N extends number> = ToNumber<`-${N}`>
type Result = Negative<5>  // => -5

Теперь можно проверить, похож ли тип T на строку, содержимое которой похоже на число, и даже вывести и вернуть это число.

Убеждаемся в том, что все тестовые примеры работают так, как задумано:

type Sub<A extends number, B extends number> = 
  ToTuple<A> extends [...ToTuple<B>, ...infer U]
    ? Length<U>
    : Negative<Sub<B, A>>

type Result1 = Sub<5, 2>  // => 3
type Result2 = Sub<3, 3>  // => 0
type Result3 = Sub<2, 5>  // => -3

Теперь, когда типы Add и Sub реализованы, важно признать, что, помимо отсутствия практического применения, они также имеют некоторые недостатки. Один из них связан с вычислительной сложностью: максимальная глубина стека вызовов у компилятора TypeScript составляет 1000 для рекурсивных типов, поэтому при любом большем числе в работе произойдет сбой. Другой недостаток заключается в том, что ни один из типов не будет взаимодействовать с отрицательными числами, поскольку мы просто не сможем создавать кортежи отрицательной длины.

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

Заключение

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

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Konstantin Lebedev: TypeScript: advanced and esoteric

Предыдущая статья8 полезных команд NPM для фронтенд-инженера
Следующая статьяОбзор JavaScript на основе диалога с ChatGPT