CodeFixesHub
    programming tutorial

    Readonly Properties in TypeScript Interfaces: A Practical, In-Depth Guide

    Learn how to use readonly properties in TypeScript to prevent bugs and enforce immutability. Practical examples, refactors, and next steps—start improving code now.

    article details

    Quick Overview

    TypeScript
    Category
    Aug 12
    Published
    20
    Min Read
    2K
    Words
    article summary

    Learn how to use readonly properties in TypeScript to prevent bugs and enforce immutability. Practical examples, refactors, and next steps—start improving code now.

    Readonly Properties in TypeScript Interfaces: A Practical, In-Depth Guide

    Introduction

    Mutable state is one of the most common sources of bugs in JavaScript applications. When multiple functions or modules can change an object's fields at any time, tracking invariants becomes harder, reasoning about code grows more expensive, and tests must cover more edge cases. TypeScript gives us tools to express intent about mutability—most importantly the readonly modifier on properties in interfaces (and types). Properly applied, readonly properties help you catch accidental mutations at compile time, create clearer APIs, and make your code safer by design.

    In this comprehensive tutorial you will learn what readonly does (and doesn't do), how it interacts with arrays and tuples, patterns for enforcing deep immutability, how to use readonly in APIs and function signatures, and pragmatic strategies for migrating existing code. You'll get practical code examples, refactor patterns, common pitfalls, and advanced techniques such as mapped types and utility types for generating readonly shapes.

    If you're familiar with basic TypeScript concepts like type annotations and function signatures, this guide will help you apply immutability in real-world code. For a refresher on adding types to variables, see our guide on type annotations in TypeScript, and if you want to pair readonly properties with strict function signatures, check out function parameter and return type annotations.

    What you'll walk away with:

    • A clear mental model for readonly semantics in TypeScript
    • Practical refactor steps to introduce readonly into existing interfaces
    • Techniques to enforce deep immutability and avoid accidental mutability
    • Tips to balance safety with ergonomics in APIs

    Let's get started.

    Background & Context

    TypeScript's type system is structural and compile-time only—types vanish at runtime. The readonly modifier exists purely to help developers and tooling prevent accidental writes to properties that should be immutable. When you declare a property readonly, TypeScript will issue an error if code tries to assign to that property. This works across interfaces, type aliases, class properties, and tuple/array types.

    Readonly properties are particularly useful in public-facing APIs, Redux-style state shapes, domain models, and anywhere you want to establish and document immutability guarantees. They complement TypeScript features like type inference; when you rely on inferred types it's still helpful to add readonly to convey intent and protect invariants. For more on how TypeScript infers types and when to explicitly annotate, see understanding type inference.

    Key Takeaways

    • readonly marks a property as non-assignable at compile time.
    • readonly is shallow by default — nested objects remain mutable unless you apply deep immutability patterns.
    • Use readonly with arrays and tuples (readonly arrays and readonly tuples) to prevent push/pop and element assignment.
    • Combine readonly with mapped types and utility types like Readonly and ReadonlyArray to generate immutable shapes.
    • Refactor incrementally to avoid breaking consumers: start with internal models, then public APIs.
    • Understand escape hatches (type assertions, as any) and avoid them unless necessary.

    Prerequisites & Setup

    This tutorial assumes intermediate knowledge of TypeScript and a working local development environment with Node.js and TypeScript installed. If you need to install TypeScript globally, run:

    bash
    npm install -g typescript
    # or locally in a project
    npm install --save-dev typescript

    To compile and test small examples use the TypeScript compiler (tsc). For detailed notes on compiling and tsconfig options, see our guide on compiling TypeScript with tsc.

    Editor support: use Visual Studio Code or any editor with TypeScript language support to see instant type errors for readonly violations. If you want to explore code snippets interactively, create a tsconfig.json with "noImplicitAny" and "strict" enabled to surface helpful errors early.

    Main Tutorial Sections

    ## What does readonly mean (shallow immutability)

    The readonly modifier on a property prevents assignment to that property. It's enforced statically by the compiler and does not change runtime behavior. Example:

    ts
    interface User {
      readonly id: string;
      name: string;
    }
    
    const u: User = { id: '123', name: 'Alice' };
    // u.id = '456'; // Error: Cannot assign to 'id' because it is a read-only property.
    u.name = 'Bob'; // Allowed

    Key point: readonly is shallow. If id were an object, its inner fields would still be mutable unless also declared readonly. Shallow readonly is often enough for simple APIs, but for nested structures you may need deeper patterns (see DeepReadonly section).

    ## readonly vs const: What's the difference?

    const applies to variables (binding immutability), while readonly applies to properties of types (structural immutability). Example:

    ts
    const obj = { x: 1 };
    obj.x = 2; // Allowed: const doesn't freeze object contents
    
    interface Point { readonly x: number }
    const p: Point = { x: 1 };
    // p.x = 2; // Error: readonly prevents assignment

    Use const for preventing rebinding of a variable, and readonly for preventing property mutation across values and references.

    ## Readonly properties on interfaces and type aliases

    You can add readonly directly to interface and type definitions.

    ts
    interface Config {
      readonly host: string;
      readonly port: number;
      timeout?: number; // optional and still can be readonly if marked
    }
    
    type DTO = Readonly<{ id: string; payload: any }>;

    TypeScript also provides a built-in mapped utility type Readonly that transforms every property into readonly. This is handy when you want to reuse a mutable type as an immutable view:

    ts
    type MutableUser = { id: string; name: string };
    type ImmutableUser = Readonly<MutableUser>;

    ## DeepReadonly patterns for nested objects

    Since readonly is shallow, nested objects remain mutable. For deep immutability you can write a recursive mapped type:

    ts
    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 State = {
      user: { id: string; profile: { bio: string } };
    };
    
    type ImmutableState = DeepReadonly<State>;

    This DeepReadonly preserves functions and converts arrays and objects recursively. Use this when you need strong guarantees (for example, a Redux store snapshot). Be cautious of performance in large types, and consider whether shallow readonly suffices.

    ## Readonly with arrays and tuples

    Arrays can be made readonly using ReadonlyArray or the shorthand readonly T[]:

    ts
    const nums: readonly number[] = [1, 2, 3];
    // nums.push(4); // Error
    // nums[0] = 10; // Error
    
    type Point = readonly [number, number];
    const p: Point = [0, 1];
    // p[0] = 2; // Error

    Tuples also accept readonly. If you need fixed-length, typed arrays, check out our guide on tuples in TypeScript and for common array typing scenarios see typing arrays.

    Readonly arrays are a powerful way to document and enforce immutability for collections returned from functions or stored in state—especially when you want to prevent consumers from mutating global arrays.

    ## Readonly in function parameters and return types

    Model immutable inputs and outputs explicitly in function signatures:

    ts
    interface Query { readonly q: string }
    
    function process(query: Query): void {
      // query.q = 'changed'; // Error
    }

    Using readonly in parameters documents intent and prevents callers and implementers from accidentally mutating the passed object. Combine readonly with accurate function type annotations for clarity—see our guide on function type annotations for patterns and examples.

    When returning arrays or objects, prefer readonly types if you don't intend for the caller to mutate them. Returning readonly types reduces risk of leaking internal mutation.

    ## Mapped types and utility types: Readonly, Pick, Omit

    Mapped types make it easy to produce readonly views of existing types. Built-ins include Readonly, ReadonlyArray, Pick<T, K>, and Omit<T, K>. You can combine them to create shapes tailored to APIs:

    ts
    interface FullModel { id: string; secret: string; publicData: string }
    
    type PublicView = Readonly<Pick<FullModel, 'id' | 'publicData'>>;
    
    // Now PublicView prevents assignment to id/publicData and hides secret

    For more advanced readonly transformations, you can craft custom mapped types (see DeepReadonly earlier). Mapped types are a powerful abstraction for DTOs and API boundaries.

    ## Mutability escape hatches: assertions, any, and unknown

    Sometimes you must opt out of readonly checks—escape hatches exist but should be used sparingly. The most common are type assertions and using any/unknown.

    ts
    interface R { readonly x: number }
    
    const r: R = { x: 1 };
    
    // Force mutation (not recommended)
    (r as any).x = 2;
    
    // Or via unknown
    (r as unknown as { x: number }).x = 3;

    If you find yourself using these frequently, reassess whether readonly should be applied in that context. For safer alternatives to any, review our explanation of the unknown type and when to use any.

    ## Practical refactoring: How to introduce readonly incrementally

    Refactoring a large codebase to use readonly lets you realize safety benefits without breaking consumers. Steps:

    1. Start with internal models where you control all callers.
    2. Convert types to Readonly or add readonly on interfaces used inside a module.
    3. Fix compile errors locally—these indicate real mutation hotspots.
    4. Add readonly to return types of functions producing stable data.
    5. For public APIs, introduce readonly in minor versions with compatibility tests.

    Example incremental change:

    ts
    // old
    type User = { id: string; roles: string[] };
    
    // new (start shallow)
    type UserImmutable = { readonly id: string; readonly roles: readonly string[] };

    Prioritize areas where accidental mutation is most harmful (state stores, cached values, shared singletons).

    Advanced Techniques

    Once you have the basics in hand, combine readonly with other TypeScript features to create robust patterns:

    • Use conditional and mapped types to build DeepReadonly selectively; e.g., make only certain branches deep-readonly while leaving others mutable.
    • Employ branded types alongside readonly to enforce domain-level invariants—create opaque types for IDs and mark them readonly to prevent accidental assignment.
    • Compose Readonly with utility types like Pick and Omit to publish safe DTOs and public views without leaking internals.
    • Use discriminated unions with readonly tag properties to guarantee exhaustive checks and avoid mutation of the discriminator.
    • If runtime immutability is required, combine readonly types with runtime freezes (Object.freeze) in factories—remember type and runtime are separate, so freeze adds runtime guarantees while readonly prevents compile-time writes.

    Example: combining Object.freeze with DeepReadonly factory:

    ts
    function freezeDeep<T>(obj: T): DeepReadonly<T> {
      // naive runtime freeze for example
      return Object.freeze(obj as any) as DeepReadonly<T>;
    }

    Use runtime freezing sparingly—freezing large graphs can be expensive and has semantic differences (frozen objects still allow non-configurable descriptors, etc.).

    Best Practices & Common Pitfalls

    Dos:

    • Use readonly on public API types and state objects to communicate and enforce immutability.
    • Prefer Readonly and ReadonlyArray for quick conversions.
    • Apply readonly to tuple element types when returning fixed-structure data.
    • Prefer explicit readonly types in function signatures to document intent.

    Don'ts and pitfalls:

    • Don’t assume readonly provides runtime immutability—it’s only a compile-time check unless you freeze objects.
    • Avoid over-applying DeepReadonly to huge types; it can slow down your type-checker and complicate error messages.
    • Beware of third-party libraries returning mutable objects—wrap or copy them when exposing them as readonly.
    • Don’t use type assertions like as any to bypass readonly checks routinely; treat exceptions as code smells.

    Troubleshooting tips:

    • When a readonly error appears, trace who mutates the object and decide whether mutation is valid or the API should be readonly.
    • If migrating gradually, convert internal modules first to reduce friction.
    • Use editor tooling (VS Code) to show where a property is defined and mutated.

    Real-World Applications

    Readonly properties shine in several practical scenarios:

    • State management: Represent Redux or other application state slices as readonly to avoid accidental updates outside reducers.
    • API clients: Return readonly DTOs from network layers so callers don’t accidentally mutate cached responses.
    • Libraries: Publish Readonly views of internal models to maintain invariants while allowing internal mutability for performance.
    • Concurrency-safe code: When using workers or shared-memory patterns, readonly types help document immutable payloads passed across threads.

    Example: A REST client returns a readonly array of items so UI code won't mutate the cached list:

    ts
    async function fetchItems(): Promise<readonly Item[]> {
      const res = await fetch('/items');
      const data: Item[] = await res.json();
      return data as readonly Item[];
    }

    For more on typing arrays and fixed-length arrays, check typing arrays and tuples.

    Conclusion & Next Steps

    Readonly properties are a lightweight, effective tool to increase code safety and self-documentation in TypeScript. Start small—add readonly to public interfaces and return types, then expand to deeper immutability where necessary. Combine readonly with mapped types and clear function signatures to create APIs that are easier to reason about and maintain.

    Next steps:

    • Audit critical parts of your code (state, caches, public APIs) and add readonly guards.
    • Explore DeepReadonly patterns if your domain demands nested immutability.
    • Pair readonly with good testing to verify runtime behavior where needed.

    To deepen your TypeScript knowledge, revisit guides on type inference and primitive types to ensure a strong foundation.

    Enhanced FAQ

    Q1: Does readonly make an object immutable at runtime?

    A1: No. readonly is a compile-time constraint enforced by TypeScript's type checker. At runtime, objects are plain JavaScript objects and can be mutated unless you use runtime mechanisms such as Object.freeze. Consider combining readonly types with runtime freeze if you need runtime immutability guarantees, but be aware of performance and semantics when freezing deep graphs.

    Q2: Is readonly transitive? If I mark the parent property readonly, are nested fields readonly too?

    A2: readonly is shallow by default. Marking a top-level property readonly prevents reassigning that property to a new value but does not make nested fields readonly. Use a DeepReadonly mapped type to recursively mark nested fields as readonly when you need deep immutability.

    Q3: When should I use Readonly vs marking properties readonly manually?

    A3: Use Readonly when you want a quick, global conversion of an existing type to readonly without modifying the original definition. Mark properties readonly manually if you need fine-grained control (some properties stay mutable, others don't). Readonly is great for building read-only views for APIs.

    Q4: How does readonly interact with classes?

    A4: Classes can have readonly properties as well. readonly on a class property prevents assignment after initialization (unless you use a type assertion). Example:

    ts
    class User {
      readonly id: string;
      constructor(id: string) { this.id = id; }
    }

    Once assigned (typically in constructor), the property cannot be changed. Note that runtime reassignment can still be forced via casting; the readonly constraint is a compile-time check.

    Q5: Can I make array elements readonly individually?

    A5: Yes. Use readonly tuples or ReadonlyArray / readonly T[] for collections. For tuples, you can declare readonly [T1, T2] to prevent assignment to elements. For arrays, readonly number[] removes mutating methods like push/pop and prevents index assignment.

    Q6: Are there performance implications to using DeepReadonly or large mapped types?

    A6: Yes. Very large, highly recursive mapped types can slow down TypeScript's type checker and produce complex error messages. Use DeepReadonly judiciously and prefer shallow readonly for many cases. Where deeper control is needed, make selective deep readonly transformations rather than blanket recursive conversions.

    Q7: How to migrate a large codebase to readonly without breaking everything?

    A7: Migrate incrementally: convert internal modules first, add readonly to return types to prevent downstream mutation, and reevaluate setters and mutating utilities. Run your test suite and fix real mutation sites. Use automatic codemods for repetitive changes but manually review public API surfaces. Start with non-breaking areas like DTOs returned by APIs.

    Q8: What are safe alternatives to bypassing readonly checks?

    A8: Avoid bypassing readonly checks with as any or as unknown as ... unless absolutely necessary. If mutation is required, consider returning a copy (e.g., using spread or slice for arrays) or redesigning the API to accept updates via pure functions (immutability-friendly APIs). If you must mutate for performance reasons, encapsulate the mutation into a confined module and keep the public API readonly.

    Q9: How do readonly properties relate to function parameters and defensive copying?

    A9: Marking function parameters readonly ensures the callee does not mutate caller-provided objects. Defensive copying (creating shallow or deep copies inside functions) is another strategy—use readonly for intent and copying when the callee must guard against caller-side reuse. If you return internal data, return readonly copies.

    Q10: Can readonly be combined with other utility types and mapped types in interesting ways?

    A10: Absolutely. Pair Readonly with Pick, Omit, Partial, Required, and custom mapped types to craft precise API shapes. For example, you might publish a Readonly<Pick<Model, 'id' | 'name'>> as a public DTO while keeping the internal Model mutable. This combination is common in libraries that want internal mutability for performance but provide immutable public views.


    If you want to dive deeper into related TypeScript topics that complement readonly usage, check out our articles on the unknown type for safer alternatives to any, when to use any, and best practices for primitive types. These will help you build a strong foundation for safe, maintainable TypeScript code.

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:20:15 PM
    Next sync: 60s
    Loading CodeFixesHub...