Home > Enterprise >  TypeScript is weirdly joining types
TypeScript is weirdly joining types

Time:11-13

Imagine I have 2 different functions that I want to call depending on the passed function name. Also, I want to add type annotations for the function parameters.

Minimal example:

const a = (options: { o1: number }) => {}
const b = (options: { o2: string }) => {}

const functions = { a, b }

type A = {
  name: 'a'
  options: Parameters<typeof a>[0]
}
type B = {
  name: 'b'
  options: Parameters<typeof b>[0]
}

type Data = A | B

(data: Data) => {
  functions[data.name](data.options)
}

But TypeScript won't allow passing data.options:

TS2345: Argument of type '{ o1: number; } | { o2: string; }' is not assignable to parameter of type '{ o1: number; } & { o2: string; }'.   Type '{ o1: number; }' is not assignable to type '{ o1: number; } & { o2: string; }'.     Property 'o2' is missing in type '{ o1: number; }' but required in type '{ o2: string; }'.

Looks like TypeScript transforms

type Data = A | B

into

type Data = {
  name: 'a' | 'b'
  options: Parameters<typeof a>[0] | Parameters<typeof b>[0]
}

which seems incorrect for me

CodePudding user response:

The issue here is one I call "correlated union types". This was reported in microsoft/TypeScript#30581 and has been addressed via microsoft/TypeScript#47109, which advises a certain kind of refactoring to help the compiler understand what you're doing.


In general, if you have a function f and an argument x, both of which are of union types, like this:

declare const f: ((options: { o1: number }) => void) | ((options: { o2: string }) => void)
declare const x: { o1: number } | { o2: string }

then it is not safe to call the function using that argument, and the TypeScript compiler generates an error telling you this:

f(x); // error!
// Argument of type '{ o1: number; } | { o2: string; }' is not assignable to 
// parameter of type '{ o1: number; } & { o2: string; }'.

That error message might be confusing, but it's just saying that the argument union doesn't work; the only thing it knows would work would be an argument intersection, as per the support for calling union types introduced in TS3.3 and implemented in microsoft/TypeScript#29011.

It makes sense that this would be an error, since for all the compiler knows, f might be of type (options: { o1: number }) => void) while x is of type { o2: string }. That is, they might be mismatched.


Now, in your code you're doing this:

const f = functions[data.name];
const x = data.options;
f(x); // same error!

The compiler produces the same error as above and for the same reason: f and x are both of union types, and for all the compiler knows, they might be mismatched. But the compiler doesn't realize that the types of f and x are correlated to each other. They both depend on the type of data in such a way as to make a mismatch impossible.

This is effectively a limitation of TypeScript. If you leave f and x of union types, the compiler will not be able to see any correlation between them.


The refactoring recommended in microsoft/TypeScript#47109 is to make your function generic instead of a union, and pass it an argument of a corresponding generic type. In order for this to work, the function and argument types must be curated quite carefully.

First, we can create a DataMap mapping interface which represents a basic key-value relationship from which we can build your other types:

interface DataMap {
  a: { o1: number },
  b: { o2: string }
};

Then, we can annotate your functions variable as a mapped type which explicitly iterates over the key and value types of DataMap:

const a = (options: { o1: number }) => { }
const b = (options: { o2: string }) => { }   
const functions: { [K in keyof DataMap]: (options: DataMap[K]) => void } =
  { a, b }

Instead of Data being a straight union of hardcoded A and B, we can rewrite Data as a generic distributive object type (which, as mentioned in ms/TS#47109, is a mapped type into which we immediately index:

type Data<K extends keyof DataMap = keyof DataMap> =
  { [P in K]: { name: P, options: DataMap[P] } }[K]

From this we can define A or B if you want:

type A = Data<"a">
type B = Data<"b">

And if you want, you can use the Data type without a type argument (thanks to its generic default) and see that it's the same type as your version:

type D = Data
/* type D = {
    name: "a"; options: { o1: number; };
} | {
    name: "b"; options: { o2: string; };
} */

And now we can write your function; instead of accepting Data, we accept Data<K> for generic K:

<K extends keyof DataMap>(data: Data<K>) => {
  functions[data.name](data.options); // okay
}

Yay, no compiler error! That works because the compiler sees the types of functions[data.name] and data.options as appropriately related generics:

<K extends keyof DataMap>(data: Data<K>) => {
  const f: (options: DataMap[K]) => void = functions[data.name];
  const x: DataMap[K] = data.options;
  f(x); // okay
}

You can see that the argument x is of type DataMap[K], and the function f is of type (options: DataMap[K]) => void. So the argument is seen to be exactly the type that the function accepts, and there is no longer a compiler error.


Playground link to code

CodePudding user response:

TS2345: Argument of type '{ o1: number; } | { o2: string; }' is not assignable to parameter of type '{ o1: number; } & { o2: string; }'. This error message is explaining what is happening.

The A | B operator tells TypeScript that the value could be either A or B. You want to create a type which has value A and B. In the former case, TypeScript cannot guarantee that A is present because the object might only have value B, and vice versa. In the latter case TypeScript knows that both A and B will be present. The operator for this is A & B.

The following should work:

const a = (options: { o1: number }) => {}
const b = (options: { o2: string }) => {}

const functions = { a, b }

type A = {
  name: 'a'
  options: Parameters<typeof a>[0]
}
type B = {
  name: 'b'
  options: Parameters<typeof b>[0]
}

// only change is here
type Data = A & B

(data: Data) => {
  functions[data.name](data.options)
}

  • Related