Під час проектування узагальнених інтерфейсів, бібліотек та функцій ключовим завданням розробника є управління напрямком і точками виводу типів (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. Він забезпечує повний контроль над тим, які аргументи функції слугують джерелами істини для узагальненого типу, а які є лише споживачами. Це захищає кодові бази від небажаного розширення об'єднань та перетворює помилки в дефолтних значеннях на миттєві повідомлення компілятора.