Reputation: 5792
Is there a way to make an assignment like this to work?
type A = { a: string };
type B = { b: string };
const x = {
AA: undefined as A | undefined,
BB: undefined as B | undefined,
}
const name1 = "AA"
const x1 = x[name1]; // type: A | undefined
x[name1] = x1;
const name2 = "AA" as "AA"|"BB";
const x2 = x[name2]; // type: A | B | undefined
x[name2] = x2; //ERROR
//Type 'A | B | undefined' is not assignable to type '(A & B) | undefined'.
// Type 'A' is not assignable to type 'A & B'.
// Property 'b' is missing in type 'A' but required in type 'B'.
Upvotes: 1
Views: 1127
Reputation: 330481
This is due to a recent breaking change introduced in TypeScript 3.5 (see the relevant pull request for more info) which improved the soundness when writing to indexed-access/lookup types. In short, what you are doing is only safe since you happen to know for a fact that you are writing a value back into the property it was just read from. But the compiler doesn't realize this since you intentionally widened the type of the property key to a union. The type of x2
is therefore A | B | undefined
, and it isn't safe to write that type back to x[name2]
:
const name2 = "AA" as "AA" | "BB";
let x2 = x[name2]; // type: A | B | undefined
x2 = { b: "okay" }; // works because x2 is of A | B | undefined
x[name2] = x2; // now this error makes sense, right?
The problem here, as I see it, is a lack of support in the language for what I've been calling "correlated types". You know that the two union-typed expressions x2
and x[name2]
of type A | B | undefined
are not independent of each other. Either they are both A | undefined
, or they are both B | undefined
. But the compiler has no way to express that dependence in general. Workarounds here generally involve generics, type assertions, or code duplication.
Generics: One way to make the compiler understand this to be safe is to abstract what you are doing to a generic function where name is of a generic type that extends "AA" | "BB"
:
function genericVersion<N extends "AA" | "BB">(name: N) {
const x3 = x[name];
x[name] = x3; // no error
}
genericVersion("AA" as "AA" | "BB");
This works because the compiler allows you to assign a value of type T[N]
to a variable of type T[N]
when N
is a generic parameter. It's still technically unsafe:
function unsafeGeneric<N extends "AA" | "BB">(readName: N, writeName: N) {
const x4 = x[readName];
x[writeName] = x4; // no error
}
unsafeGeneric("AA", "BB"); // no error... oops
but it's more useful to allow this than it is to disallow it.
Assertions: Another workaround is to loosen the type of x
for the assignment via a type assertion, as in:
type Loosen<T extends object, K extends keyof T> = {
[P in keyof T]: P extends K ? T[K] : T[P]
};
(x as Loosen<typeof x, typeof name2>)[name2] = x2; // okay
The type Loosen<T, K>
takes an object type T
and a set of its keys K
and returns a wider type where any of the property types in T
with keys in K
can be assigned to any. For example,
type L = Loosen<{ a: string; b: number; c: boolean }, "b" | "c">;
is equivalent to
type L = {
a: string;
b: number | boolean;
c: number | boolean;
}
And thus I can write a boolean
to b
amd a number
to c
without complaint if I widen a value of type { a: string; b: number; c: boolean }
to L
before the assignment. When you do assertions like this you need to be careful not to lie to the compiler, and put the wrong value in the wrong property.
Duplication: The last workaround would be to manually walk the compiler through all the possibilities... this is fully type safe but redundant:
const name5 = Math.random() < 0.5 ? "AA" : "BB";
if (name5 === "AA") {
const x5 = x[name5];
x[name5] = x5; // okay
} else {
const x5 = x[name5];
x[name5] = x5; // okay
}
I'm afraid I don't have a wonderful answer for you. The improved soundness in TS3.5 is 100% correct and does catch bugs, but has also caused a lot of headaches for developers. Usually I tend to go with a type assertion as it's often the least disruptive change that achieves the goal.
Oh well. Hope that helps! Good luck.
Upvotes: 2