Read-only
readonly is a compile-time check on one member: you may read it, you may not assign to it. That is the whole of what the keyword does, and two consequences surprise almost everyone. It does not travel, because a value whose type says readonly is freely assignable to a parameter whose type does not, and nothing warns, so readonly protects the code you wrote it in rather than the value. And the collections work differently, because ReadonlyArray<T>, ReadonlySet<T>, and ReadonlyMap<K, V> are not their mutable versions with a flag set but smaller interfaces, missing the methods that change things, and that difference is what makes them behave the way the keyword does not.
None of it survives to run time, the fact the values and references page pinned in readonly exists only at compile time. Object.freeze is the one thing here that does, and it turns out to change the type as well.
Create programs/read-only.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
const is not readonly
Deno.test("const is not readonly", () => {
type Counter = { count: number };
const counter: Counter = { count: 1 };
counter.count = 2;
assertStrictEquals(counter.count, 2);
assertThrows(
// deno-lint-ignore no-eval
() => eval("counter = { count: 3 }"),
TypeError,
"Assignment to constant variable",
);
});
Check programs/read-only.test.ts
running 1 test from ./programs/read-only.test.ts
const is not readonly ... ok (397µs)
ok | 1 passed | 0 failed (1ms)
const and readonly answer different questions. const says the binding will not be pointed at something else, which is JavaScript's rule and throws at run time, as the eval proves; the object the binding points at mutates freely. readonly says a member will not be assigned to, which is TypeScript's rule and throws nothing. The eval is there because a literal reassignment would not compile, and the lint ignore above it is the price of asking the runtime instead of the checker.
readonly is a check, not a lock
Mark the member readonly and write to it:
Deno.test("readonly is a check, not a lock", () => {
type ReadonlyCounter = { readonly count: number };
const counter: ReadonlyCounter = { count: 1 };
counter.count = 2;
assertStrictEquals(counter.count, 2);
});
Check programs/read-only.test.ts
TS2540 [ERROR]: Cannot assign to 'count' because it is a read-only property.
counter.count = 2;
~~~~~
at file:///programs/read-only.test.ts:29:11
error: Type checking failed.
That is the whole keyword: TS2540 at the write site, and nothing else anywhere. This page needs to run the forbidden writes anyway, to measure what the types are worth at run time, and the tool for that is // @ts-expect-error, a comment telling the checker the next line's error is expected, which turns the refusal into documentation. The page calls it a shield. Shield the write and watch it land:
Deno.test("readonly is a check, not a lock", () => {
type ReadonlyCounter = { readonly count: number };
const counter: ReadonlyCounter = { count: 1 };
// @ts-expect-error: Cannot assign to 'count' because it is a read-only property.
counter.count = 2;
assertStrictEquals(counter.count, 2);
});
readonly is a check, not a lock ... ok (20µs)
The write happened anyway. Worth seeing once, because everything below is a variation on it.
Readonly<T> does every member, including methods
Deno.test("Readonly<T> does every member, including methods", () => {
type Point = { x: number; y: number; dist(): number };
const point: Readonly<Point> = { x: 1, y: 2, dist: () => 3 };
// @ts-expect-error: Cannot assign to 'x' because it is a read-only property.
point.x = 9;
// @ts-expect-error: Cannot assign to 'dist' because it is a read-only property.
point.dist = () => 4;
assertStrictEquals(point.x, 9);
assertStrictEquals(point.dist(), 4);
class Frozen {
readonly created: number;
constructor(created: number) {
this.created = created;
}
}
const instance = new Frozen(0);
// @ts-expect-error: Cannot assign to 'created' because it is a read-only property.
instance.created = 1;
assertStrictEquals(instance.created, 1);
});
Readonly<T> does every member, including methods ... ok (53µs)
Readonly<T> is a mapped type that adds the modifier to every property of T, so a method-shaped property becomes read-only too: dist(): number becomes readonly dist: () => number, and the shielded reassignment of dist lands like any other. A class field takes the same keyword and behaves the same way, which the typing classes page covers alongside the rest of what a class body can say.
readonly does not travel, in either direction
declaredReadonly is typed read-only and handed to a function that increments. Predict whether it compiles, and what it returns if it does:
Deno.test("readonly does not travel, in either direction", () => {
type Counter = { count: number };
type ReadonlyCounter = { readonly count: number };
function takesMutable(counter: Counter): number {
counter.count++;
return counter.count;
}
function takesReadonly(counter: ReadonlyCounter): number {
return counter.count;
}
const mutable: Counter = { count: 1 };
const view: ReadonlyCounter = mutable;
mutable.count = 2;
assertStrictEquals(view.count, 2);
const declaredReadonly: ReadonlyCounter = { count: 10 };
assertStrictEquals(takesMutable(declaredReadonly), 10);
assertStrictEquals(takesReadonly(mutable), 2);
});
Check programs/read-only.test.ts
running 4 tests from ./programs/read-only.test.ts
...
readonly does not travel, in either direction ... FAILED (8ms)
ERRORS
readonly does not travel, in either direction => ./programs/read-only.test.ts:63:6
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- 11
+ 10
FAILURES
readonly does not travel, in either direction => ./programs/read-only.test.ts:63:6
FAILED | 3 passed | 1 failed (9ms)
error: Test failed
It compiled, and it incremented. Two facts in one test, and both are worth sitting with. A read-only view of a mutable object is a window, not a lock: view and mutable are the same object, so writing through the name that permits it changes what the other name reads. And passing a read-only value into a mutable parameter is allowed, with no cast, no shield, and no warning, because assignability compares members and a write restriction is not a member: {readonly count: number} and {count: number} have the same property with the same type, so each is assignable to the other. The modifier was checked where it was written and forgotten at the call. There is an open proposal for an enforceReadonly compiler option that would change this; until it exists, treat readonly on a property as documentation plus a local check, and do not expect it to hold at a boundary. Correct the prediction to 11:
readonly does not travel, in either direction ... ok (32µs)
a read-only array is a smaller interface
Deno.test("a read-only array is a smaller interface", () => {
const names: readonly string[] = ["Hedy", "Robin"];
assertEquals(names.map((name) => name.length), [4, 5]);
assertEquals(names.toSorted(), ["Hedy", "Robin"]);
// @ts-expect-error: Property 'push' does not exist on type 'readonly string[]'.
names.push("Sam");
// @ts-expect-error: Index signature in type 'readonly string[]' only permits reading.
names[0] = "Sam";
assertEquals(names, ["Sam", "Robin", "Sam"]);
});
a read-only array is a smaller interface ... ok (482µs)
Reading is untouched, including the copying methods, since toSorted, toReversed, with, and toSpliced return new arrays and so belong on the read-only interface, where sort, reverse, and splice do not, the split the mutating arrays page sorted into two groups and then captured as eleven refusals against this exact type. Look at the codes in that capture with this page's eyes and there are three mechanisms rather than one: TS2339 for push, a member that does not exist, because ReadonlyArray<T> is declared without it; TS2542 for the index write, a read-only index signature; and TS2540 for length, an ordinary read-only property. ReadonlyArray<string> and readonly string[] are the same type in two notations. And the two shielded writes landed, as they now always do.
the tax runs one way
Because a read-only array is a smaller interface, one direction really is checked, unlike the property case. Hand an as const array to a mutable parameter:
Deno.test("the tax runs one way", () => {
function sumMutable(values: number[]): number {
return values.reduce((a, b) => a + b, 0);
}
const frozen = [1, 2, 3] as const;
assertStrictEquals(sumMutable(frozen), 6);
});
Check programs/read-only.test.ts
TS2345 [ERROR]: Argument of type 'readonly [1, 2, 3]' is not assignable to parameter of type 'number[]'.
The type 'readonly [1, 2, 3]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
assertStrictEquals(sumMutable(frozen), 6);
~~~~~~
at file:///programs/read-only.test.ts:110:33
error: Type checking failed.
A readonly number[] has no push, so it cannot stand in for a number[] that might call one, and this is the cost of the type: hand a read-only array to any of the enormous quantity of code typed Array<T> and this error is yours. The recommendation follows. Type your array parameters readonly, because it costs nothing, accepts a mutable array as well as an as const one, and keeps you off the wrong side of the error, as sumReadonly shows by taking all comers; the same goes for generic constraints, where T extends readonly unknown[] accepts what T extends unknown[] refuses. Taking a mutable array when you only read from it is the mistake:
Deno.test("the tax runs one way", () => {
function sumReadonly(values: readonly number[]): number {
return values.reduce((a, b) => a + b, 0);
}
function sumMutable(values: number[]): number {
return values.reduce((a, b) => a + b, 0);
}
const mutable = [1, 2, 3];
const frozen = [1, 2, 3] as const;
assertStrictEquals(sumReadonly(mutable), 6);
assertStrictEquals(sumReadonly(frozen), 6);
assertStrictEquals(sumMutable(mutable), 6);
// @ts-expect-error: The type 'readonly [1, 2, 3]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
assertStrictEquals(sumMutable(frozen), 6);
});
the tax runs one way ... ok (46µs)
a mutable tuple has an odd relationship with its length
Deno.test("a mutable tuple has an odd relationship with its length", () => {
const pair: [string, number] = ["a", 1];
pair[0] = "b";
pair.length = 2;
pair.push("extra");
assertEquals(pair as unknown[], ["b", 1, "extra"]);
// @ts-expect-error: Type '1' is not assignable to type '2'.
pair.length = 1;
assertEquals(pair as unknown[], ["b"]);
const frozen: readonly [string, number] = ["a", 1];
// @ts-expect-error: Cannot assign to '0' because it is a read-only property.
frozen[0] = "b";
// @ts-expect-error: Property 'push' does not exist on type 'readonly [string, number]'.
frozen.push("extra");
assertEquals(frozen as readonly unknown[], ["b", 1, "extra"]);
});
a mutable tuple has an odd relationship with its length ... ok (40µs)
A mutable tuple's length is the literal type 2 rather than number, so assigning 2 to it is fine and assigning 1 is a shielded type error. Meanwhile push is allowed, because a tuple inherits every array method, the fact the arrays page measured in a tuple can grow, and you may not read what grew, so you may lengthen a fixed-length type and may not shorten it. The shielded lines show what the guarantees are worth at run time: length = 1 truncates the array to ["b"], and both writes to the read-only tuple land. A read-only tuple closes all three doors at the type level, and it is what as const gives you.
as const does three things at once
Deno.test("as const does three things at once", () => {
const table = { small: 1, large: 2 } as const;
const list = ["igneous", "metamorphic"] as const;
const exact: 1 = table.small;
const first: "igneous" = list[0];
// @ts-expect-error: Cannot assign to 'small' because it is a read-only property.
table.small = 9;
// @ts-expect-error: Property 'push' does not exist on type 'readonly ["igneous", "metamorphic"]'.
list.push("sedimentary");
assertStrictEquals(exact, 1);
assertStrictEquals(first, "igneous");
assertStrictEquals(table.small as number, 9);
assertStrictEquals(list.length, 3);
let pair = [1, 2] as const;
pair = [1, 2];
// @ts-expect-error: Type '3' is not assignable to type '2'.
pair = [1, 3];
assertEquals(pair as readonly number[], [1, 3]);
});
as const does three things at once ... ok (32µs)
Three effects, worth naming separately. Every member becomes read-only: an object's properties, an array's elements and length. An array literal becomes a tuple, so list is readonly ["igneous", "metamorphic"] rather than string[], with the length known and each position its own type. And types narrow to literals, 1 rather than number, the effect the unions and narrowing page uses to stop widening. The first pitfall is the previous step's tax, since an as const value will not go into a mutable parameter. The second is the narrowness itself: pair has the type readonly [1, 2], a set with one member, so a let declared this way can only ever be reassigned the exact value it started with, which is almost never what somebody wanted. as const belongs on a const.
Deno's linter has one rule in this area, about reaching for the assertion rather than an annotation. Put this in a scratch file programs/as-const.ts and lint it:
let size: 2 = 2;
size = 2;
console.log(size);
error[prefer-as-const]: Expected a `const` assertion instead of a literal type annotation
--> programs/as-const.ts:1:11
|
1 | let size: 2 = 2;
| ^
= hint: Remove a literal type annotation and add `as const`
docs: https://docs.deno.com/lint/rules/prefer-as-const
Found 1 problem
Checked 1 file
Good advice for the annotation, and it does not stop you from writing the let, so the warning above still applies. Delete the scratch file.
Object.freeze narrows the type as well as locking the value
Every shield so far has been followed by the write landing. Object.freeze is the one tool on this page that exists at run time, so predict the frozen array after a shielded push:
Deno.test("Object.freeze narrows the type as well as locking the value", () => {
const frozenObject = Object.freeze({ x: 1, mode: "fast" });
const frozenArray = Object.freeze(["a", "b"]);
const exact: 1 = frozenObject.x;
const mode: "fast" = frozenObject.mode;
assertStrictEquals(exact, 1);
assertStrictEquals(mode, "fast");
const loose = { x: 1, mode: "fast" };
// @ts-expect-error: Type 'number' is not assignable to type '1'.
const widened: 1 = loose.x;
assertStrictEquals(widened, 1);
// @ts-expect-error: Type 'string' is not assignable to type '"a"'.
const first: "a" = frozenArray[0];
assertStrictEquals(first, "a");
// @ts-expect-error: Property 'push' does not exist on type 'readonly string[]'.
frozenArray.push("c");
assertEquals(frozenArray as readonly string[], ["a", "b", "c"]);
assert(Object.isFrozen(frozenObject));
assertThrows(
// deno-lint-ignore no-eval
() => eval("frozenObject.x = 2"),
TypeError,
"Cannot assign to read only property",
);
type ReadonlyCounter = { readonly count: number };
const typedOnly: ReadonlyCounter = { count: 1 };
assertFalse(Object.isFrozen(typedOnly));
});
Check programs/read-only.test.ts
running 9 tests from ./programs/read-only.test.ts
...
Object.freeze narrows the type as well as locking the value ... FAILED (231µs)
ERRORS
Object.freeze narrows the type as well as locking the value => ./programs/read-only.test.ts:175:6
error: TypeError: Cannot add property 2, object is not extensible
frozenArray.push("c");
^
FAILURES
Object.freeze narrows the type as well as locking the value => ./programs/read-only.test.ts:175:6
FAILED | 8 passed | 1 failed (2ms)
error: Test failed
The push threw, and that is the difference between a type and a lock in one method call: on the readonly string[] that was only typed read-only, four steps up, the shielded push succeeded, and on a frozen array it dies with a TypeError. There is a second surprise in the types. Object.freeze({x: 1, mode: "fast"}) has the type Readonly<{x: 1; mode: "fast"}>, with literal property types the same literal does not get without the freeze, as the widened shield shows, so freeze is a partial as const that also does something at run time. Partial, because an array does not get the tuple treatment: Object.freeze(["a", "b"]) is readonly string[], read-only but not a tuple, with elements typed string rather than "a". Wrap the push in assertThrows and pin all of it:
Deno.test("Object.freeze narrows the type as well as locking the value", () => {
const frozenObject = Object.freeze({ x: 1, mode: "fast" });
const frozenArray = Object.freeze(["a", "b"]);
const exact: 1 = frozenObject.x;
const mode: "fast" = frozenObject.mode;
assertStrictEquals(exact, 1);
assertStrictEquals(mode, "fast");
const loose = { x: 1, mode: "fast" };
// @ts-expect-error: Type 'number' is not assignable to type '1'.
const widened: 1 = loose.x;
assertStrictEquals(widened, 1);
// @ts-expect-error: Type 'string' is not assignable to type '"a"'.
const first: "a" = frozenArray[0];
assertStrictEquals(first, "a");
assertThrows(
() => {
// @ts-expect-error: Property 'push' does not exist on type 'readonly string[]'.
frozenArray.push("c");
},
TypeError,
"object is not extensible",
);
assert(Object.isFrozen(frozenObject));
assertThrows(
// deno-lint-ignore no-eval
() => eval("frozenObject.x = 2"),
TypeError,
"Cannot assign to read only property",
);
type ReadonlyCounter = { readonly count: number };
const typedOnly: ReadonlyCounter = { count: 1 };
assertFalse(Object.isFrozen(typedOnly));
});
Object.freeze narrows the type as well as locking the value ... ok (115µs)
The eval write throws too, because a write to a frozen object throws in strict mode, which every module is, from the objects page. And the last two lines are the summary: a frozen object reports Object.isFrozen, and a merely typed one does not, because there is nothing at run time to report.
two of the three stop at one level
Deno.test("two of the three stop at one level", () => {
type Nested = Readonly<{ inner: { deep: number } }>;
const nested: Nested = { inner: { deep: 1 } };
nested.inner.deep = 2;
const frozen = Object.freeze({ inner: { deep: 1 } });
frozen.inner.deep = 2;
const asserted = { inner: { deep: 1 } } as const;
// @ts-expect-error: Cannot assign to 'deep' because it is a read-only property.
asserted.inner.deep = 2;
assertStrictEquals(nested.inner.deep, 2);
assertStrictEquals(frozen.inner.deep, 2);
assertStrictEquals(asserted.inner.deep, 2);
});
two of the three stop at one level ... ok (26µs)
Readonly<T> adds the modifier to T's own properties and stops, so the write through nested.inner needs no shield at all. Object.freeze freezes the object it was given and stops, so its inner write compiles and lands too. as const is the exception, because a const assertion applies to the whole literal expression, so nested object and array literals inside it are read-only too. That makes as const the only deep one of the three, and it is deep only in the type, as the third assertion shows by landing the shielded write anyway. If you want depth at run time you write it yourself, one recursive Object.freeze, the advice the values and references page gives from the value side.
the same trick for Set and Map, and the cast that undoes it
Deno.test("the same trick for Set and Map, and the cast that undoes it", () => {
const colours: ReadonlySet<string> = new Set(["red", "green"]);
const sizes: ReadonlyMap<string, number> = new Map([["small", 1]]);
// @ts-expect-error: Property 'add' does not exist on type 'ReadonlySet<string>'.
colours.add("blue");
// @ts-expect-error: Property 'set' does not exist on type 'ReadonlyMap<string, number>'.
sizes.set("large", 2);
assertStrictEquals(colours.size, 3);
assertStrictEquals(sizes.get("large"), 2);
(colours as Set<string>).add("yellow");
assertStrictEquals(colours.size, 4);
});
the same trick for Set and Map, and the cast that undoes it ... ok (23µs)
ReadonlySet<T> has has, size, forEach, and the iteration methods; ReadonlyMap<K, V> adds get; neither has the mutators, and neither exists at run time, so the value in the variable is an ordinary Set or Map that a single cast returns to full strength, no shield required. Which is the honest summary of this entry: these types describe an intention and check the code that can see them. If you need a collection nothing can change, you wrap it in a class that does not expose the mutators, and pay for the wrapper.
freeze at the edge
Deno.test("freeze at the edge", () => {
type Config = {
readonly retries: number;
readonly hosts: readonly string[];
};
const config: Config = Object.freeze({
retries: 3,
hosts: Object.freeze(["a", "b"]),
});
function describe(config: Config): string {
return `${config.retries} retries across ${config.hosts.length} hosts`;
}
assertStrictEquals(describe(config), "3 retries across 2 hosts");
assert(Object.isFrozen(config));
assert(Object.isFrozen(config.hosts));
});
freeze at the edge ... ok (28µs)
One Object.freeze per level, because it is shallow, and the type says readonly at both levels so the checker agrees with the runtime. Reach for this where a value is shared widely and mutation would be a real bug, not everywhere.
In practice
- Use
readonlyon members to document intent and catch writes in checked code. - Type array and tuple parameters as
readonlywhen a function only reads them. - Use
as constfor tables and configuration held in aconst, not alet. - Use
Object.freezeat a boundary when the guarantee must exist at runtime. - Add a deep-readonly type only after encountering a problem that needs it.
- Remember that every readonly type disappears at runtime; only
Object.freezeremains.