In this blog post, we look at how can make things “read-only” in TypeScript – mainly via the keyword readonly
.
For showing computed and inferred types in source code, I use the npm package asserttt
– e.g.:
// Types of values
assertType<string>('abc');
assertType<number>(123);
// Equality of types
type Pair<T> = [T, T];
type _ = Assert<Equal<
Pair<string>, [string,string]
>>;
const
variable declarations: only the binding is immutable In JavaScript, if a variable is declared via const
, the binding becomes immutable but not the bound value:
const obj = {prop: 'yes'};
// We can’t assign a different value:
assert.throws(
() => obj = {},
/^TypeError: Assignment to constant variable./
);
// But we can modify the assigned value:
obj.prop = 'no'; // OK
TypeScript’s read-only accessibility is similar. However, it is only checked at compile time; it does not affect the emitted JavaScript in any way.
We can use the keyword readonly
to make object properties immutable:
type ReadonlyProp = {
readonly prop: { str: string },
};
const obj: ReadonlyProp = {
prop: { str: 'a' },
};
Making a property immutable has the following consequences:
// The property is immutable:
// @ts-expect-error: Cannot assign to 'prop' because it is
// a read-only property.
obj.prop = { str: 'x' };
// But not the property value:
obj.prop.str += 'b';
If a property .count
is read-only, we can initialize it via an object literal but not change it afterwards. If we want to change its value, we have to create a new object (line A):
type Counter = {
readonly count: number,
};
function createCounter(): Counter {
return { count: 0 };
}
function toIncremented(counter: Counter): Counter {
return { // (A)
count: counter.count + 1,
};
}
This mutating version of toIncremented()
produces a compile-time error:
function increment(counter: Counter): void {
// @ts-expect-error: Cannot assign to 'count' because it is
// a read-only property.
counter.count++;
}
readonly
doesn’t affect assignability Somewhat surprisingly, readonly
does not affect assignability:
type Obj = { prop: number };
type ReadonlyObj = { readonly prop: number };
function func(_obj: Obj) { }
function readonlyFunc(_readonlyObj: ReadonlyObj) { }
const obj: Obj = { prop: 123 };
func(obj);
readonlyFunc(obj);
const readonlyObj: ReadonlyObj = { prop: 123 };
func(readonlyObj);
readonlyFunc(readonlyObj);
We can, however, detect it via type equality:
type _ = Assert<Not<Equal<
{ readonly prop: number },
{ prop: number }
>>>;
There already is a pull request for the compiler option --enforceReadonly
which would change this behavior.
In addition to properties, index signatures can also be modified by readonly
. The following built-in type describes Array-like objects:
interface ArrayLike<T> {
readonly length: number;
readonly [n: number]: T; // (A)
}
Line A is a read-only index signature. One example of ArrayLike
being used: The type of the parameter of Array.from()
.
interface ArrayConstructor {
from<T>(iterable: Iterable<T> | ArrayLike<T>): T[];
// ···
}
If the index signature is read-only, we can’t use indexed access to change values:
const arrayLike: ArrayLike<string> = {
length: 2,
0: 'a',
1: 'b',
};
assert.deepEqual(
Array.from(arrayLike), ['a', 'b']
);
assert.equal(
// Reading is allowed:
arrayLike[0], 'a'
);
// Writing is not allowed:
// @ts-expect-error: Index signature in type 'ArrayLike<string>'
// only permits reading.
arrayLike[0] = 'x';
Readonly<T>
The utility type Readonly<T>
makes all properties of T
read-only:
type Point = {
x: number,
y: number,
dist(): number,
};
type ReadonlyPoint = Readonly<Point>;
type _ = Assert<Equal<
ReadonlyPoint,
{
readonly x: number,
readonly y: number,
readonly dist: () => number,
}
>>;
Classes can also have read-only properties. Those must be initialized directly or in the constructor and can’t be changed afterward. That’s why the mutating increment .incMut()
doesn’t work:
class Counter {
readonly count: number;
constructor(count: number) {
this.count = count;
}
inc(): Counter {
return new Counter(this.count + 1);
}
incMut(): void {
// @ts-expect-error: Cannot assign to 'count' because
// it is a read-only property.
this.count++;
}
}
There are two ways in which, e.g. an Array of strings can be declared to be read-only:
ReadonlyArray<string>
readonly string[]
ReadonlyArray
is only a type: In contrast to Array
, it does not exist at runtime. This is how we can use this type:
const arr: ReadonlyArray<string> = ['a', 'b'];
// @ts-expect-error: Index signature in type 'readonly string[]'
// only permits reading.
arr[0] = 'x';
// @ts-expect-error: Cannot assign to 'length' because it is
// a read-only property.
arr.length = 1;
// @ts-expect-error: Property 'push' does not exist on
// type 'readonly string[]'.
arr.push('x');
We create a normal Array and give it the type ReadonlyArray<string>
. That’s how to make Arrays that are read-only at the type level.
In the last line, we can see that type ReadonlyArray
does not only make properties and the index signature readonly
– it is also missing mutating methods:
interface ReadonlyArray<T> {
readonly length: number;
readonly [n: number]: T;
// Included: non-destructive methods such as .map(), .filter(), etc.
// Excluded: destructive methods such as .push(), .sort(), etc.
// ···
}
If we wanted to create Arrays that are read-only at runtime, we could use the following approach:
class ImmutableArray<T> {
#arr: Array<T>;
constructor(arr: Array<T>) {
this.#arr = arr;
}
get length(): number {
return this.#arr.length;
}
at(index: number): T | undefined {
return this.#arr.at(index);
}
map<U>(
callbackfn: (value: T, index: number, array: readonly T[]) => U,
thisArg?: any
): U[] {
return this.#arr.map(callbackfn, thisArg);
}
// (Many omitted methods)
}
We don’t implement ReadonlyArray<T>
because we don’t provide indexed access via square brackets, only via .at()
. The former could be done via Proxies but that would lead to less elegant and performant code.
In normal tuples, we can assign different values to the elements, but not the length:
const tuple: [string, number] = ['a', 1];
tuple[0] = 'x'; // OK
tuple.length = 2; // OK
// @ts-expect-error: Type '1' is not assignable to type '2'.
tuple.length = 1;
// The type of `.length` is 2 (not `number`)
type _ = Assert<Equal<
(typeof tuple)['length'], 2
>>;
// Interestingly, `.push()` is allowed:
tuple.push('x'); // OK
If a tuple is read-only, we can’t assign different values to either elements or .length
:
const tuple: readonly [string, number] = ['a', 1];
// @ts-expect-error: Cannot assign to '0' because it is
// a read-only property.
tuple[0] = 'x';
// @ts-expect-error: Cannot assign to 'length' because it is
// a read-only property.
tuple.length = 2;
// @ts-expect-error: Property 'push' does not exist on
// type 'readonly [string, number]'.
tuple.push('x');
We set up the read-only tuple once (at the beginning) and can’t change it later. The type of a read-only tuple is a subtype of ReadonlyArray
:
type _ = Assert<Extends<
typeof tuple, ReadonlyArray<string | number>
>>;
ReadonlySet
and ReadonlyMap
Similar to type ReadonlyArray
being a read-only version of Array
, there are also read-only versions of Set
and Map
:
// Not included here: methods defined in lib.es2015.iterable.d.ts
// such as: .keys() and .[Symbol.iterator]()
interface ReadonlySet<T> {
forEach(
callbackfn: (value: T, value2: T, set: ReadonlySet<T>) => void,
thisArg?: any
): void;
has(value: T): boolean;
readonly size: number;
}
interface ReadonlyMap<K, V> {
forEach(
callbackfn: (value: V, key: K, map: ReadonlyMap<K, V>) => void,
thisArg?: any
): void;
get(key: K): V | undefined;
has(key: K): boolean;
readonly size: number;
}
This is how we could use ReadonlySet
:
const COLORS: ReadonlySet<string> = new Set(['red', 'green']);
This is a wrapper class that makes a Set read-only at runtime:
class ImmutableSet<T> implements ReadonlySet<T> {
#set: Set<T>;
constructor(set: Set<T>) {
this.#set = set;
}
get size(): number {
return this.#set.size;
}
forEach(
callbackfn: (value: T, value2: T, set: ReadonlySet<T>) => void,
thisArg?: any
): void {
return this.#set.forEach(callbackfn, thisArg);
}
has(value: T): boolean {
return this.#set.has(value);
}
entries(): SetIterator<[T, T]> {
return this.#set.entries();
}
keys(): SetIterator<T> {
return this.#set.keys();
}
values(): SetIterator<T> {
return this.#set.values();
}
[Symbol.iterator](): SetIterator<T> {
return this.#set[Symbol.iterator]();
}
}
const
assertions (as const
) The const
assertion as const
is an annotation for values that only affects their types. It can be applied to:
It has two effects:
'abc'
(not string
)This is how objects are affected – note the readonly
and the narrower type (123
vs. number
):
const obj = { prop: 123 };
type _1 = Assert<Equal<
typeof obj, { prop: number }
>>;
const constObj = { prop: 123 } as const;
type _2 = Assert<Equal<
typeof constObj, { readonly prop: 123 }
>>;
This is how Arrays are affected – note the readonly
and the narrower types ('a'
and 'b'
vs. string
):
const arr = ['a', 'b'];
type _1 = Assert<Equal<
typeof arr, string[]
>>;
const constTuple = ['a', 'b'] as const;
type _2 = Assert<Equal<
typeof constTuple, readonly ["a", "b"]
>>;
Since primitive values are already immutable, as const
only leads to a narrower type being inferred:
let str1 = 'abc';
type _1 = Assert<Equal<
typeof str1, string
>>;
let str2 = 'abc' as const;
type _2 = Assert<Equal<
typeof str2, 'abc'
>>;
Switching from let
to const
also narrows the type:
const str3 = 'abc';
type _3 = Assert<Equal<
typeof str3, 'abc'
>>;
ReadonlyArray
if you want to accept read-only tuples Even though readonly
does not affect assignability, read-only tuples are a subtype of ReadonlyArray
and therefore not compatible with Array
because the latter type has methods that the former doesn’t have. Let’s examine what that means for functions and generic types.
The following function sum()
can’t be applied to read-only tuples:
function sum(numbers: Array<number>): number {
return numbers.reduce((acc, x) => acc + x, 0);
}
sum([1, 2, 3]); // OK
const readonlyTuple = [1, 2, 3] as const;
// @ts-expect-error: Argument of type 'readonly [1, 2, 3]'
// is not assignable to parameter of type 'number[]'.
sum(readonlyTuple);
function sum(numbers: ReadonlyArray<number>): number {
return numbers.reduce((acc, x) => acc + x, 0);
}
const readonlyTuple = [1, 2, 3] as const;
sum(readonlyTuple); // OK
If a type T
is constrained to a normal array type then it doesn’t match the type of an as const
literal:
type Wrap<T extends Array<unknown>> = Promise<T>;
const arr = ['a', 'b'] as const;
// @ts-expect-error: Type 'readonly ["a", "b"]' does not satisfy
// the constraint 'unknown[]'.
type _ = Wrap<typeof arr>;
We can change that by switching to ReadonlyArray
:
type Wrap<T extends ReadonlyArray<unknown>> = Promise<T>;
const arr = ['a', 'b'] as const;
type Result = Wrap<typeof arr>;
type _ = Assert<Equal<
Result, Promise<readonly ["a", "b"]>
>>;
ReadonlyArray
Problem: Most code uses Array
.
function appFunc(arr: ReadonlyArray<string>): void {
// @ts-expect-error: Argument of type 'readonly string[]'
// is not assignable to parameter of type 'string[]'.
libFunc(arr);
}
function libFunc(arr: Array<string>): void {}
The following sections of the official TypeScript Handbook were sources of this blog post: