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 Map
s 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
Great article! I was familiar with ReadonlyArray, but the caveats of readonly modifier were something I wasn’t aware of. ReadonlyArray has literally increased the code quality and decreased the amount that I need to memorize as the editor will auto-suggest only non-mutating operations. No more thinking: “Was it splice or slice that mutated the array”. This has been absolutely fantastic with libraries like Redux where you should not mutate the state.
Thank you for spending the time to write this.
Thank you for your kind words! I’m glad this strategy worked out well for you. 👍🏻
I have always liked adding undefined in index signatures for stricter checks. e.g.
type Dictionary = { [key: string]: T | undefined };
type ReadonlyDictionary = { readonly [key: string]: T | undefined }