TypeScript Mapped Type Modifiers: When Inference Breaks
How TypeScript infers +readonly and -optional in mapped types—and when inference silently breaks
TypeScript's mapped types can add or remove readonly and ?
modifiers using + and - prefixes. But how TypeScript
infers these modifiers in generic contexts leads to surprising behavior
that breaks seemingly correct code.
Modifier Syntax Basics
Mapped types can manipulate property modifiers explicitly:
// Add readonly to all properties
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Remove optional from all properties
type Required<T> = { [K in keyof T]-?: T[K] };
// Remove readonly (mutable)
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
// Add optional
type Partial<T> = { [K in keyof T]?: T[K] };
The + prefix is implicit—readonly means +readonly,
and ? means +?. But -readonly and -?
must be explicit.
The Inference Problem
When mapped types are used in generic function parameters, TypeScript must infer the original type from a transformed version. This is where modifiers cause trouble:
type Wrapped<T> = { [K in keyof T]: { value: T[K] } };
function unwrap<T>(wrapped: Wrapped<T>): T {
// Implementation...
}
// Works fine
const result = unwrap({ name: { value: "Alice" } });
// result: { name: string } ✓But add modifiers, and inference fails:
type PartialWrapped<T> = { [K in keyof T]?: { value: T[K] } };
function unwrapPartial<T>(wrapped: PartialWrapped<T>): T {
// Implementation...
}
// Inference breaks!
const result = unwrapPartial({ name: { value: "Alice" } });
// result: { name?: string | undefined } ← Still has optional modifier!Why Inference Preserves Modifiers
TypeScript's inference algorithm doesn't "undo" modifier transformations.
When inferring T from { [K in keyof T]?: ... },
the inferred type includes the optional modifier because TypeScript
sees the input as having optional properties.
// What you expect:
// Input: { name?: { value: string } }
// Infer: T = { name: string } ← Removed the ?
// What happens:
// Input: { name?: { value: string } }
// Infer: T = { name?: string } ← Kept the ?The type system can't distinguish between "this property is optional because of the mapped type transformation" and "this property was already optional in T."
Workaround: Explicit Constraint
Force the inferred type to exclude modifiers by using a constraint:
type StrictObject = { [key: string]: unknown };
function unwrapPartial<T extends StrictObject>(
wrapped: PartialWrapped<T>
): Required<T> {
// Now returns Required<T> explicitly
}
// Or use a helper type to strip modifiers
type Concrete<T> = { [K in keyof T]-?: T[K] };
function unwrapPartial<T>(wrapped: PartialWrapped<T>): Concrete<T> {
// Explicitly removes optional
}The readonly Inference Quirk
readonly has its own inference oddity. TypeScript is more lenient
with readonly—it allows assigning readonly properties to mutable ones:
type ReadonlyWrapped<T> = { readonly [K in keyof T]: T[K] };
const mutableObj = { name: "Alice" };
const readonlyObj: ReadonlyWrapped<typeof mutableObj> = mutableObj;
// ✓ Works! Mutable → readonly is allowed
const backToMutable: typeof mutableObj = readonlyObj;
// ✓ Also works?! readonly → mutable is allowed too!TypeScript's structural typing means readonly is advisory at the type level. This affects inference: the compiler won't add readonly to inferred types just because the mapped type has it.
function wrap<T>(obj: T): ReadonlyWrapped<T> {
return obj; // ← No error, even though obj is mutable
}
const wrapped = wrap({ name: "Alice" });
// wrapped: { readonly name: string }
// But the original object is still mutable!Homomorphic vs Non-Homomorphic
Mapped types that iterate over keyof T are "homomorphic"—they
preserve modifiers from the original type. Non-homomorphic types don't:
// Homomorphic: preserves modifiers from T
type Homomorphic<T> = { [K in keyof T]: T[K] };
// Non-homomorphic: iterates over literal union, loses modifiers
type Keys = "name" | "age";
type NonHomomorphic<T> = { [K in Keys]: T[K] };
When you use keyof T, TypeScript knows to carry over readonly/optional
from the source type. When you use a hardcoded union, that context is lost.
interface User {
readonly id: string;
name?: string;
}
type Homo = Homomorphic<User>;
// { readonly id: string; name?: string } ← Modifiers preserved
type NonHomo = { [K in "id" | "name"]: User[K] };
// { id: string; name: string } ← Modifiers lost!Practical Implications
- Form libraries using mapped types for partial updates often hit this—initial values lose their required status when inferred.
- State management types that wrap/unwrap state shapes can accidentally make everything optional or mutable.
- API response transformers may infer looser types than intended, requiring explicit return type annotations.
The Fix: Explicit Types
When mapped type inference matters, don't rely on inference. Annotate explicitly:
// Instead of letting T be inferred...
function processPartial<T>(input: Partial<T>): T {
return input as T; // Dangerous!
}
// ...require T explicitly
function processPartial<T extends object>(
input: Partial<T>,
defaults: Required<T>
): Required<T> {
return { ...defaults, ...input };
}Mapped type modifier inference is predictable once you understand it, but it's rarely intuitive. When in doubt, add explicit type annotations rather than trusting inference through complex mapped types.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement