3 Comments

Understanding and Embracing TypeScript’s “readonly”

If you’ve tried to use JavaScript in a functional style, you’ve no doubt chafed at the fact that all those little objects flying around at any given time are about as far as they can possibly get from immutable. You can, of course, be careful to write code that never mutates an object, but while good practices improve your code, they’re weak defenses against bugs.

TypeScript can help you with its readonly property modifier. With readonly, you can rely on TypeScript’s static analysis to enforce and flag mutations in your codebase.

The readonly property modifier has a similar effect on properties to what const does with variables: It prohibits you from redefining a property on an object to have something else.


type Foo = {
  bar: string
};

const x: Foo = {bar: 'baz'};
x.bar = 'quux'; // allowed

type ReadonlyFoo = {
  readonly bar: string
};

const y: ReadonlyFoo = {bar: 'baz'}; 
y.bar = 'quux'; // disallowed

But, again much like const, it’s subject to one very important limitation:


type ReadonlyFoo = {
  readonly bar: { baz: string }
};

const x: ReadonlyFoo = {bar: {baz: 'quux'}};
x.bar.baz = 'zebranky'; // allowed

What happened here? Well, we’re not changing the bar property—we’re changing the baz property on the object assigned to the bar property; the object bar refers to is never replaced, so it’s allowed. The same would happen if you assigned a mutable object to a const. You could never assign a new object, but mutating the object is very much permissible.

If you want to enforce immutability all the way down, you’ll need more readonly modifiers:


type ReadonlyFoo = {
  readonly bar: { readonly baz: string }
};

const x: ReadonlyFoo = {bar: {baz: 'quux'}};
x.bar.baz = 'zebranky'; // disallowed

The Readonly Mapped Type

Of course, when you start adding lots of properties, typing readonly in front of each one can rapidly become tedious. TypeScript’s Readonly mapped type can help:


type ReadonlyFoo = Readonly<{
  bar: string,
  baz: string
}>;

const x: ReadonlyFoo = {bar: 'quux', baz: 'zebranky'}; 
x.bar = 'poit'; // disallowed
x.baz = 'blat'; // disallowed

Readonly only works on your top-level properties, though, so you’ll need to reuse it when you go deeper:


type ReadonlyFoo = Readonly<{
  bar: { baz: string }
}>;

const x: ReadonlyFoo = {bar: {baz: 'quux'}};
x.bar.baz = 'zebranky'; // allowed

type DeeplyReadonlyFoo = Readonly<{
  bar: Readonly<{ baz: string }>
}>;

const y: DeeplyReadonlyFoo = {bar: {baz: 'quux'}};
y.bar.baz = 'zebranky'; // disallowed

Arrays and Dictionaries

This is great for manually constructed types that have known properties, but what about arrays? And dictionaries? There are solutions for both.

Arrays

Arrays have built-in support via the ReadonlyArray generic. (If you weren’t already describing your array types as Array<T> instead of T[], this is a great reason to start for consistency’s sake.)


type Foo = {
  bars: Array<string>
};

const x: Foo = {bars: ['baz', 'quux']};
x.bars.push('zebranky'); // allowed

type ReadonlyFoo = Readonly<{
  bars: ReadonlyArray<string>
}>;

const y: ReadonlyFoo = {bars: ['baz', 'quux']};
y.bars.push('zebranky'); // disallowed

Dictionaries

For key-value data structures, we routinely use JavaScript objects as dictionaries. (We could use Map, but the support for it isn’t nearly as strong as it is for objects—for example, you can’t pass Maps through Electron IPC channels without converting them.) Declaring one is easy:


type StringDictionary = {
  readonly [key: string]: string
};

const x: StringDictionary = {'foo': 'bar', 'baz': 'quux'};
x['foo'] = 'zebranky'; // disallowed
x['poit'] = 'blat'; // disallowed

We found ourselves creating these types so often in our current codebase that we decided to make a generic type specifically for dictionaries:


type Dictionary<T> = { [key: string]: T };
type ReadonlyDictionary<T> = { readonly [key: string]: T };

type StringDictionary = ReadonlyDictionary<string>;

Beware–there is one key (get it?) problem with this scheme for a dictionary type: Unfortunately, you are not restricted from setting properties.


type StringDictionary = {
  readonly [key: string]: string
};

const x: StringDictionary = {'foo': 'bar', 'baz': 'quux'};
x.foo = 'zebranky'; // allowed!

The only solution I’m aware of for this is to always treat these objects like dictionaries—don’t access them using property name literals. That has, thankfully, been a very easy rule to follow.

Crossing the Boundary

One of the more useful bits of TypeScript zen I’ve picked up is an understanding that TypeScript makes type assertions based not on the properties an object actually has at run-time, but on the properties we assert it has through the type we give it.

This is why all of this readonly stuff works in the first place. Regular JavaScript objects don’t have any protection against mutation, but if we assert properties are readonly, then TypeScript will flag uses of these properties which should not be permitted according to our assertion.

Understanding this lesson allows us to do things like efficiently assembling objects and arrays in mutable space inside a function–while declaring that the function’s return is an immutable object–and having TypeScript enforce that at compile-time.


function range(start: number, end: number): ReadonlyArray<number> {
  const result: Array<number> = [];
  for (const i = start; i <= end; i++) {
    result.push(i);
  }
  return result;
}

const oneToThree = range(1, 3);
oneToThree.push(4); // disallowed

This works because the Array type is a superset of ReadonlyArray. It has all the same properties the latter does, so you can access an Array as a ReadonlyArray at run-time, barring access to anything that would mutate that array.

Bonus: Freeze, Mutator!

TypeScript only enforces these constraints at compile-time. If you’re concerned about other code that isn’t covered by TypeScript mutating your objects, you may be able to leverage Object.freeze() (but note, you’ll have to check your run-time target’s compatibility):


type Foo = {
  bar: string
};

const x: Foo = {bar: 'baz'};

const y = Object.freeze(x);
y.bar = 'quux'; // disallowed at run-time AND compile-time!

Note that we didn’t specify a type for y in this example. Though I make a practice of always specifying the types I expect on declarations (it’s much easier to debug a bad inference where you make the assignment rather than several files over), in this specific instance, I wanted to demonstrate that TypeScript has inferred y as Readonly<Foo>. TypeScript’s own type definitions work this magic:


interface ObjectConstructor {
    …
    freeze<T>(o: T): Readonly<T>;
    …
}

Object.freeze() also works on arrays, and TypeScript will infer the return type as ReadonlyArray; (again, because its own type definitions cover this case).


const x: Array<string> = ['foo', 'bar'];

const y = Object.freeze(x);
y.push('baz'); // disallowed

If you’re going to use Object.freeze(), remember to use it at all levels of your deeply-nested objects, much as you need to use readonly and friends deeply—or you could find yourself with a mutable surprise.


type Foo = {
  bar: { baz: string }
};

const x: Foo = {bar: {baz: 'quux'}};

const y = Object.freeze(x);
y.bar = {baz: 'zebranky'}; // disallowed
y.bar.baz = 'zebranky'; // allowed