Вывод типов в TypeScript: что такое infer и зачем нужен NoInfer
Это статья про один из самых мутных моментов в TypeScript: откуда компилятор берёт тип T, когда мы его не пишем руками. По дороге разберёмся, почему функция с дженериком иногда молча пропускает неправильный аргумент, что такое ключевое слово infer и зачем в TypeScript 5.4 добавили NoInfer.
Читать статью можно и просто так, но в четырёх местах стоит редактор прямо в тексте: там настоящий TypeScript, ошибки подчёркиваются красным, а наведение на вызов показывает выведенный тип. Это ключевые моменты статьи, их стоит потрогать руками. Все примеры проверены на TypeScript 5.6.
Задача, из-за которой всё затевалось
Вот функция. Она берёт первый элемент массива, а если брать нечего, возвращает запасное значение.
1function pickDefault<T>(items: T[], fallback: T): T {
2 return items[0] ?? fallback;
3}Задумка простая: fallback должен быть того же типа, что элементы массива. Массив чисел, значит и запасное значение число.
1pickDefault([1, 2, 3], 0); // ок
2pickDefault([1, 2, 3], "hello"); // ошибка: string не присваивается numberОшибка есть. Кажется, всё работает.
Теперь ровно тот же приём, но со строками. Функция принимает список допустимых цветов и цвет по умолчанию:
1function createLight<C extends string>(colors: C[], defaultColor: C): C {
2 return defaultColor;
3}
4
5createLight(["red", "yellow", "green"], "blue"); // ошибки нетЦвета "blue" в списке нет. Мы ждём красное подчёркивание, а компилятор молчит.
Запустите редактор и убедитесь сами: в первой функции ошибка есть, во второй её нет, хотя написаны они одинаково.
Разница между двумя примерами не в удаче и не в багах TypeScript. Она в том, как работает вывод типов. Дальше разберём это по шагам.
Что вообще значит «вывод типов»
Дженерик это не тип, а параметр
Когда мы пишем function pickDefault<T>(...), мы не объявляем тип с именем T. Мы объявляем параметр типа: пустое место, которое кто-то заполнит при вызове.
Заполнить его можно руками:
1pickDefault<number>([1, 2, 3], 0);Но так почти никто не пишет. Обычно мы вызываем функцию без угловых скобок, и компилятор сам догадывается, чем заполнить T, глядя на аргументы. Это и есть вывод типов (type inference).
Разница видна на двух соседних строчках:
1function box<T extends string>(value: T, other: T): T[] {
2 return [value, other];
3}
4
5box<"a">("a", "b"); // ошибка: '"b"' не присваивается параметру типа '"a"'
6box("a", "b"); // ошибки нет, T = "a" | "b"Явный аргумент типа делает T эталоном, и второй аргумент проверяется по нему. А при выводе оба аргумента становятся равноправными советчиками, и T спокойно превращается в "a" | "b". Именно из этого растут все дальнейшие странности.
Места вывода
Посмотрите, где в сигнатуре встречается T:
1function pickDefault<T>(items: T[], fallback: T): T
2// ^^^ ^
3// источник 1 источник 2Каждое такое место в списке параметров это источник вывода. При вызове компилятор проходит по всем источникам и собирает кандидатов на роль T.
Для вызова pickDefault([1, 2, 3], "hello") кандидатов двое:
- из
items:number - из
fallback:string
Дальше из кандидатов надо получить один тип.
Три исхода на пути от кандидатов к типу
Исход первый: кандидаты объединяются. Так происходит с литеральными типами, когда у параметра есть ограничение вроде C extends string, и с объектными литералами.
1createLight(["red", "yellow", "green"], "blue");
2// C = "red" | "yellow" | "green" | "blue"Вот она, разгадка светофора. Компилятор не отверг "blue", он дописал его в тип. Список допустимых цветов сам подстроился под аргумент, который мы хотели по этому списку проверить.
То же самое с объектами:
1function pair<T>(a: T, b: T): T {
2 return a;
3}
4
5pair({ x: 1 }, { y: 2 });
6// T = { x: number; y?: undefined } | { y: number; x?: undefined }Исход второй: кандидаты несовместимы и объединить их нельзя. Тогда побеждает кандидат из первого источника, а остальные аргументы проверяются уже по нему.
1pickDefault([1, 2, 3], "hello");
2// T = number, "hello" не проходит проверкуИменно поэтому первый пример статьи ругался. Ошибка появилась не потому, что TypeScript понял нашу задумку, а потому что number и string слишком разные.
Исход третий: кандидаты это литералы, а ограничения у параметра нет. Тогда литералы расширяются до обычного примитива.
1pickDefault(["a", "b"], "c");
2// T = string, ошибки нетОграничения extends string нет, поэтому "a" | "b" превращается в string, и в него влезает что угодно строковое.
Как посмотреть, что вывелось
Самый честный способ: навести курсор на вызов в редакторе.
Второй способ работает даже в чужом коде и в код-ревью: присвоить результат заведомо невозможному типу и прочитать текст ошибки.
1const revealed: "__" = createLight(["red", "yellow"], "blue");
2// Type '"red" | "yellow" | "blue"' is not assignable to type '"__"'
3// ^^^^^^^^^^^^^^^^^^^^^^^^^ вот он, выведенный типВажная тонкость: в качестве «невозможного» типа берите строковый литерал, а не объект. Одно и то же значение показывается по-разному:
1const light = createLight(["red", "yellow"], "blue");
2
3const lie: { __: 1 } = light; // Type 'string' is not assignable ...
4const truth: "__" = light; // Type '"red" | "yellow" | "blue"' is not assignable ...Объединение строковых литералов при сравнении с объектом TypeScript печатает как string, и по такому сообщению легко сделать неверный вывод. Я сам чуть не протащил эту ошибку в статью, пока не сверил результаты двумя способами.
Что произойдёт при таком вызове?
1function pickDefault<T>(items: T[], fallback: T): T {
2 return items[0] ?? fallback;
3}
4
5pickDefault([1, 2, 3], "hello");T два источника вывода: items даёт кандидата number, fallback даёт string. Объединить их компилятор не может, поэтому побеждает кандидат из первого источника, а второй аргумент проверяется уже по нему и не проходит. Объединение появилось бы, будь кандидаты литералами под ограничением или объектными литералами.Почему светофор молчит
Соберём мысль целиком, потому что она главная во всей статье.
Мы хотели, чтобы список цветов был источником истины, а второй аргумент проверялся по нему.
А написали мы другое: «C берётся и из списка, и из значения по умолчанию». Для компилятора оба аргумента равноправны. Он не проверяет второй по первому, он спрашивает у обоих, каким должен быть C.
Поэтому неправильный аргумент не отвергается, а дописывается в тип. Проверка не срабатывает потому, что проверять не по чему: эталон подстраивается под то, что мы проверяем.
1// Что написано: C выводится из обоих аргументов
2function createLight<C extends string>(colors: C[], defaultColor: C): C;
3
4// Что имелось в виду: C выводится из colors, defaultColor обязан ему соответствоватьОсталось научиться говорить вторую строчку на языке TypeScript. Но сначала расчистим путаницу вокруг слова «вывод».
Ключевое слово infer, и почему это не то же самое
Слово «вывод» в TypeScript живёт в двух разных местах, и новичка это регулярно сбивает.
- Вывод аргументов дженерика при вызове функции. Это всё, что было выше.
- Ключевое слово
inferвнутри условных типов. Это про другое.
infer работает только в условном типе, то есть в конструкции A extends B ? X : Y, и означает «достань тип отсюда и дай ему имя».
1type ElementType<T> = T extends (infer E)[] ? E : never;
2
3type A = ElementType<string[]>; // string
4type B = ElementType<number[][]>; // number[]
5type C = ElementType<number>; // never, это не массивЧитается так: «если T это массив чего-то, назови это что-то E и верни E, иначе верни never».
Так устроены многие встроенные типы. Вот упрощённый ReturnType:
1type MyReturn<F> = F extends (...args: any[]) => infer R ? R : never;
2
3type R = MyReturn<() => Date>; // DateИ упрощённый Awaited:
1type MyAwaited<T> = T extends Promise<infer V> ? V : T;
2
3type V = MyAwaited<Promise<number>>; // numberДостать можно почти что угодно, в том числе отдельный параметр функции:
1type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;
2
3type Id = FirstArg<(id: string, flag: boolean) => void>; // stringПопробуйте написать такие типы сами, это лучший способ понять, что infer это просто «дырка», в которую компилятор кладёт найденный кусок типа. Проверить решение можно в песочнице в конце статьи.
1// Замените never на условный тип с infer так, чтобы проверки внизу перестали ругаться.
2
3type SecondArg<F> = never; // тип второго аргумента функции
4type PromiseItem<T> = never; // PromiseItem<Promise<number>[]> должен дать number
5type IdOf<T> = never; // тип свойства id, если оно есть
6
7const c1: SecondArg<(a: string, b: boolean) => void> = true;
8const c2: PromiseItem<Promise<number>[]> = 42;
9const c3: IdOf<{ id: string; name: string }> = "user-1";Ответы, если застряли:
1type SecondArg<F> = F extends (first: any, second: infer S, ...rest: any[]) => any ? S : never;
2type PromiseItem<T> = T extends Promise<infer V>[] ? V : never;
3type IdOf<T> = T extends { id: infer I } ? I : never;Запомнить разницу можно так:
inferэто инструмент разбора типа на части внутри условного типа. Пишем его мы, в описании типа.- Вывод аргументов дженерика это то, что компилятор делает сам, глядя на аргументы вызова.
NoInfer, несмотря на похожее название, управляет вторым, а не первым. С ключевым словом infer он не связан никак, кроме общего корня в названии.
Чему будет равен тип Result?
1type Unwrap<T> = T extends Promise<infer V> ? V : T;
2
3type Result = Unwrap<Promise<string>>;infer V встаёт на место содержимого промиса. Unwrap<Promise<string>> попадает в ветку true, V захватывает string, его условный тип и возвращает.NoInfer: как выключить источник вывода
NoInfer<T> это встроенный тип из TypeScript 5.4. Ничего импортировать не надо, он живёт в стандартной библиотеке рядом с Partial и Record.
Смысл ровно один: позиция, обёрнутая в NoInfer, перестаёт быть источником вывода. Значение в ней по-прежнему проверяется, но больше не участвует в решении, каким быть T.
Наш светофор:
1function createLight<C extends string>(colors: C[], defaultColor: NoInfer<C>): C {
2 return defaultColor;
3}
4
5createLight(["red", "yellow", "green"], "blue");
6// Argument of type '"blue"' is not assignable
7// to parameter of type '"red" | "yellow" | "green"'Наконец-то честная ошибка, да ещё и с перечислением допустимых значений.
Порядок работы компилятора теперь такой:
- Собрать кандидатов на
C, глядя только на обычные позиции. Здесь этоcolors, кандидат"red" | "yellow" | "green". - Зафиксировать
C. - Проверить остальные аргументы по зафиксированному
C."blue"не проходит.
Подсказка, если застряли: defaultColor: NoInfer<C> и fallback: NoInfer<T>. Разница с исходной версией не в том, что ошибок стало больше. Она в том, что теперь понятно, кто главный: массив задаёт тип, второй аргумент под него подстраивается.
Что изменится в этом вызове, если убрать NoInfer?
1interface Option<V extends string> {
2 value: V;
3 label: string;
4}
5
6declare function select<V extends string>(
7 options: Option<V>[],
8 selected: NoInfer<V>,
9): V;
10
11select(
12 [
13 { value: "ru", label: "Русский" },
14 { value: "en", label: "English" },
15 ],
16 "de", // Argument of type '"de"' is not assignable to parameter of type '"ru" | "en"'
17);NoInfer второй аргумент снова становится источником вывода. Кандидаты из списка и кандидат «de» объединяются, V превращается в «ru» | «en» | «de», и список опций перестаёт что-либо ограничивать.Как это устроено внутри и что делать на TypeScript младше 5.4
В 5.4 NoInfer сделали встроенным (intrinsic): компилятор знает про него на уровне алгоритма вывода, а не выражает его через другие типы.
До 5.4 в проектах жил самодельный вариант, и он до сих пор рабочий:
1type NoInferOld<T> = [T][T extends any ? 0 : never];Выглядит как шаманство, но идея понятная.
T extends any ? 0 : never это условный тип, который зависит от ещё не известного T. Пока T не зафиксирован, компилятор не может его вычислить и откладывает результат. Заглянуть внутрь отложенного типа он тоже не может, а значит, не может выцепить оттуда кандидата на T. Позиция перестаёт быть источником вывода, чего мы и добивались.
Когда T наконец известен, условие честно вычисляется в 0, и [T][0] даёт обычный T. На проверку типа эта обёртка не влияет, она влияет только на вывод.
Если у вас TypeScript 5.4 и новее, самодельный вариант не нужен. Но встретив такую строчку в чужом коде, вы теперь знаете, что это.
Грабли
Хотя бы один обычный источник обязан остаться
NoInfer не «предпочитает» один источник другому. Он выключает вывод в своей позиции, и всё.
Если обернуть все позиции, выводить будет неоткуда:
1declare function both<T>(a: NoInfer<T>, b: NoInfer<T>): T;
2
3const value = both(1, 2); // T = unknownОшибки нет, но и пользы тоже: T схлопнулся в unknown. Правило: NoInfer вешают на ведомые аргументы, а хотя бы один ведущий оставляют как есть.
Пустой массив ломает вывод
Это самый неожиданный побочный эффект. Если единственный источник вывода не даёт кандидатов, T становится never, и совершенно нормальный второй аргумент внезапно становится ошибкой:
1function pickDefault<T>(items: T[], fallback: NoInfer<T>): T {
2 return items[0] ?? fallback;
3}
4
5pickDefault([], 5);
6// Argument of type 'number' is not assignable to parameter of type 'never'Без NoInfer этот вызов проходил: кандидата давал второй аргумент. Теперь давать некому.
Лечится указанием типа руками или типизацией самого пустого массива:
1pickDefault<number>([], 5); // ок
2
3const empty: number[] = [];
4pickDefault(empty, 5); // окЕсли пустой список это штатная ситуация в вашем API, подумайте дважды, прежде чем вешать NoInfer.
NoInfer ничего не приводит и не сужает
Обёртка не превращает типы, не сужает и не расширяет их. Она только вычёркивает позицию из списка источников. Все обычные проверки совместимости остаются на месте.
NoInfer это не замена ограничению
C extends string говорит, каким C вообще разрешено быть. NoInfer говорит, откуда C берётся. Это разные вопросы, и в хорошей сигнатуре часто нужны оба.
Что выведет компилятор для T в этом вызове?
1declare function both<T>(a: NoInfer<T>, b: NoInfer<T>): T;
2
3const value = both(1, 2);NoInfer, кандидатов на T не осталось ни одного. Выводить не из чего, поэтому T схлопывается в unknown. Хотя бы один обычный источник вывода в сигнатуре оставлять обязательно.Где это пригодится на практике
Значение по умолчанию из списка
Самый частый случай, он же наш светофор: где-то есть список допустимых значений, а рядом одно выбранное или запасное.
1function pickDefault<T>(items: T[], fallback: NoInfer<T>): T {
2 return items[0] ?? fallback;
3}Ассерт в тестах
Хелпер, который сравнивает то, что получилось, с тем, что ожидалось. Ведущий здесь тот аргумент, который пришёл из кода, а не тот, который написал автор теста.
1declare function assertEquals<T>(actual: T, expected: NoInfer<T>): void;
2
3declare const status: "ok" | "fail";
4
5assertEquals(status, "ok"); // ок
6assertEquals(status, "pending"); // ошибка: '"pending"' не входит в '"ok" | "fail"'Именно здесь NoInfer окупается. Без обёртки вторая строчка проходит молча: T становится "ok" | "fail" | "pending", и тест сравнивает статус с чем угодно.
Обратите внимание на нюанс. Если бы типы были совсем несовместимы, скажем number и string, ошибка появилась бы и без NoInfer. Уязвимы именно случаи, где кандидаты объединяются: строковые литералы, объединения, объектные литералы.
Список состояний и переходы между ними
Здесь NoInfer стоит уже не на аргументе, а внутри вспомогательного типа. Так тоже можно.
1type Transition<S extends string> = { from: NoInfer<S>; to: NoInfer<S> };
2
3declare function machine<S extends string>(states: S[], transitions: Transition<S>[]): S;
4
5machine(["idle", "loading", "done"], [
6 { from: "idle", to: "loading" },
7 { from: "loading", to: "finished" },
8 // Type '"finished"' is not assignable to type '"done" | "idle" | "loading"'
9]);Список состояний задаёт словарь, переходы обязаны в него укладываться. Без NoInfer опечатка "finished" просто дописалась бы в S и осталась незамеченной.
Схема-словарь и ключ по ней
NoInfer работает и внутри Record, Partial и любых других типов-обёрток. И здесь видно ещё одну его пользу: он чинит не только тишину, но и адрес ошибки.
Сначала без обёртки:
1declare function trackEvent<E extends string>(schema: Record<E, string[]>, event: E): void;
2
3trackEvent({ click: [], focus: [] }, "click");
4// Object literal may only specify known properties,
5// and 'focus' does not exist in type 'Record<"click", string[]>'
6
7trackEvent({ click: [], focus: [] }, "hover");
8// Object literal may only specify known properties,
9// and 'click' does not exist in type 'Record<"hover", string[]>'Красные оба вызова, включая совершенно правильный первый. И оба сообщения обвиняют схему, хотя виноват в лучшем случае второй аргумент: компилятор взял E из события и требует, чтобы словарь состоял ровно из одного этого ключа.
Теперь с NoInfer:
1declare function trackEvent<E extends string>(schema: Record<E, string[]>, event: NoInfer<E>): void;
2
3trackEvent({ click: [], focus: [] }, "click"); // ок
4trackEvent({ click: [], focus: [] }, "hover");
5// Argument of type '"hover"' is not assignable to parameter of type '"click" | "focus"'Правильный вызов стал зелёным, а ошибка встала на нужный аргумент и говорит по делу. Это второй повод применять NoInfer: он чинит не только молчание компилятора, но и запутанные сообщения об ошибках.
Запасное значение для асинхронной операции
1declare function retry<T>(fn: () => Promise<T>, fallback: NoInfer<T>): Promise<T>;
2
3retry(async () => 1, 0); // ок
4retry(async () => 1, "x"); // ошибка: string не присваивается numberПропсы React-компонента
Тот же приём работает и в типах компонентов: список опций ведущий, выбранное значение ведомое.
1interface SelectProps<V extends string> {
2 options: V[];
3 value: NoInfer<V>;
4 onChange: (value: NoInfer<V>) => void;
5}
6
7declare function Select<V extends string>(props: SelectProps<V>): null;
8
9Select({ options: ["ru", "en"], value: "ru", onChange: () => {} }); // ок
10Select({ options: ["ru", "en"], value: "de", onChange: () => {} }); // ошибкаСоседняя фича: const-параметры типа
В TypeScript 5.0 появились const-параметры типа. Их часто вспоминают рядом с NoInfer, поэтому разложим, кто за что отвечает.
1declare function light<const C extends string>(colors: C[], def: C): C;const C просит компилятор не расширять литералы из аргумента, то есть влияет на точность кандидата. Вопрос «откуда берётся тип» он не решает вовсе:
1light(["red", "green"], "blue"); // ошибки по-прежнему нет, C = "red" | "green" | "blue"Как только к тому же объявлению добавить NoInfer, всё встаёт на места:
1declare function lightStrict<const C extends string>(colors: C[], def: NoInfer<C>): C;
2
3lightStrict(["red", "green"], "blue"); // ошибкаКороткое правило: const про то, насколько точный тип получится, NoInfer про то, кто его задаёт.
Шпаргалка
Tв дженерике это пустое место, которое компилятор заполняет по аргументам вызова.- Каждое появление
Tв списке параметров это источник вывода, и все они равноправны. - Кандидаты от разных источников могут объединиться. Тогда неправильный аргумент не отвергается, а дописывается в тип, и проверки просто нет.
- Если кандидаты объединить нельзя, побеждает первый, а остальные проверяются по нему. Отсюда ошибка в примере с
numberиstring. inferэто про разбор типа внутри условного типа. Другая механика, похожее слово.NoInfer<T>выключает вывод в своей позиции: значение проверяется, но наTбольше не влияет.- Хотя бы одну позицию нужно оставить обычной, иначе
Tстанетunknown. - Пустой массив плюс
NoInferдаютnever. Спасает явный тип-аргумент или типизированная переменная. NoInferчинит и адрес ошибки: без него компилятор нередко ругается на первый аргумент вместо того, где опечатка.const-параметр типа отвечает за точность,NoInferза источник. Часто нужны оба.- До TypeScript 5.4:
type NoInfer<T> = [T][T extends any ? 0 : never].
Проверь себя напоследок
В какой из сигнатур NoInfer стоит правильно, если цель такая: тип берётся из массива items, а fallback обязан ему соответствовать?
T. Первый вариант делает всё наоборот: ведущим становится fallback. Третий оставляет T без источников и даёт unknown. Четвёртый просто не скомпилируется: NoInfer нельзя написать в объявлении параметра типа.Функция объявлена как declare function assertEquals<T>(actual: T, expected: NoInfer<T>): void. Что будет с вызовом assertEquals([1, 2], [])?
T приходит из первого аргумента и равен number[]. Пустой массив совместим с number[], поэтому вызов проходит. Грабля с never появляется в другом случае: когда пустой массив стоит в единственной ведущей позиции и кандидатов не даёт.Большая песочница
Здесь всё вместе и с настоящим компилятором: команда npx tsc --noEmit запускается сразу после установки зависимостей, а её вывод виден в терминале. Правьте код и запускайте команду заново, чтобы получить свежий список ошибок.
Если хочется закрепить типизацию на практике, а не только в теории, посмотрите курс TypeScript для начинающих: там задания решаются в браузере с автопроверкой.