TypeScript Readonly

Readonly<T> is a built-in TypeScript utility type that takes an object type T and produces a new type where every property is marked readonly. Once a value has that type, the compiler blocks any attempt to reassign one of its properties after creation. It is a compile-time-only guarantee: nothing about it changes how your code runs, only what the type checker will let you write. It’s the tool you reach for whenever you want to promise “this object will not be mutated” — for config objects, function parameters that shouldn’t be touched, or state you want to protect from accidental writes.

Overview / How it works

Readonly<T> is defined as a mapped type in TypeScript’s standard library (lib.es5.d.ts). A mapped type iterates over the keys of an existing type and builds a new type by applying a transformation to each property. Conceptually, its definition looks like this:

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P];
};

Read it as: “for every property key P in keyof T, keep the same value type T[P], but add the readonly modifier.” The result is a brand-new type shape with identical property names and value types as T, but with every property locked against reassignment through that type.

Because TypeScript uses structural typing, Readonly<T> does not create a different runtime object or a subclass — it only changes what the compiler will allow. A plain, fully mutable object literal that matches the shape of T can be assigned directly to a variable typed as Readonly<T>, because structurally it has all the right properties. TypeScript’s readonly modifier is a one-way street for assignability: a mutable type is assignable to its readonly counterpart, but not the reverse (you can’t pass a Readonly<Point> somewhere that requires a mutable Point, since that call site might try to write to it).

It’s important to understand that readonly in TypeScript is a type-level restriction, not a runtime one. All type annotations, including Readonly<T>, are erased by the compiler — the JavaScript that comes out the other end has no idea any property was ever “readonly.” If you want actual runtime immutability (an object that throws or silently ignores writes in plain JavaScript), you need Object.freeze() as well, which is a real JavaScript runtime API. TypeScript’s typings for Object.freeze conveniently return a Readonly<T>-typed result, which is why the two pair so well together.

Also worth knowing up front: Readonly<T> is shallow. It only applies the readonly modifier to T‘s own top-level properties. If one of those properties is itself an object, that nested object’s properties remain fully mutable. This surprises a lot of developers, and we’ll dig into it in the Common Mistakes section below.

Syntax

Readonly<T>
Part Meaning
Readonly The built-in generic utility type, available globally with no import needed.
T Any object type — an interface, a type alias for an object shape, or an inline object type.
Result A new object type with the same property names and value types as T, but every property additionally marked readonly.

You can use Readonly<T> anywhere a type is expected: as a variable’s type annotation, a function parameter type, a function return type, or nested inside another type.

Examples

Example 1: A basic readonly object

interface Point {
  x: number;
  y: number;
}

const origin: Readonly<Point> = { x: 0, y: 0 };

console.log(origin.x, origin.y);

Output:

0 0

Here origin is declared with type Readonly<Point>, so both x and y can be read freely but never reassigned through the origin variable. Creating the object with an object literal still works fine — readonly only restricts assignment after initialization, not the initial construction.

Example 2: Using Readonly<T> as a function parameter type

interface User {
  id: number;
  name: string;
  email: string;
}

function printUser(user: Readonly<User>): void {
  console.log(`User #${user.id}: ${user.name} <${user.email}>`);
}

const user: User = { id: 1, name: "Ada Lovelace", email: "ada@example.com" };
printUser(user);

Output:

User #1: Ada Lovelace <ada@example.com>

This is one of the most common real-world uses of Readonly<T>: documenting, at the type level, that a function only reads from the object it’s given and will never mutate it. Notice that a plain, mutable User can be passed in directly — because of structural typing, a type with fewer restrictions (mutable) is assignable to one with more restrictions (readonly). Inside printUser, though, the parameter user is treated as fully readonly, so any attempt to write to user.name inside that function body would be a compile error.

Example 3: Combining Readonly<T> with Object.freeze for real immutability

interface Config {
  apiUrl: string;
  timeoutMs: number;
  retries: number;
}

function createConfig(overrides: Partial<Config> = {}): Readonly<Config> {
  const defaults: Config = { apiUrl: "https://api.example.com", timeoutMs: 5000, retries: 3 };
  const merged: Config = { ...defaults, ...overrides };
  return Object.freeze(merged);
}

const config = createConfig({ retries: 5 });
console.log(config.apiUrl, config.timeoutMs, config.retries);

Output:

https://api.example.com 5000 5

This factory function merges defaults with caller-supplied overrides and returns the result wrapped in Object.freeze. TypeScript’s typings for Object.freeze return Readonly<T>, so the return type lines up naturally with the function’s declared Readonly<Config> signature. Because this uses Object.freeze, the immutability here is enforced at runtime too, not just by the type checker — attempting to write to a frozen object’s property silently fails (or throws in strict mode JavaScript).

Under the hood

interface Point {
  x: number;
  y: number;
}

const frozenPoint: Readonly<Point> = { x: 3, y: 4 };

console.log(JSON.stringify(frozenPoint));

Output:

{"x":3,"y":4}

