Readonly everywhere
How readonly stops accidental writes — and where it stops being deep.
A function takes a config object and quietly sets a default on it. Two screens later, the config — the one the caller passed in — has the default the function added. Now another part of the app, holding a reference to the same object, sees the change and renders something unexpected. The bug isn't logic. It's that a value got mutated when the caller assumed it was theirs to keep.
Readonly fields
The simplest defensive move is to mark a field readonly. A readonly property compiles to nothing at runtime — JavaScript itself can't enforce it — but the compiler refuses any code that tries to assign to it after construction.
type User = {
readonly id: string;
readonly email: string;
displayName: string;
};
function rename(user: User, name: string) {
user.displayName = name; /* fine, displayName is mutable */
user.email = name; /* error: cannot assign to read-only property */
}id and email are locked once the object is built. displayName stays mutable because the design says renaming is allowed. The split is per-field. The point is to write down which fields are meant to change and let the compiler hold the line on the rest.
Readonly<T> is a built-in helper that flips every top-level property of T to readonly in one step. It's useful for parameters where you want to promise the caller you won't write to anything.
function summarize(user: Readonly<User>): string {
return user.displayName + " <" + user.email + ">";
}Inside summarize, every property of user is read-only — even displayName, which was mutable on the original type. The function is signaling, in its signature, that it's a pure reader. A future change that adds user.displayName = "..." won't compile. The signature has become a small contract.
Readonly arrays
Arrays come in two flavors in TypeScript. string[] is the mutable kind — push, pop, splice, index assignment all work. readonly string[] (or its longer name ReadonlyArray<string>) is the locked kind. The shape is the same; the methods that would change the array in place are gone from the type.
function sum(numbers: readonly number[]): number {
let total = 0;
for (const n of numbers) total += n;
numbers.push(0); /* error: push does not exist on readonly number[] */
return total;
}for...of still works because it only reads. numbers.push(0) doesn't compile because push is a mutator and isn't on readonly number[]. If sum were given number[], nothing would stop it from mutating the caller's array as a side effect — which is the kind of thing that wrecks debugging.
A readonly number[] accepts a regular number[] everywhere it's needed (one-way assignability — you can promise less than you have). It does not accept a readonly number[] as a number[], which is exactly right.
Default to readonly array parameters. The function gets a stronger contract; callers lose nothing, because number[] is assignable to readonly number[]. Drop the readonly only when the function's job is to mutate.
readonly string[]. A caller passes a normal string[]. What happens?as const
When you write a literal like [1, 2, 3] or { kind: "circle" }, TypeScript widens it to number[] and { kind: string } by default — the compiler assumes you might change it. The as const suffix tells the compiler the opposite: this literal is the exact value, immutable, with the narrowest possible type.
const theme = {
primary: "#C96442",
surface: "#F4E9D5",
} as const;
/* theme is now: { readonly primary: "#C96442"; readonly surface: "#F4E9D5" } */
const sizes = [12, 14, 16] as const;
/* sizes is now: readonly [12, 14, 16] */Two things happened. The values got narrowed from string and number to the specific literals ("#C96442", 12). The properties (and the array elements) got marked readonly. So theme.primary is the literal "#C96442", not just any string — useful when you want to constrain a parameter to this set of theme keys, like keyof typeof theme.
as const is the cheapest way to lock a config object or a list of allowed values. No type alias, no Readonly<T>, no helper. Just three characters at the end of the literal.
Shallow vs deep
readonly and Readonly<T> are shallow. They protect the top level of an object — the immediate properties — but not anything those properties point to. If a readonly property holds an object, the reference is locked, but the object on the other end is still mutable.
type Config = Readonly<{
flags: { darkMode: boolean };
}>;
declare const config: Config;
config.flags = { darkMode: true }; /* error: flags is readonly */
config.flags.darkMode = true; /* fine — the inner object isn't readonly */The first assignment is rejected because flags itself is read-only. The second assignment is allowed because the inner object's darkMode is just a regular property. Readonly<T> only walked one level.
A truly deep readonly needs a recursive helper. You can write one yourself, or reach for a utility from a library, but the standard library doesn't ship one. For most code the shallow version plus discipline is enough — the benefit of deep readonly diminishes once the shape gets complex.
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type Locked = DeepReadonly<Config>;
/* now config.flags.darkMode = true is also a compile error */The mapped type walks every property. If the property's type is itself an object, it recurses. The output type has readonly everywhere, all the way down. Useful for state snapshots in reducers, for configs you want to truly seal, and for the kind of API surface where one mutation could cause a hard-to-find bug.
const settings = { a: 1, b: { c: 2 } } as const;. Which of these compiles?Types vs Object.freeze
Object.freeze is a runtime function that makes an object's own properties non-writable. It's a different beast from readonly and the two often disagree.
readonly is compile-time only. It prevents writes during type checking. At runtime, the object is plain JavaScript — anyone can mutate it through any reference that doesn't have the readonly type.
Object.freeze is runtime only. It throws (in strict mode) or silently fails when you try to write. The TypeScript type after Object.freeze(x) is Readonly<T>, but the freeze is shallow — same depth limit as the type system.
const seal = Object.freeze({ a: 1, b: { c: 2 } });
seal.a = 99; /* compile error AND runtime error in strict mode */
seal.b.c = 99; /* compile error caught, but the inner object isn't frozen */The seal.b.c = 99 line is interesting. The type system stops it because Object.freeze returned Readonly<...> — b is a readonly reference. But if you cast away the type, the runtime allows the inner write because freeze didn't recurse. Belt-and-braces deep immutability needs both: a recursive type and a recursive freeze. For most app code, either one is enough discipline; the redundancy isn't free in code complexity.
readonly doesn't ship to production as a check. If untyped JavaScript or a sneaky cast bypasses the type, the value is fully mutable. Treat readonly as authorial intent backed by the compiler, not as a runtime lock.
readonly on a property and calling Object.freeze on the object?