Advanced Type Inference Tricks
Deep-dive into TypeScript's inference engine: infer in conditional types, return type inference, contextual typing, generic defaults, and satisfies vs as.
Overview
Key Points
- •`infer U` in a conditional type creates a type variable U that TypeScript binds during matching
- •`ReturnType<T>`, `Parameters<T>`, `InstanceType<T>` are all built on `infer`
- •Multiple `infer` positions in one conditional type: all must be consistent for inference to succeed
- •Contextual typing: the expected type from context (e.g., callback parameter) drives inference
- •`satisfies` validates a type without widening: `const x = { ... } satisfies Config` - x keeps literal types, errors if shape is wrong
- •Generic defaults `<T = DefaultType>`: T is inferred when possible, falls back to Default when not
- •`infer` with template literals: `T extends `prefix-${infer Rest}` ? Rest : never`
Examples
The `infer` keyword in conditional types
// Extract the element type from an array
type ElementType<T> = T extends (infer U)[] ? U : never;
type A = ElementType<string[]>; // string
type B = ElementType<number[][]>; // number[]
type C = ElementType<string>; // never
// Extract from a Promise
type Awaited_<T> = T extends Promise<infer U> ? Awaited_<U> : T;
// (TypeScript has built-in Awaited<T> since 4.5)
type D = Awaited_<Promise<Promise<string>>>; // string
// Extract function parameter types
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
function greet(name: string, age: number): string { return ''; }
type E = FirstParam<typeof greet>; // string
// Multiple infer positions
type UnpackPromiseAndArray<T> = T extends Promise<(infer U)[]>
? U
: T extends (infer V)[]
? V
: T;
type F = UnpackPromiseAndArray<Promise<string[]>>; // string
type G = UnpackPromiseAndArray<number[]>; // number
type H = UnpackPromiseAndArray<boolean>; // boolean
// Infer with template literals
type RouteParam<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | RouteParam<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = RouteParam<'/users/:userId/posts/:postId'>;
// "userId" | "postId"The `satisfies` operator vs type annotations vs `as`
// Problem: annotating loses literal types
const palette1: Record<string, string> = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
};
// palette1.red is typed as string, not "#ff0000"
// palette1.purple = '#800080'; // No error! Record<string, string> is too wide
// Problem: 'as' skips type checking
const palette2 = {
red: '#ff0000',
green: 42, // Bug - should be string!
blue: '#0000ff',
} as Record<string, string>; // No error - 'as' is a cast, not a check
// SOLUTION: satisfies - validates AND preserves literal types
type ColorMap = Record<string, string | [r: number, g: number, b: number]>;
const palette3 = {
red: '#ff0000',
green: [0, 255, 0], // Also allowed by the type
blue: '#0000ff',
// yellow: 42, // Error - 42 is not string | [number, number, number]
} satisfies ColorMap;
// palette3.red is typed as "#ff0000" (string literal preserved)
// palette3.green is typed as [0, 255, 0] (tuple preserved)
// palette3.yellow would be a compile error
// palette3.red.toUpperCase() - works because TypeScript knows it's a string
// satisfies is also great for config validation
interface ServerConfig {
port: number;
host: string;
ssl: boolean;
}
const config = {
port: 3000, // typed as 3000 (literal), not number
host: 'localhost',
ssl: false,
} satisfies ServerConfig;
// config.port is 3000 (literal), useful for conditional logic
type IsDevPort = typeof config.port extends 3000 ? true : false; // trueGeneric defaults and contextual typing
// Generic defaults - T falls back when it cannot be inferred
function createStore<T = Record<string, unknown>>(initialState?: T) {
let state: T = initialState ?? ({} as T);
return {
get: (): T => state,
set: (newState: T) => { state = newState; },
};
}
// Inferred from argument
const store1 = createStore({ count: 0, name: 'app' });
// T is { count: number; name: string }
// Default applies when no argument
const store2 = createStore();
// T is Record<string, unknown> (the default)
// Contextual typing - TypeScript infers from where the function is used
const nums = [1, 2, 3, 4, 5];
// TypeScript knows n is number because Array<number>.filter callback expects (n: number) => boolean
const even = nums.filter(n => n % 2 === 0); // n inferred as number
// Same for object methods
interface EventMap {
click: MouseEvent;
input: InputEvent;
}
function on<K extends keyof EventMap>(
event: K,
handler: (e: EventMap[K]) => void
): void {
document.addEventListener(event, handler as EventListener);
}
// e is inferred as MouseEvent from context
on('click', e => {
console.log(e.clientX, e.clientY); // Works - e is MouseEvent
});
// e is inferred as InputEvent from context
on('input', e => {
console.log(e.data); // Works - e is InputEvent
});Common Mistakes
- Using `infer` outside a conditional type: `infer` is only valid inside the extends clause of a conditional type (`T extends Pattern ? Use<infer U> : ...`)
- Confusing `satisfies` with type annotation: an annotation (`const x: Type = ...`) widens to Type and loses literal types; `satisfies` validates the shape while preserving the narrowest type. Use annotation when you WANT the variable typed as Type, use satisfies when you want validation but keep specific types
- Relying on inference when you need explicit: if a function body can return multiple types, TypeScript may infer a wider type than intended. Add an explicit return type to lock down the contract
- Using `as` to fix type errors: `as` is a cast that bypasses the type checker. Almost every use of `as` is a suppressed type error. Prefer `satisfies`, proper typing, or a user-defined type guard instead
Note
The `satisfies` operator (added in TS 4.9) is the answer to the tension between "I want TypeScript to check my object against a schema" and "I want to keep the specific inferred types for each property." Use annotation when you need the variable typed as the schema type; use `satisfies` when you want to keep the specific types while still enforcing the schema.
Exercises
Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.
Completed: 0 / 2 exercises
Knowledge Check
Question 1 of 4