Using Readonly: Making All Properties Immutable
Introduction
Mutable state is a frequent source of bugs in JavaScript and TypeScript applications: properties get changed in unexpected places, objects drift from their intended shapes, and reasoning about data flow becomes harder. TypeScript's Readonly
This article is aimed at intermediate developers who already know TypeScript basics and want to apply immutability to reduce bugs and create clearer APIs. We'll cover practical examples, step-by-step transformations, interactions with other utility types, best practices for libraries and applications, and debugging tips. By the end you'll be able to choose between Readonly
Background & Context
Readonly
Understanding Readonly
Key Takeaways
- Readonly
makes all top-level properties of a type readonly at compile time. - Readonly
is a mapped type and can be composed with other utilities like Partial and Pick . - Readonly
does not deeply freeze nested objects at runtime — for deep immutability you need mapped types or runtime freezing. - Prefer immutable types on public API surfaces and function parameters to communicate intent.
- Use runtime validation (Zod/Yup) or Object.freeze when you need runtime guarantees.
Prerequisites & Setup
You should be familiar with TypeScript basics: interfaces, types, generics, and mapped types. A recent TypeScript version (4.x+) is recommended to access improved type inference and nicer error messages. To try examples locally create a project with TypeScript installed (npm i -D typescript) and configure tsconfig.json (strict mode recommended). We'll include compilation examples and runtime snippets — optional libraries for runtime validation are noted in the sections below.
If you want a refresher on utility types before diving in, check the utility types guide.
Main Tutorial Sections
What Readonly Does (and What It Doesn't)
Readonly
type User = { id: number; name: string };
type ImmutableUser = Readonly<User>;
const u: ImmutableUser = { id: 1, name: 'Jane' };
// u.id = 2; // Error: cannot assign to 'id' because it is a read-only propertyImportant: Readonly
Readonly vs readonly Modifier in Interfaces
You can declare readonly directly in interfaces and classes:
interface Point { readonly x: number; readonly y: number }Readonly
Readonly with Arrays and Tuples
Arrays have a special readonly array type: readonly T[] or ReadonlyArray
const arr: ReadonlyArray<number> = [1, 2, 3]; // arr.push(4); // Error const tuple: readonly [string, number] = ['a', 1]; // tuple[0] = 'b'; // Error
Use readonly arrays to prevent mutation of list containers; that complements Readonly
Combining Readonly with Other Utility Types
Readonly
type Settings = { host: string; port?: number; timeout?: number };
type StableSettings = Readonly<Pick<Settings, 'host'>> & Partial<Pick<Settings, 'port' | 'timeout'>>;If you need a refresher on utility type composition and transformations, see the comprehensive utility types guide and how Partial works in practice in Using Partial
Creating a DeepReadonly Type
Readonly
type DeepReadonly<T> = T extends Function
? T
: T extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type Nested = { a: { b: number[] }, f(): void };
type RN = DeepReadonly<Nested>;This preserves functions (so methods stay callable) and recursively converts arrays and objects. Use this pattern when you need type-level guarantees across an entire tree.
Readonly in Function Signatures
Marking function parameters as Readonly communicates intent:
function processUser(user: Readonly<User>) {
// compile-time guarantee: we won't reassign user's properties
}If your function needs to transform data, prefer returning a new object rather than mutating inputs. Combine Readonly with generic helpers when writing reusable APIs — see patterns in Generic Functions.
Readonly in Generic Types, Interfaces and Classes
When writing generic types, you can expose readonly variants:
interface Container<T> { value: T }
type ReadonlyContainer<T> = Readonly<Container<T>>;For class-based libraries, readonly properties can be declared on class fields. If you're authoring classes with generics, look at Generic Classes and Generic Interfaces for patterns on exposing typed, immutable APIs.
Runtime Guarantees: Object.freeze and Validation
TypeScript's immutability is compile-time. If you need runtime enforcement, use Object.freeze or validation libraries. Example:
const frozenUser = Object.freeze({ id: 1, name: 'Jane' } as const);
// frozenUser.id = 2; // fails silently in non-strict mode or throws in strict modeFor robust runtime guarantees and consistent parsing, integrate validation with libraries like Zod or Yup. See practical integration patterns in Using Zod or Yup for Runtime Validation with TypeScript Types. For API payloads, immutability plus parsing can prevent accidental mutation of request/response objects — learn more at Typing API Request and Response Payloads with Strictness.
Avoiding Unsafe Type Assertions with Readonly
Avoid asserting types to circumvent read-only checks. Using as or <> to cast can break immutability guarantees:
const r: Readonly<User> = { id: 1, name: 'Jane' };
const writable = r as unknown as User; // bypasses readonly — dangerousIf you must convert, do so with explicit copies:
const writableCopy = { ...r };
writableCopy.name = 'John'; // safe explicit mutation on a new objectRead more about the risks of type assertions in our guide: Type Assertions (as keyword or <>) and Their Risks.
Interop with Third-Party Libraries and Typings
When a library exposes mutable types, you can wrap them with Readonly to protect your internal code. However, if the library mutates objects internally you must coordinate: either make defensive copies on input or use runtime freezing. When writing library types that export immutability, document whether your types are shallow or deep and prefer returning new states rather than mutating inputs. For patterns on typing different library shapes consult articles on typing libraries with complex signatures and class-based libraries such as Typing Libraries With Complex Generic Signatures — Practical Patterns and Typing Libraries That Are Primarily Class-Based in TypeScript.
Advanced Techniques
- Create specialized mapped types: beyond DeepReadonly, build DeepMutable or SelectiveReadonly<T, K>. Use conditional types and infer to preserve function types and handle tuples correctly.
- Use branded types with readonly to avoid accidental mixing of mutable and immutable variants.
- Combine Readonly with opaque types or TypeScript's nominal typing patterns to surface intent in APIs.
- For performance-sensitive code, avoid deep freezing large objects at runtime; validate only the public boundary or use structural checks. If designing a library API, consider offering both a frozen runtime variant and a typed Readonly
variant to balance safety and performance.
Best Practices & Common Pitfalls
Dos:
- Use Readonly
on public API inputs/outputs to communicate no-mutation intent. - Return new objects instead of mutating inputs; prefer pure functions.
- Compose Readonly with Partial, Pick, and other utilities for precise contracts.
Don'ts:
- Don't assume Readonly
is deep — implement DeepReadonly when needed. - Avoid circumventing readonly via type assertions or any casts.
- Don't overuse deep freezing at runtime for large nested structures — consider strategic validation.
Troubleshooting:
- If TypeScript still allows a mutation, check for
as anyoras unknown ascasts in your codebase. - Watch out when interacting with libraries that mutate objects — copy inputs defensively.
- Use tsconfig strict options to surface unintended mutability early.
Real-World Applications
- Immutable DTOs: Return Readonly
for request/response DTOs in server code to prevent accidental mutation by business logic. See Typing API Request and Response Payloads with Strictness for patterns. - Redux/State Management: Use DeepReadonly for state types to model immutable Redux state shapes.
- Library APIs: Expose Readonly
in public methods to make your contract explicit and safer for consumers. If your library does input validation, integrate with libraries described in Using Zod or Yup for Runtime Validation with TypeScript Types. - Configuration Objects: Mark configuration objects readonly to ensure defaults aren't mutated at runtime; see patterns in Typing Configuration Objects in TypeScript: Strictness and Validation.
Conclusion & Next Steps
Readonly
For related topics, revisit utility types and explore generic programming patterns in Introduction to Utility Types: Transforming Existing Types and Generic Functions.
Enhanced FAQ
Q1: Does Readonly
A1: No. Readonly
Q2: How is Readonly
A2: Readonly
Q3: When should I use readonly fields in interfaces vs Readonly
A3: Use readonly fields directly when authoring the canonical type. Use Readonly
Q4: Can I convert a Readonly
A4: Not directly via the type system without a cast. You can create a mutable copy at runtime: const copy = { ...readonlyObj }; which yields a new object with writable properties. Type-level conversion would require a mapped Mutable
Q5: Is readonly the same as const?
A5: No. const applies to variables (bindings) and means the variable cannot be reassigned. readonly applies to object properties preventing reassignment of that property on the object. const does not make objects immutable — their properties are still mutable unless declared readonly at the type level or frozen at runtime.
Q6: How do I handle arrays with Readonly
A6: For arrays, use ReadonlyArray
Q7: Should I use Object.freeze to enforce immutability at runtime?
A7: Object.freeze offers runtime guarantees for shallow immutability. It can be useful at public boundaries if you want to prevent consumer mutation. But freeze can have performance implications on large objects and doesn't deeply freeze nested objects unless you recursively freeze them. For robust parsing and validation consider schema validators like Zod or Yup as described in Using Zod or Yup for Runtime Validation with TypeScript Types.
Q8: How does Readonly
A8: Readonly
Q9: What are common pitfalls when using Readonly
A9: Common pitfalls include assuming deep immutability, bypassing readonly with type assertions (as), and not accounting for library internals that mutate objects. Always prefer explicit copies over silent casts, and complement type-level readonly with runtime strategies where necessary.
Q10: How do I apply immutability to API payloads in a server or client?
A10: For API payloads, validate incoming data and then convert it into readonly shapes (either via types + runtime freezing or by returning copies with readonly types). Combining typed validation and readonly types helps ensure that downstream logic treats payloads as immutable — see Typing API Request and Response Payloads with Strictness for patterns and examples.
If you want practical exercises next, try converting a small module of mutable models in your codebase to readonly typed variants, implement DeepReadonly, and add a simple Zod schema to freeze/validate runtime data. For more advanced library typing patterns see Typing Libraries With Complex Generic Signatures — Practical Patterns and remember to balance developer ergonomics with safety.
