Articles

TypeScript readonly Modifiers Explained

TypeScript's readonly keyword blocks reassignment at compile time for properties, arrays, and tuples — with no runtime enforcement at all.

Takina Takina · · 4 min read
A code editor showing TypeScript source in a dark theme

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 byThe compiler, at type-check timeThe JS engine, at runtime
Survives to compiled outputNo — erased entirelyYes — it’s a runtime call
Blocks reassignment via any / assertionsNoYes
Deep or shallowShallow by default (nested objects are still mutable unless also typed readonly)Shallow (nested objects are still mutable unless also frozen)
CostNone — purely a type annotationSmall 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().

Takina 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.

#TypeScript #JavaScript #Web Development
Takina 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.

#TypeScript #JavaScript #Web Development
Takina 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.

#TypeScript #JavaScript #Web Development