The "readonly" lie of Typescript

It turns out Typescript doesn't do any of those and does not keep the promise of type safety...

The "readonly" lie of Typescript

“I'm not upset that you lied to me, I'm upset that from now on I can't believe you.” ― Friedrich Nietzsche

Meet Billy.

He is a developer just starting with Typescript, working on a new project excited and full of hope for a better future.

He is learning as much as he can while working on a project that makes use of this new language.

One thing to know about Billy is that he really cares about type safety and also about immutability.

For example, he would like to define a structure holding user data. Let's say the user has a name, a phone number, and an address.

interface UserInfo {
    name : string,
    phone: string,
    address: string
}

Being a fan of immutability, he wants to make sure that if he creates an instance of this object, it won't be modified later.

A first attempt

He first comes across the const modifier that can be specified when declaring a variable. Hmm, maybe that works?

const user : UserInfo = {
	name : "Bruce",
	Phone : "3334444",
	Address : "Earth"
}

user = {
	name : "Peter",
	phone : "1234567",
	address : "Moon"
}  //fails with the error: cannot assign to 'user' because it is a constant

//no complaints
user.name = "Peter"
user.phone = "1234567"
user.address = "Moon"

It turns out this doesn't do what he wants. This just makes the reference immutable, but the referent can still be modified:

Making progress

Billy is perseverant and he finds a new light: the readonly modifier. This is like const, but for properties. So now he can redefine the UserInfo interface:

interface UserInfo {
    readonly name : string,
    readonly phone: string,
    readonly address: string
}

const user : UserInfo = {
	name : "Bruce",
	phone : "3334444",
	address : "Earth"
} 


user.name = "Peter" // this one fails now since the property was marked as readonly: cannot assign to 'name' because it is a read-only property
user.phone = "1234567" // also fails
user.address = "Moon" // also fails

Third time is the charm

Nice! But he doesn't want to impose immutability on everyone that would want to make use of the UserInfo interface. Here he discovers the Readonly<Type> construct. This accepts a type and returns a type with all the properties of the input type as if they would have the readonly modifier set.

interface userInfo {
    name : string, 
    phone: string,
    address: string
}

const immutableUserInfo : Readonly<UserInfo> = {
	name : "Bruce",
	phone :"3334444",
	address :"Earth"
} 

const mutableUserInfo : UserInfo = {
	name : "Peter",
	phone :"1234567",
	address :"Moon"
} 

immutableUserInfo.name = "Alfred" //fails with the error: cannot assign to 'name' because it is a read-only property

mutableUserInfo.name = "Alfred" //works

Nice! Now Billy and everyone else can be happy, each making use of UserInfo as they see fit.

Or is it?

Later on in the project, he wants to check that the data in a UserInfo object is correct. For that purpose, he makes use of a library delivered by some of his colleagues that provides the following innocent-looking function:

function checkUserInfo(userInfo : UserInfo);

What he doesn't know is that checkUserInfo not only does it check the user info, but also tries to sanitize it(e.g. it removes special characters from the name), so it tries to modify the input data. In our example, it will just straight up replace the value of the 'name' property on the input object.

Billy doesn't need or want that for his use case, but the function is poorly documented so he has no idea that this function will modify his input data and for some cases, it will result in corrupted data.

But there is no reason to worry, to make sure things are going to be ok he passes a readonly type as an argument. Surely the compiler will save Billy.


function checkUserInfo(userInfo : UserInfo):void{
        userInfo.name = "default";
}

const immutableUserInfo : Readonly<UserInfo> = {
	name : "Bruce",
	phone : "3334444",
	address : "Earth"
} 


checkUserInfo(immutableUserInfo); //no compiler error/warning!!?

Wait a minute, it seems like there is no one to save Billy here!

He specified an immutable argument and the function modified it with no issues raised by the compiler.

What is happening here?

The LIE!

Well if we look at the signature, the function definition specifies the mutable version of the interface. To keep the type promise of immutability for our immutableUserInfo variable there are at least 2 options:

  • the compiler should warn us that the function is modifying the input,
  • a lot more straightforward, it should complain about any calls like this, where the function is being called with an immutable variable when the type in the definition is a mutable one.

It turns out Typescript doesn't do any of those and does not keep the promise of type safety, allowing readonly types to be assigned to mutable types. More than that, it is a deliberate decision in order to have some backward compatibility.

Hope and a lifeline

There are several issues raised on Github about this(e.g. 13002, 13347), being open for several years already, but it seems there is no real progress taking place. If you would like this to be fixed somewhere in the near future, make sure to have your voice heard on those issues.

In the meantime, not all hope is lost. There is one possible solution under the form of an ESLint rule. It can be found in this repo: eslint-plugin-total-functions, and the rule you are looking for is no-unsafe-readonly-mutable-assignment which is also part of the plugin's recommended rules.

If there are other quirks like this of Typescript that you know off let me know in the comments.