Reputation: 1715
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.
Upvotes: 2
Views: 2217
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.
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 :/
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
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
.
{
"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
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";
If you're interested in type branding, check out ts-brand for more advanced usages.
Upvotes: 3