Read-only accessibility in TypeScript

[2025-02-06] dev, typescript
(Ad, please don’t block)

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

Notation used in this blog post  

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.

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

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

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.

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

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

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

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.

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

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:

  • References to enum members
  • Boolean literals
  • String literals
  • Object literals
  • Array literals

It has two effects:

  • A value with a non-primitive type becomes read-only:
    • Object: all properties become read-only
    • Array: becomes read-only tuple
  • The inferred type become narrower – e.g. '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'
>>;

Usage recommendations  

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.

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

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

A caveat of using 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 {}

Further reading  

Sources of this blog post  

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