Kodokon kodokon.com

Условные типы и infer: логика компилятора

Освой дистрибутивность условных типов, сопоставление с образцом через infer и крайние случаи вроде never, any и boolean.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

Условные типы - это механизм ветвления в системе типов: T extends U ? X : Y спрашивает у компилятора, присваиваем ли T к U, а затем выбирает ветку. Все стандартные утилиты вроде ReturnType, Exclude или Awaited построены на них. На этом уровне вопрос уже не в том, как их писать, а в том, как точно предсказать, как компилятор их вычисляет - особенно когда в игре объединения и never.

TYPESCRIPT
type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">;
// true

type B = IsString<string | number>;
// boolean, not false!
Неожиданный результат на объединении: boolean.

Ключевое поведение - это дистрибутивность: когда проверяемый тип является голым параметром типа (не обёрнут в кортеж, массив или пересечение), условие применяется отдельно к каждому члену объединения, а результаты затем соединяются. Поэтому IsString<string | number> вычисляет true | false, то есть boolean. Чтобы отключить это распределение, оберни обе стороны в кортеж: [T] extends [U]. Один грозный крайний случай: never - это пустое объединение; распределение по нулю членов не вычисляет ни одной ветки, и результат равен never, какую бы ветку ты ни написал.

TYPESCRIPT
type ToArray<T> = T extends unknown ? T[] : never;
type NoDist<T> = [T] extends [unknown] ? T[] : never;

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

type B = NoDist<string | number>;
// (string | number)[]

type IsNever<T> = [T] extends [never] ? true : false;
type C = IsNever<never>; // true thanks to the tuple
Кортеж нейтрализует распределение и делает never предсказуемым.

Ключевое слово infer вводит переменную типа, захваченную через сопоставление с образцом: компилятор пытается сопоставить структуру проверяемого типа с образцом и связывает переменную, если совпадение удалось. Можно поставить несколько объявлений infer с одним и тем же именем: в ковариантной позиции (производимые значения, например возвращаемый тип) кандидаты сливаются в объединение; в контравариантной позиции (параметры функции) они соединяются в пересечение. Это правило напрямую следует из вариантности: в каждом направлении это единственный безопасный выбор.

TYPESCRIPT
type ElementOf<T> =
  T extends readonly (infer E)[] ? E : never;

type FirstParam<T> =
  T extends (arg: infer P) => unknown ? P : never;

type Merged<T> =
  T extends { a: (x: infer P) => void;
              b: (x: infer P) => void }
    ? P : never;

type M = Merged<{
  a: (x: { id: string }) => void;
  b: (x: { tag: string }) => void;
}>; // { id: string } & { tag: string }
Два infer в контравариантной позиции: пересечение.

Начиная с TypeScript 4.7 infer принимает встроенное ограничение: infer N extends number фильтрует захват, а с версии 4.8 компилятор даже преобразует числовой строковый литерал в числовой литерал. Ещё одна тонкость спецификации: условный тип, во входных данных которого остаётся неразрешённый обобщённый параметр, остаётся отложенным; он вычисляется только в месте инстанцирования - это объясняет, почему некоторые ошибки всплывают только на стороне вызывающего кода.

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

type Port = ToNumber<"8080">; // the literal 8080
type Bad = ToNumber<"abc">;   // never
Ограничение на infer и преобразование литерала (4.8).

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Каков тип Exclude<boolean, true>?
    • boolean
    • false
    • never
    • true
  2. Если type IsNever<T> = T extends never ? true : false;, чему равен IsNever<never>?
    • true
    • false
    • never
    • boolean
  3. Два infer P с одинаковым именем, поставленные в позицию параметра функции, дают:
    • объединение кандидатов
    • пересечение кандидатов
    • первого встреченного кандидата
    • ошибку компиляции