HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

25 Read-only accessibility (readonly etc.)

In this chapter, we look at how can make things “read-only” in TypeScript – mainly via the keyword readonly.

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

25.2 Read-only object properties

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';

25.2.1 No change after initialization

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++;
}

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

25.2.3 Read-only index signatures

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';

25.2.4 Utility type 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,
  }
>>;

25.3 Class properties

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++;
  }
}

25.4 Read-only Arrays

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.

25.5 Read-only tuples

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>
>>;

25.6 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);
  }
  // Etc.
}

25.7 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:

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'
>>;

25.8 Usage recommendations

25.8.1 You need 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.

25.8.1.1 Functions

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
25.8.1.2 Generic types

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"]>
>>;

25.8.2 A downside of using the type ReadonlyArray

There is one downside of using the type ReadonlyArray: You can’t pass on the data to locations that do not use that type (of which there are many) – e.g.:

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 {}

25.9 Further reading

25.9.1 Sources of this chapter

The following sections of the official TypeScript Handbook were sources of this chapter: