Advanced Inference¶
TypeScript’s inference engine is incredibly powerful, but it follows strict rules.
Two of the most important concepts are:
- Variance — how types relate when wrapped in other types
- 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
Dogis a subtype ofAnimal, what aboutArray<Dog>vsArray<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.