Step by step, here’s what happens when the compiler processes Readonly<Point>:

  • TypeScript looks up the built-in Readonly<T> mapped type and substitutes T with Point.
  • It iterates keyof Point (which is "x" | "y") and builds a new anonymous type: { readonly x: number; readonly y: number }.
  • When you write frozenPoint.x = 10 anywhere, the checker looks up the property’s modifiers on that computed type, sees readonly, and reports an error — this all happens purely during type checking.
  • When the TypeScript compiler emits JavaScript, all type annotations are stripped. The emitted code is just const frozenPoint = { x: 3, y: 4 }; — there is no readonly concept left in the output at all. JSON.stringify proves this: it sees a completely ordinary JavaScript object.

This is why Readonly<T> alone gives you zero runtime protection — a JavaScript consumer of your compiled code (or a call to your function that bypasses the type checker, e.g. via as any) can still mutate the object freely. If you need the guarantee to hold at runtime as well, reach for Object.freeze() as shown in Example 3.

Common Mistakes

Mistake 1: Trying to reassign a property on a Readonly<T> value

The most immediate mistake is simply forgetting that readonly blocks direct assignment:

interface Point {
  x: number;
  y: number;
}

const p: Readonly<Point> = { x: 1, y: 2 };
p.x = 10;

This fails to compile with an error along the lines of error TS2540: Cannot assign to 'x' because it is a read-only property. The fix is to build a new object instead of mutating the old one — typically with the spread operator:

interface Point {
  x: number;
  y: number;
}

const p: Readonly<Point> = { x: 1, y: 2 };
const moved: Readonly<Point> = { ...p, x: 10 };

console.log(moved.x, moved.y);

Output:

10 2

Mistake 2: Assuming Readonly<T> is deep (recursive)

This is the more dangerous mistake, because it fails silently rather than as a compile error. Many developers expect Readonly<T> to lock down an entire object tree, but it only applies one level deep:

interface Address {
  city: string;
}

interface Person {
  name: string;
  address: Address;
}

const person: Readonly<Person> = { name: "Grace", address: { city: "NYC" } };

person.address.city = "Boston";

console.log(person.address.city);

Output:

Boston

This compiles and runs without complaint! person.address itself cannot be replaced (person.address = {...} would error), but nothing stops you from reaching into it and mutating city, because Address.city was never wrapped in readonly. If you actually need deep immutability, write (or install from a utility library) a recursive mapped type:

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

interface Address {
  city: string;
}

interface Person {
  name: string;
  address: Address;
}

const person2: DeepReadonly<Person> = { name: "Grace", address: { city: "NYC" } };

console.log(person2.address.city);

Output:

NYC

DeepReadonly<T> checks, for every property, whether its value type extends object; if so it recurses into DeepReadonly again, otherwise it leaves the primitive type alone. With person2 typed this way, the same nested mutation from before is now correctly rejected:

person2.address.city = "Boston";

That line now produces error TS2540: Cannot assign to 'city' because it is a read-only property. — exactly the protection the shallow Readonly<T> failed to give us.

Best Practices

  • Use Readonly<T> on function parameters to document (and enforce) that the function will not mutate the object it receives — it’s cheap, self-documenting, and catches accidental mutation bugs early.
  • Remember Readonly<T> is shallow. For nested objects, either wrap each level explicitly, apply a recursive DeepReadonly<T> helper, or restructure the data to avoid deep nesting where immutability really matters.
  • Pair Readonly<T> with Object.freeze() when you need the guarantee to hold at runtime too, not just at compile time — this matters especially for values shared with plain-JavaScript code or third-party libraries.
  • Prefer returning Readonly<T> from factory functions that build configuration or constant-like objects, signaling to every caller that the result shouldn’t be mutated.
  • Don’t use Readonly<T> as a substitute for validating your data — it stops accidental reassignment, not invalid values being assigned in the first place.
  • Avoid sprinkling readonly everywhere reflexively; reserve it for values whose immutability is actually part of your design (constants, DTOs passed across boundaries, frozen config), so its presence stays meaningful.

Practice Exercises

  • Define an interface Rectangle with width and height number properties. Write a function area that accepts a Readonly<Rectangle> and returns the computed area without ever modifying the parameter. Call it with a plain, mutable Rectangle object and confirm it still compiles.
  • Given interface Settings { theme: string; layout: { columns: number; sidebar: boolean } }, create a value typed as Readonly<Settings> and try to mutate layout.columns. Confirm it compiles (demonstrating the shallow gotcha), then write your own DeepReadonly<T> type and redo the example so that mutation is now rejected by the compiler.
  • Write a function withUpdatedName(user: Readonly<User>, name: string): Readonly<User> that returns a new user object with the name changed, without mutating the original. Log both the original and the updated object to confirm the original is unchanged.

Summary

  • Readonly<T> is a built-in mapped utility type that marks every property of T as readonly, blocking reassignment through that type.
  • It’s a compile-time-only guarantee — all type information, including readonly, is erased when TypeScript compiles to JavaScript.
  • A mutable object is assignable to its Readonly<T> counterpart because of structural typing, but not vice versa.
  • Readonly<T> is shallow: nested object properties remain mutable unless you apply a recursive DeepReadonly<T> type.
  • Combine Readonly<T> with Object.freeze() when you need immutability enforced at runtime, not just by the type checker.
  • It’s most valuable on function parameters and factory-function return types, where it documents and enforces a “do not mutate” contract.