Вывод типов в TypeScript: что такое infer и зачем нужен NoInfer

19 августа 2026 г.

Это статья про один из самых мутных моментов в 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");

Почему светофор молчит

Соберём мысль целиком, потому что она главная во всей статье.

Мы хотели, чтобы список цветов был источником истины, а второй аргумент проверялся по нему.

А написали мы другое: «C берётся и из списка, и из значения по умолчанию». Для компилятора оба аргумента равноправны. Он не проверяет второй по первому, он спрашивает у обоих, каким должен быть C.

Поэтому неправильный аргумент не отвергается, а дописывается в тип. Проверка не срабатывает потому, что проверять не по чему: эталон подстраивается под то, что мы проверяем.

1// Что написано: C выводится из обоих аргументов 2function createLight<C extends string>(colors: C[], defaultColor: C): C; 3 4// Что имелось в виду: C выводится из colors, defaultColor обязан ему соответствовать

Осталось научиться говорить вторую строчку на языке TypeScript. Но сначала расчистим путаницу вокруг слова «вывод».

Ключевое слово infer, и почему это не то же самое

Слово «вывод» в TypeScript живёт в двух разных местах, и новичка это регулярно сбивает.

  1. Вывод аргументов дженерика при вызове функции. Это всё, что было выше.
  2. Ключевое слово 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>>;

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"'

Наконец-то честная ошибка, да ещё и с перечислением допустимых значений.

Порядок работы компилятора теперь такой:

  1. Собрать кандидатов на C, глядя только на обычные позиции. Здесь это colors, кандидат "red" | "yellow" | "green".
  2. Зафиксировать C.
  3. Проверить остальные аргументы по зафиксированному 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);

Как это устроено внутри и что делать на 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);

Где это пригодится на практике

Значение по умолчанию из списка

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

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 обязан ему соответствовать?

Проверь себя

Функция объявлена как declare function assertEquals<T>(actual: T, expected: NoInfer<T>): void. Что будет с вызовом assertEquals([1, 2], [])?

Большая песочница

Здесь всё вместе и с настоящим компилятором: команда npx tsc --noEmit запускается сразу после установки зависимостей, а её вывод виден в терминале. Правьте код и запускайте команду заново, чтобы получить свежий список ошибок.

Если хочется закрепить типизацию на практике, а не только в теории, посмотрите курс TypeScript для начинающих: там задания решаются в браузере с автопроверкой.