EdgeCases Logo
Feb 2026
TypeScript
Expert
7 min read

TypeScript Mapped Type Modifiers: When Inference Breaks

How TypeScript infers +readonly and -optional in mapped types—and when inference silently breaks

typescript
mapped-types
readonly
optional
inference
generics
edge-case

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

Related Insights

Explore related edge cases and patterns

CSS
Deep
CSS Animations vs Figma: Production Reality vs Design Prototypes
7 min
CSS
Deep
Animating CSS Grid: The Discrete Value Problem
7 min

Advertisement