Skip to content

Advanced Inference

TypeScript’s inference engine is incredibly powerful, but it follows strict rules.

Two of the most important concepts are:

  1. Variance — how types relate when wrapped in other types
  2. Generic inference patterns — how TypeScript infers type parameters from usage

Understanding these unlocks the ability to design expressive, safe, ergonomic APIs.


Variance

Variance describes how type relationships behave when wrapped in another type.

For example:

  • If Dog is a subtype of Animal, what about Array<Dog> vs Array<Animal>?

Variance answers this.


The Four Kinds of Variance

1. Covariance (most common)

If A extends B, then Wrapper<A> extends Wrapper<B>.

Example:

type Box<T> = { value: T }

let a: Box<"hello"> = { value: "hello" }
let b: Box<string> = a // ✔ OK (covariant)

Arrays, Promises, and most generic containers are covariant.


2. Contravariance

If A extends B, then Wrapper<B> extends Wrapper<A>.

This happens mainly with function parameters.

type Handler<T> = (value: T) => void

let handleString: Handler<string> = v => {}
let handleAny: Handler<any> = v => {}

handleString = handleAny   // ✔ OK (contravariant)
handleAny = handleString   // ❌ Not safe

Why?

A function that expects a string cannot safely handle any.


3. Invariance

No relationship is allowed.

type Invariant<T> = { value: T }

let x: Invariant<string>
let y: Invariant<any>

// Neither direction is allowed
x = y // ❌
y = x // ❌

Some libraries intentionally use invariance to prevent unsafe assignments.


4. Bivariance

Both directions are allowed.

This is unsafe, but TypeScript allows it for event handlers for ergonomic reasons.

type Listener<T> = (value: T) => void

let l: Listener<string> = v => {}
let a: Listener<any> = v => {}

l = a // ✔ allowed (bivariant)
a = l // ✔ allowed (bivariant)

This is a pragmatic compromise.


Why Variance Matters

Variance affects:

  • function parameter inference
  • callback types
  • event handlers
  • generic constraints
  • safe API design

If you’ve ever wondered:

“Why won’t TypeScript let me assign this function?”

…it’s almost always variance.


Generic Inference Patterns

TypeScript uses several inference strategies when resolving generics.

Let’s explore the most important ones.


1. Inference from Arguments

function wrap<T>(value: T): T {
  return value
}

const x = wrap("hello") // T = string

This is the simplest and most common pattern.


2. Inference from Return Position

function makePair<T>(value: T) {
  return [value, value] as const
}

const p = makePair(123)
// T = 123 (literal inference)

Return‑position inference is especially powerful with as const.


3. Inference from Multiple Parameters

function merge<A, B>(a: A, b: B) {
  return { ...a, ...b }
}

const m = merge({ id: 1 }, { name: "Alice" })
// A = { id: number }
// B = { name: string }

TypeScript infers each type parameter independently.


4. Inference with Constraints

function getProp<T extends object, K extends keyof T>(obj: T, key: K) {
  return obj[key]
}

const name = getProp({ id: 1, name: "Alice" }, "name")
// T = { id: number; name: string }
// K = "name"

Constraints guide inference without restricting flexibility.


5. Inference with Conditional Types

Conditional types can infer types using infer.

Extract return type

type Return<T> = T extends (...args: any[]) => infer R ? R : never

Extract argument type

type FirstArg<T> = T extends (arg: infer A, ...rest: any[]) => any ? A : never

Extract array element

type Element<T> = T extends (infer U)[] ? U : never

These patterns are used everywhere in advanced libraries.


6. Inference with Variance

Variance affects how inference works.

Example: Contravariance blocks inference

type Handler<T> = (value: T) => void

function register<T>(handler: Handler<T>) {}

register(v => console.log(v))
// T = unknown (not inferred as `string`)

Why?

Because function parameters are contravariant, so TypeScript cannot safely infer a narrower type.


7. Inference with Partial Information

TypeScript infers as much as it can:

function pair<A, B>(a: A, b: B) {
  return [a, b]
}

const p = pair(1, null)
// A = number
// B = null

If inference fails, TypeScript falls back to unknown.


Real‑World Example: React’s useState

React’s useState uses advanced inference patterns:

function useState<T>(initial: T): [T, (value: T) => void]

Usage:

const [count, setCount] = useState(0)
// T = number

But with null:

const [value, setValue] = useState(null)
// T = null (not helpful)

So React uses a default type parameter:

function useState<T = undefined>(initial?: T): [T, Dispatch<T>]

This is why you often write:

const [user, setUser] = useState<User | null>(null)

This is advanced inference in action.


Real‑World Example: Zod’s infer

Zod schemas infer TypeScript types using conditional types + infer:

type Infer<T> = T extends ZodType<infer U> ? U : never

Usage:

const UserSchema = z.object({
  id: z.string(),
  name: z.string()
})

type User = z.infer<typeof UserSchema>

This pattern is everywhere in modern TS libraries.


Putting It All Together

Here’s a powerful example combining variance + inference:

type Listener<T> = (value: T) => void

function onEvent<T>(event: string, listener: Listener<T>) {
  // ...
}

onEvent("login", (payload) => {
  payload.userId // payload inferred as unknown
})

Why is payload inferred as unknown?

  • function parameters are contravariant
  • TypeScript cannot safely infer a narrower type
  • so it defaults to unknown

To fix it:

onEvent<{ userId: string }>("login", payload => {
  payload.userId
})

Understanding variance explains why inference behaves this way.


Summary

In this lesson, you learned the deeper mechanics behind TypeScript’s inference engine:

1. Variance

  • covariance (safe for outputs)
  • contravariance (safe for inputs)
  • invariance
  • bivariance (special case for callbacks)

2. Generic inference patterns

  • inference from arguments
  • inference from return types
  • inference with constraints
  • inference with conditional types
  • inference with infer
  • inference limitations due to variance

These concepts are essential for designing safe, ergonomic, and powerful TypeScript APIs—especially in libraries and frameworks.