🚀AdvancedAdvanced Integration

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

TypeScript's type inference engine is powerful but has quirks. Understanding how it works lets you write types that "just work" from the call site without requiring explicit annotations. This topic covers advanced inference techniques: - **`infer` in conditional types**: extract parts of a type without knowing the structure upfront - **Inferring from function return types**: TypeScript can infer complex types from what a function returns - **Contextual typing**: TypeScript infers parameter types from how a function is used - **Generic defaults**: `<T = string>` lets a generic have a fallback when T cannot be inferred - **The `satisfies` operator** (TS 4.9): validate a value against a type while keeping its literal type - **Inference priority and sites**: TypeScript prefers some inference sites over others when T appears in multiple positions Mastering these patterns lets you build APIs where the compiler does the annotation work for the user.

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; // true

Generic 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.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 4

What does `infer U` do when used inside a conditional type?