lmcarreiro
lmcarreiro

Reputation: 5792

Union type assignment in TypeScript

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

Answers (1)

jcalz
jcalz

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.

Link to code

Upvotes: 2

Related Questions