Andrew Medworth
Andrew Medworth

Reputation: 1715

How can I ensure that a readonly property is NOT assignable to a mutable one?

Suppose I have two types A and B in TypeScript, and I do not want values of type A to be assignable to B.

I would like to enforce this, such that if one (or both) of the types is accidentally modified to allow the assignment, we get a compile error or test failure. Is there a way to accomplish this in TypeScript?

Of course it's easy to enforce that an assignment is legal, simply by doing the assignment in a test. But I can't immediately see a way to enforce that a particular piece of code does not pass TypeScript's type checking.

Here is some more background about why I want to do this. I want to enforce immutability of a particular type, something like this:

interface MyImmutableThing {
    readonly myfield: string;
}

However, this issue makes that problematic, because if I have an otherwise-identical mutable type like this:

interface MyThing {
    myfield: string;
}

then values of type MyImmutableThing are assignable to MyThing, allowing type safety to be bypassed and myfield to be mutated. The following code compiles, runs and causes imm.myfield to change:

const imm: MyImmutableThing = {myfield: 'mumble'};
const mut: MyThing = imm;
mut.myfield = 'something else';

I don't know of a way to robustly ensure immutability of a type like this at compile time, but I can at least implement runtime enforcement by using a class instead, like this:

class MyImmutableThing {
    private _myfield: string;
    get myfield(): string { return this._myfield; }
    constructor(f: string) { this._myfield = f; }
}

Then, while code like the following will still compile, it will result in a runtime error:

const imm = new MyImmutableThing('mumble');
const mut: MyThing = imm;
mut.myfield = 'something else';

I can then write a test that asserts this runtime error occurs.

However, if my field is of array (or tuple) type, the situation changes:

interface MyArrayThing {
    myfield: string[];
}
interface MyImmutableArrayThing {
    readonly myfield: readonly string[];
}

Now, a value of MyImmutableArrayThing is not assignable to MyArrayThing, because of the readonlyness of the array type. The following will not compile:

const imm: MyImmutableArrayThing = {myfield: ['thing']};
const mut: MyArrayThing = imm;

This is good, in that it gives us more compile-time reassurance of immutability than we got with the string field. However, it's now harder to write tests that capture our intent here, or otherwise to enforce it.

The non-assignability of MyImmutableArrayThings to MyArrayThing is key to the type system enforcing the properties we want, but how do we stop someone making some change, such as adding readonly to the array in MyArrayThing, allowing something like this and breaking the property we wanted?

interface MyArrayThing {
    myfield: readonly string[]; // now readonly
}
interface MyImmutableArrayThing {
    readonly myfield: readonly string[];
}
const imm: MyImmutableArrayThing = {myfield: ['thing']};
const mut: MyArrayThing = imm;
mut.myfield = ['other thing'];

TypeScript's readonly enforcement is quite confusing at the moment, so being able to make assertions of this sort would be quite helpful in preventing regressions.

Here is a TypeScript Playground link for the code in this question.

Upvotes: 2

Views: 2217

Answers (2)

ford04
ford04

Reputation: 74490

The readonly modifier currently does not contribute to assignability type checks. There is no language-level construct, that errors on readonly property assignments to mutable ones.

In other words, this is why your example compiles without errors:
const imm: MyImmutableThing = { myfield: 'mumble' };
const mut: MyThing = imm; // readonly `myfield` is assignable to mutable one
mut.myfield = 'something else'; // duh! Changed a readonly declared property :/

Why does readonly string[] error correctly?

readonly string[] is a shortform for ReadonlyArray. ReadonlyArray has its own type definitions and effectively is a supertype of Array (with less properties). So the usual type compatibility check can kick in, which forbids assignments of a broader type to a more narrow one:

// We can check above statement directly with TS types
type IsROArrayAssignableToArray = ReadonlyArray<any> extends Array<any> ? 1 : 0 // 0
type IsArrayAssignableToROArray = Array<any> extends ReadonlyArray<any> ? 1 : 0 // 1

Workaround

Until the compiler is capable of checking these things (let's give the issue an upvote), we can use a linting rule like total-functions/no-unsafe-assignment from eslint-plugin-total-functions.

Minimal .eslintrc.json:
{
  "extends": [
    "plugin:total-functions/recommended"
  ],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "project": "./tsconfig.json"
  },
  "plugins": ["@typescript-eslint", "total-functions"]
}
package.json:
"devDependencies": {
  "@typescript-eslint/eslint-plugin": "^3.6.0",
  "@typescript-eslint/parser": "^3.6.0",
  "eslint": "^7.4.0",
  "eslint-plugin-total-functions": "^1.35.2",
  "typescript": "^3.9.6"
}
// ...
Now, all your cases above emit and ESLint error, as desired:
const imm: MyImmutableThing = { myfield: "mumble" }
const mut: MyThing = imm // error:
// Using a readonly type to initialize a mutable type can lead to unexpected mutation 
// in the readonly value. eslint(total-functions/no-unsafe-assignment)

Upvotes: 3

Avish
Avish

Reputation: 4626

You can achieve "nominal typing", i.e. making similar types incompatible despite sharing the same structure, by using a technique called "type branding".

Take a look at this example from the TypeScript playground for more details.

In your case, type branding could look something like this:

interface ImmutableThing {
  readonly myfield: string
  __brand: "ImmutableThing"
}

interface MutableThing {
  myfield: string
  __brand: "MutableThing"
}

const imm: ImmutableThing = {myfield: "thing"} as ImmutableThing;
const mut: MutableThing = imm; // type error
mut.myfield = "mutated"; 

Playground Link

If you're interested in type branding, check out ts-brand for more advanced usages.

Upvotes: 3

Related Questions