TypeScript readonly Modifiers Explained
TypeScript's readonly keyword blocks reassignment at compile time for properties, arrays, and tuples — with no runtime enforcement at all.
TypeScript’s readonly modifier marks a property, array, or tuple element so the compiler flags any attempt to reassign it after initial assignment. It’s a compile-time-only guarantee — there’s no runtime check, no frozen object, nothing stopping the reassignment in compiled JavaScript. What it buys you is the compiler catching accidental mutation before your code ever runs.
readonly on object properties
The most common use is on interface or class properties, marking data that should be set once and never changed:
interface User {
readonly id: string;
name: string;
}
function rename(user: User) {
user.id = "new-id"; // Error: Cannot assign to 'id' because it is a read-only property.
user.name = "Alice"; // fine
}
This is purely a type-checking feature. Nothing in the compiled JavaScript prevents user.id = "new-id" from running if the check is bypassed — through a type assertion, any, or code from outside TypeScript entirely. readonly documents and enforces intent within the type system; it isn’t a security or immutability guarantee at runtime the way Object.freeze() is.
ReadonlyArray and readonly arrays
Arrays get a dedicated utility type, ReadonlyArray<T>, along with a shorthand syntax:
const ids: ReadonlyArray<string> = ["a", "b", "c"];
const tags: readonly string[] = ["x", "y"];
ids.push("d"); // Error: Property 'push' does not exist on type 'readonly string[]'.
ids[0] = "z"; // Error: Index signature in type 'readonly string[]' only permits reading.
A readonly array type removes every mutating method — push, pop, splice, sort, and the rest — from the type entirely, so the compiler catches the mistake at the call site rather than only blocking direct index assignment. This is useful for function parameters: declaring a parameter as readonly T[] tells callers (and the compiler) that the function won’t mutate the array they pass in, which is a much stronger contract than a doc comment saying the same thing.
readonly tuples
Tuples support the same modifier, which matters because tuple types otherwise still expose array mutation methods:
type Point = readonly [number, number];
const origin: Point = [0, 0];
origin[0] = 5; // Error
This combines well with functions that return fixed-shape data — coordinates, RGB triples, key-value pairs — where the position and length are meaningful and shouldn’t be mutated after the fact.
Const assertions: as const
A related but distinct feature is the as const assertion, which infers the narrowest possible type for a literal and makes everything in it readonly recursively:
const config = {
mode: "production",
retries: 3,
} as const;
// type is { readonly mode: "production"; readonly retries: 3 }
Without as const, config.mode would be inferred as the wider string type, and the object’s properties would be mutable. as const is the fastest way to get a fully readonly, precisely-typed literal without writing out readonly on every property by hand — but it only applies at the point of the literal; it doesn’t retroactively make an existing interface readonly.
readonly vs Readonly
TypeScript also provides Readonly<T>, a built-in utility type that maps every property of an existing type to its readonly equivalent:
interface Config {
host: string;
port: number;
}
type FrozenConfig = Readonly<Config>;
// { readonly host: string; readonly port: number }
This is the tool to reach for when you want a readonly version of a type you don’t control or don’t want to rewrite by hand — wrapping it is simpler than duplicating every field with readonly prefixed.
readonly vs Object.freeze
It’s worth being explicit about the boundary between compile-time and runtime here, since the two are easy to conflate:
readonly (TypeScript) | Object.freeze() (JavaScript) | |
|---|---|---|
| Enforced by | The compiler, at type-check time | The JS engine, at runtime |
| Survives to compiled output | No — erased entirely | Yes — it’s a runtime call |
Blocks reassignment via any / assertions | No | Yes |
| Deep or shallow | Shallow by default (nested objects are still mutable unless also typed readonly) | Shallow (nested objects are still mutable unless also frozen) |
| Cost | None — purely a type annotation | Small runtime overhead |
The two aren’t mutually exclusive. A common, defensive pattern for data that genuinely must not change — a shared config object, a constants module — is to type it as Readonly<T> for compile-time safety and wrap the value in Object.freeze() for runtime enforcement, covering both the code your team writes with TypeScript checking and any code path that might bypass the type system.
The takeaway
readonly gives TypeScript’s compiler a way to catch accidental reassignment of properties, array elements, and tuple positions before code ever runs, and it composes with as const and Readonly<T> to make whole literals or types immutable in the type system with minimal boilerplate. It’s a documentation and correctness tool, not a runtime guarantee — if you need actual immutability against code that bypasses the type checker, pair it with Object.freeze().
Keep reading
Takina · · 4 min read TypeScript Abstract Classes, Explained
Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.
Takina · · 3 min read TypeScript's never Type, Explained
never represents values that can't exist — it marks unreachable code, exhaustive switches, and functions that always throw or loop forever.
Takina · · 4 min read TypeScript's `as const`, Explained
TypeScript's as const assertion locks a value to its literal, readonly type instead of widening it. How it works and when to reach for it.