DevSurge 💦

Typescript Conditional Types: Как и зачем использовать условные типы

Cover Image for Typescript Conditional Types: Как и зачем использовать условные типы
Mark Nelyubin
Mark Nelyubin

Условные типы в TypeScript позволяют выразить логику типов на основе условий. Они действуют аналогично if-else, только для типов. Conditional types помогают определять многократно используемые, динамичные и точные типы.

Синтаксис

Пример базового синтаксиса:

Если значение переменной типа T наследуется из значения переменной типа U, результатом выражения будет значение X, а в противном случае — Y. См. Generic Types.

T extends U ? X : Y

В данном случае extends — ключевое слово, означающее, что тип T является тем же типом или подтипом U.

Примеры

Базовый пример

type IsString<T> = T extends string ? true : false;
type Test1 = IsString<string>; // true
type Test2 = IsString<number>; // false

Мы хотим создавать тип с конкретным значением true или false в зависимости от того, какой тип мы передаём в качестве аргумента — string или number.

Type Utilities

Многие утилитарные типы строятся на базе условных типов — Exclude, Extract, NonNullable.

Рассмотрим пример с Exclude — он убирает тип из объединения union.

type Exclude<T, U> = T extends U ? never : T;

type Result = Exclude<"a" | "b" | "c", "a">; // "b" | "c"

const letter: Result = "a"; // Type '"a"' is not assignable to type 'Result'

Ключевое слово never представляет собой нечто, чего никогда не бывает. Если элемент T является тем же типом или подтипом, что и U — возвращается never. В качестве типа T мы передаём union "a" | "b" | "c". Для каждого элемента мы:

  1. Проверим, является ли он тем же самым типом или подтипом что и U
  2. Если да — вернется never, элемент будет исключен из union.
  3. Если нет — вернется сам элемент, таким образом он останется в union.

Элемент union "a" является тем же типом, что и тот, который мы передаём в качестве типа U — он тоже "a". В этом случае возвращается never, а в других — возвращается переданный тип ("b" и "c").

Вывод подтипов с помощью infer

infer используется в условных операциях над типами для извлечения типа из другого типа. Мы можем захватить тип прямо во время операции и использовать его в результирующем выражении.

type First<T extends any[]> = T extends [infer First, ...any[]] ? First : never;

При создании переменной типа мы указываем, что она расширяет некоторый массив, который может содержать любые типы элементов. Затем мы проверяем условие — если T расширяет массив, в котором есть как минимум один элемент, то мы захватываем тип (infer) первого элемента в качестве значения и возвращаем его. В противном случае, если у нас пустой массив, то мы возвращаем never, то есть сообщаем, что тип элемента не может быть определен.

type Empty = First<[]> // never
type Mixed = First<["hi", 15, true]> // "hi"

Рассмотрим, как написан утилитарный тип ReturnType:

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

type ReturnString = ReturnType<() => string>; // string
type ReturnSpecificString = ReturnType<() => "Z">; // Z
type ReturnBoolean = ReturnType<(x: number) => boolean>; // boolean
  • Используем условный тип, который проверяет, что переменная типа T соответствует функции. Эта функция может принимать любые аргументы любых типов;
  • Заранее мы не знаем, какой тип возвращаемого элемента. Он может быть string, number, функция может ничего не возвращать (тип void), может пробросить ошибку (тип never). Каким бы ни был тип возвращаемого элемента, мы запишем его в переменную типа R с помощью ключевого слова infer.
  • Если T является функцией, то мы вернём тип, который записали в R на предыдущем шаге
  • В противном случае, будет never, так как "не функция" никогда не возвращает значение.

Type Narrowing

С помощью условных типов мы создаём типы, которые сужаются в зависимости от входящих данных.

type Flatten<T> = T extends Array<infer U> ? U : T;

type Nums = Flatten<number[]>; // number
type Strings = Flatten<string>; // string

Если в качестве переменной типа мы передадим во Flatten массив, то получим более узкий тип — тип элемента массива. В противном случае, вёрнется тот же тип, что мы передали в качестве значения переменной типа.

Благодаря conditional types мы можем создавать рекурсивные типы:

type DeepFlatten<T> = T extends Array<infer U> ? DeepFlatten<U> : T;

type NestedNumberElement = DeepFlatten<number[][][]>; // number

В данном примере тип будет вызывать сам себя до тех пор, пока не столкнётся с "не массивом" в переменной типа.

Распределенные условные типы

Условные типы автоматически распределяются по объединению union:

type ToArray<T> = T extends any ? T[] : never;

type StringOrNumberArray = ToArray<number | string>; // number[] | string[]

Нераспределенные условные типы

Порой мы хотим типизировать массив, который содержит разные типы элементов:

type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;

type PrimitiveArray = ToArrayNonDistributive<number | string | boolean>; // (number | string | boolean)[]

В этом примере объединенное number | string | boolean обрабатывается как единое целое, в результате чего получается union массив вместо union, состоящего из массивов, как в прошлом примере.

Чем полезны условные типы

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

  • Динамические преобразования типов: Преобразование типов на основе определенных условий.
  • Точное сужение типов: Создание узких, специфических типов для повышения безопасности типов.
  • Многократно переиспользуемая логика: Инкапсулируйте сложную логику типов в общие утилиты.
  • Основа для типов утилит: Встроенные утилиты TypeScript в значительной степени опираются на условные типы.

Примеры с реальных проектов

Порой мы хотим конвертировать один тип данных в другой, чтобы перевести результат с сервера в формат, который сможет переварить какая-то библиотека. Мы пишем функцию, которая конвертирует массив с элементами вида { key: string; value: string; } в объект вида { key: value }. В этой же функции мы можем устанавливать плейсхолдер — значение по-умолчанию, когда value = undefined.

Чтобы корректно задать тип возвращаемого значения этой функции напишем следующий условный тип:

type MapKeyValueToObjectWithDefaults<
  T extends {
    key: string;
    value: string;
  },
  D = undefined,
> = {
  [K in T as T["key"]]: T["value"] extends infer V
    ? V extends undefined
      ? D
      : V
    : never;
};

Что могут спросить на собеседовании

Напишите утилиту для получения типа элемента массива:

type ElementType<T> = T extends Array<infer U> ? U : never;

Реализуйте утилиту, которая проверяет, является ли тип any:

type IsAny<T> = 0 extends (1 & T) ? true : false;

type Any = IsAny<any>; // true
type NotAny = IsAny<number>; // false
  • Ключом к пониманию задачи служит тот факт, что пересечение типа с any даст в результате any: typeTest=any&number; // any
  • 1 & T в результате будет выдавать any, когда T=any, потому что any абсорбирует любые другие типы при пересечении;
  • 0 extends any даёт в результате true (впрочем, как и любое другое значение или тип);
  • Мы используем простые литеральные типы 0 и 1 для простоты и предсказуемости. Мы можем заменить их на 42 и 500, на "a" и "b" — результат не изменится, но станет сложнее понимать код;

Всем спасибо за внимание.


Другие материалы