简体   繁体   中英

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 readonly ness 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 MyImmutableArrayThing s 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.

You can achieve "nominal typing", ie 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.

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)

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM