Most of my posts on this blog are about Java but in recent years I worked a lot with TypeScript. I think the reason I never wrote about it is that most of the the weirdness comes from EcmaScript, which is so often weird that enough other people have written about it and TypeScript itself is actually quite well done in my opinion.
But there is the “readonly” modifier and it is quite weird when used on types and interfaces. Most don’t seem to understand what it does and so there are many misconceptions.
The modifier “readonly” can be used in classes and there it’s quite easy to understand what it does:
class C implements T {
constructor(readonly foo:string) {
this.foo = foo;
}
}
The property is read-only and that means the compiler doesn’t allow you to assign anything to “foo” except for inside the constructor.
But “readonly” can also be used in interfaces and types:
type T = { readonly foo: string }
type S = T & { foo: string }
let s : S = { foo: "a"};
s.foo = "b";
As you can see it doesn’t really work as expected. There is no compiler error here. S is a subtype of T, but it just loses the “readonly” constraint.
The Liskov substitution principle (LSP) says:
Let ϕ(x) be a property provable about objects x of type T. Then ϕ(y) should be true for objects y of type S where S is a subtype of T.
An so in this case ϕ(x) is “readonly”. That means when type T is defined as { readonly foo: string }
any subtype S must have “foo” be read-only as well. But we can do the assignment s.foo = "b"
as shown above.
The modifier “readonly” in TypeScript is quite confusing for beginners and even those who have used the language and advanced types for some time.
It’s fine as long as you understand that “readonly” just means that T only declares that “foo” can be read. It doesn’t say that “foo” is immutable. It it meant that the property was “readonly”, they could have just used “const”.
Should we even use the “readonly” modifier on types and interfaces?
Should a type even define that something is read-only? The modifier makes sense on a class, where you define the behaviour. The “readonly” modifier restricts what the value can do. That’s fine. Even foo:string
actually restricts it to having a string value. You are then restricted to that type.
The issue is more about the exact semantics. Programmers have many different opinions on what “readonly” means or what it should mean:
- It’s just something a programmer put there to tell you it’s read-only in some way. So, it’s really more like a comment.
- It makes the property read-only somehow. (But that’s not the case.)
And it’s not even clear what “read-only” means:
- This type doesn’t define a setter.
- There must not be a setter.
- Nothing may change the value after initialisation.
- There’s no setter, but the value can still be changed internally.
- The property and the referenced value are both immutable (i.e. the value is frozen).
It’s really just a question of taste. Some prefer simple types and it’s probably better to just use a class that actually defines exactly what can be done and what is prevented. But when you write a function that requires a property to be writable it’s difficult to make sure it will work. A function that takes a value of type { foo: string }
will also accept any object that implements { readonly foo: string }
. Nothing will keep anyone from calling your function using an object that is supposed to be immutable.
Why is it not enforced that you don’t wrote to a read-only property?
You already can’t pass an ReadonlyArray<number>
to a function that wants a number[]
. There seems to be a special compiler rule for this. The error is:
Argument of type ‘readonly number[]’ is not assignable to parameter of type ‘number[]’.
But why is it not assignable, when it has all the properties and functions of a mutable array? And it allows you do declare this type:
type WeirdArray = number[] & ReadonlyArray<number>;
Does this make any sense? It’s an array that is both read-only and mutable? It will treat it like any mutable array.
And this also compiles:
function setFoo<T>(o: {foo: T}, v: T) { o.foo = v; }
const t: { readonly foo: string; } = {foo : "a"}
setFoo(t, "b");
The modifier simply on a type or interface doesn’t really do anything to prevent this. At least for now (I’m writing this in April of 2025). See further below.
What does the “readonly” modifier actually do?
As of now, it just works like “get”. It only defines the getter, but not the setter. However, it is quite useful if programmers actually read the code and see that the type actually uses that modifier, which indicates that there shouldn’t be a setter. It also marks the property as a candidate for indexing, caching etc. because it is expected not to change during the object’s lifetime.
How to enforce a type not having a setter?
You could do this instead:
type T = {get foo(): string; set foo(v:never); }
type S = T & {foo: string}
const t: T = {foo : "a"}
t.foo = "b";
The compiler now tells you that the setter uses the type “never” and so it’s impossible to call it. You won’t use the setter by mistake.
Even though it’s more precise and robust, it’s harder to understand for programmers. So it’s probably better to just use “readonly”. Most programmers will understand that it’s probably a bad idea to ignore it.
If robustness is really something you worry about, then you should just freeze immutable objects or use defineProperty with “writable” set to false. This way you actually make sure it can’t be mutated at runtime.
Good news: There is a PR that will fix it
We got “readonly” when they merged PR 6532. And there’s another PR:
New –enforceReadonly compiler option to enforce read-only semantics in type relations
This will fix all the problems with the “readonly” modifier by introducing a new --enforceReadonly
compiler option to enforce read-only semantics in type relations.
Conclusion
In my opinion there is no good reason to not enforce restrictive modifiers in TypeScript. I hope the PR will be merged soon and I would even want to see it enabled by default when using strict mode. It might break some existing code but it would do that for a good reason. Every intersection type must be a subtype of both (all) component types. I.e. any intersection type must be assignable to each component type. But that doesn’t mean that the compiler should allow us to assign something with a read-only property to a type where it is writable. It should work just the same with arrays, types and interfaces. The compiler shouldn’t allow { readonly foo: string } & { foo: string }