При проектировании обобщенных интерфейсов, библиотек и функций ключевой задачей разработчика является управление направлением и точками вывода типов (Type Inference).
До версии TypeScript 5.4 в системе типов существовала серьезная архитектурная проблема: если параметр типа T использовался более чем в одном аргументе функции, компилятор рассматривал каждую позицию как равноправного кандидата для вывода T. В результате тип вычислялся как математическое объединение (Union) всех переданных аргументов:
// Проблема до TypeScript 5.4: паразитный вывод объединения
function createColorPalette<T extends string>(
allowedColors: T[],
defaultColor: T // Мы хотим, чтобы defaultColor брался строго из allowedColors!
) { /* ... */ }
// Разработчик опечатался: 'emerald' отсутствует в списке allowedColors!
createColorPalette(['red', 'blue'], 'emerald');
// ВМЕСТО ОШИБКИ: TypeScript молча выводит T как ('red' | 'blue' | 'emerald')!
// Валидация полностью нарушена: опечатка в дефолте тихо расширила разрешенный список цветов!Чтобы заблокировать конкретный аргумент от участия в формировании типа T, разработчики годами изобретали запутанные хаки на базе условных типов (вроде type NoInferLegacy<T> = [T][T extends any ? 0 : never]), которые замедляли tsc и делали сообщения об ошибках в IDE нечитаемыми.
В TypeScript 5.4+ появилась официальная встроенная платформенная утилита NoInfer<T>.
Утилита NoInfer<T> блокирует вывод типов из вторичных аргументов (дефолтных значений, коллбэков, селекторов): компилятор вычисляет generic T исключительно по первичным источникам, а в позициях с NoInfer<T> производит только строгую проверку присваиваемости.
Утилита NoInfer<T> разделяет позиции аргументов generic-функции на две строго разграниченные категории:
T (например, список разрешенных состояний конечного автомата, массив зарегистрированных маршрутов или схема колонок таблицы).T (дефолтное состояние, начальное значение, обработчик перехода), но лишенные права расширять T новыми значениями.Оборачивание типа аргумента в NoInfer<T> говорит компилятору: «Вычисли тип T по всем остальным аргументам-источникам, а на этом аргументе только проверь совместимость переданного значения с уже зафиксированным типом T».
+-------------------------------------------------------------------------+
| Механика вывода типов с утилитой NoInfer<T> |
+-------------------------------------------------------------------------+
| Без NoInfer<T>: |
| fn<T>(candidates: T[], fallback: T) |
| 1. candidates ('red' | 'blue') ──> КАНДИДАТ ДЛЯ T |
| 2. fallback ('emerald') ──> КАНДИДАТ ДЛЯ T |
| ИТОГ T: ('red' | 'blue' | 'emerald') (Тихий дефект в архитектуре!) |
+-------------------------------------------------------------------------+
│
▼ Решение в TypeScript 5.4+
+-------------------------------------------------------------------------+
| С утилитой NoInfer<T>: |
| fn<T>(candidates: T[], fallback: NoInfer<T>) |
| 1. candidates ('red' | 'blue') ──> ЕДИНСТВЕННЫЙ ИСТОЧНИК ДЛЯ T |
| ИТОГ T: ('red' | 'blue') |
| 2. fallback проверяется на assignability: |
| 'emerald' не входит в ('red' | 'blue') => ОШИБКА КОМПИЛЯЦИИ! |
+-------------------------------------------------------------------------+Компилятор исследует позиции аргументов: все параметры БЕЗ NoInfer вносят свои типы в пул вычисления дженерика T.
Позиции аргументов, обернутые в NoInfer<T>, временно отстраняются от участия в синтезе типа T.
Компилятор окончательно вычисляет замкнутый тип T на базе собранных источников первого порядка.
Аргумент в позиции NoInfer<T> проверяется на соответствие зафиксированному T; при несовпадении компилятор выдает ошибку.
Функция объявлена как: 'function createSelect<T extends string>(options: T[], selected: NoInfer<T>): void'. Что произойдет при вызове createSelect(['active', 'pending'], 'draft')?
В стейт-машинах: createMachine({ states, initialState }). Начальное состояние initialState: NoInfer<TStates> обязано входить в ключи объекта states. Если программист допустит опечатку в initialState, компилятор немедленно подсветит ошибку, вместо того чтобы неявно добавить несуществующее состояние в граф.
В фабриках компонентов: defineButton({ variants: ['primary', 'outline', 'ghost'], defaultVariant: 'primary' }). Значение defaultVariant: NoInfer<TVariant> строго привязано к объявленным вариантам.
При регистрации подписчиков: bus.on(event: TEvent, handler: (payload: EventPayload<NoInfer<TEvent>>) => void). Предотвращаются циклические искажения вывода типов, когда аргументы коллбэка случайно меняли тип самого события.
В выборках query.select(columns: TCol[], orderBy: NoInfer<TCol>). Аргумент сортировки orderBy валидируется строго против выбранных колонок, не позволяя сортировать по полям, не включенным в проекцию.
Разберем продакшн-реализацию строгого конечного автомата (State Machine), где начальное состояние и методы перехода защищены через NoInfer<TState>.
Разработчик объявляет граф состояний в объекте states. Дженерик TState формируется исключительно из этих ключей.
Поле initialState типизировано как NoInfer<TState>, что запрещает ему расширять объединение TState.
При передаче невалидного состояния компилятор сообщает об ошибке присвоения, блокируя баг до сборки.
Метод machine.transitionTo(next: NoInfer<TState>) разрешает переходы только в известные состояния.
export interface StateConfig<TContext> {
readonly onEnter?: (context: TContext) => void;
readonly onExit?: (context: TContext) => void;
}
export interface MachineDefinition<TState extends string, TContext> {
readonly states: Record<TState, StateConfig<TContext>>;
// Использование NoInfer<TState> гарантирует, что initialState
// не добавит в TState новое случайное состояние при опечатке!
readonly initialState: NoInfer<TState>;
readonly context: TContext;
}
export class StateMachine<TState extends string, TContext> {
private currentState: TState;
private readonly context: TContext;
private readonly states: Record<TState, StateConfig<TContext>>;
constructor(definition: MachineDefinition<TState, TContext>) {
this.currentState = definition.initialState;
this.context = definition.context;
this.states = definition.states;
}
public getState(): TState {
return this.currentState;
}
/**
* Метод перехода в следующее состояние.
* Аргумент nextState также обернут в NoInfer<TState>!
*/
public transitionTo(nextState: NoInfer<TState>): void {
if (!(nextState in this.states)) {
throw new Error(`Недопустимый переход в состояние: ${nextState}`);
}
this.states[this.currentState]?.onExit?.(this.context);
this.currentState = nextState;
this.states[this.currentState]?.onEnter?.(this.context);
}
}
/**
* Фабричная функция создания стейт-машины
*/
export function createStateMachine<TState extends string, TContext>(
definition: MachineDefinition<TState, TContext>
): StateMachine<TState, TContext> {
return new StateMachine(definition);
}Что произойдет, если разработчик объявит функцию как 'function compare<T>(a: NoInfer<T>, b: NoInfer<T>): boolean' и вызовет compare('foo', 'bar') без явного указания типа?
Демонстрация работы промышленного процессора платежей, использующего NoInfer<T> для защиты от опечаток в начальном состоянии и предотвращения переходов в невалидные статусы.
import { createStateMachine } from '@/lib/state-machine';
interface PaymentContext {
orderId: string;
amount: number;
}
// 1. Позитивный сценарий: initialState ('PENDING') входит в список states
export const paymentMachine = createStateMachine({
context: { orderId: 'ord_123', amount: 4500 },
states: {
PENDING: { onEnter: (ctx) => console.log('Ожидание оплаты...', ctx.orderId) },
PROCESSING: {},
COMPLETED: {},
FAILED: {},
},
initialState: 'PENDING', // ✅ Валидно!
});
// 2. Ошибки компилятора TypeScript (Compile-time Safety):
export const brokenMachine = createStateMachine({
context: { orderId: 'ord_999', amount: 100 },
states: {
IDLE: {},
RUNNING: {},
},
// @ts-expect-error Ошибка: 'STOPPED' отсутствует в объекте states!
initialState: 'STOPPED',
// Type '"STOPPED"' is not assignable to type '"IDLE" | "RUNNING"'.
});
// 3. Защита метода transitionTo:
paymentMachine.transitionTo('PROCESSING'); // ✅ Валидно!
// @ts-expect-error Ошибка: Несуществующее состояние не разрешено методом перехода
paymentMachine.transitionTo('REFUNDED');
// Argument of type '"REFUNDED"' is not assignable to parameter of type
// '"PENDING" | "PROCESSING" | "COMPLETED" | "FAILED"'.Если обернуть параметр типа в NoInfer<T> во всех аргументах функции: function fn<T>(a: NoInfer<T>, b: NoInfer<T>), компилятор потеряет все источники вывода типов! Тип T не сможет быть вычислен из аргументов и откатится к unknown. Ровно одна позиция (или группа определяющих аргументов) обязана оставаться источником вывода.
// ❌ АНТИПАТТЕРН: Нет источников вывода! T деградирует в unknown
function compare<T>(a: NoInfer<T>, b: NoInfer<T>): boolean { return a === b; }
compare('foo', 'bar'); // T выводится как unknown, проверка сломана!
// ✅ ПРАВИЛЬНО: Первый аргумент формирует тип, второй — только проверяется
function compare<T>(primary: T, candidate: NoInfer<T>): boolean { return primary === candidate; }
// @ts-expect-error Ошибка: 'bar' несовместим с 'foo'
compare('foo', 'bar');Утилита NoInfer<T> не отключает проверку типов (Assignability Check), она лишь исключает аргумент из числа кандидатов на формирование типа. Значение, переданное в позицию NoInfer<T>, по-прежнему строго проверяется компилятором на совместимость с уже зафиксированным типом T.
Объявление function get<T>(): NoInfer<T> лишено практического смысла: возвращаемое значение функции никогда не является кандидатом для вывода generic-параметра аргументов. NoInfer предназначен исключительно для аргументов функций и методов.
Утилитный тип NoInfer<T> в TypeScript 5.4+ — незаменимый инструмент архитектора библиотек и DSL. Он дает полный контроль над тем, какие аргументы функции являются «источниками истины» для обобщенного типа, а какие выступают исключительно потребителями. Это защищает кодовые базы от тихих багов расширения объединений и превращает опечатки в дефолтных значениях в моментальные ошибки компилятора.