Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Exhaustive map over a union of typed objects

I want TypeScript to enforce exhaustiveness when mapping over a union like this:

type Union = 
  { type: 'A', a: string } |
  { type: 'B', b: number }

The Union event handler:

const handle = (u: Union): string =>
  theMap[u.type](u);

It'd be great if here we could somehow get exhaustiveness check from TypeScript:

const theMap: { [a: string]: (u: Union) => string } = {
  A: ({a}: { type: 'A', a: string }) => 'this is a: ' + a,
  B: ({b}: { type: 'B', b: number }) => 'this is b: ' + b
};
like image 431
Daniel Birowsky Popeski Avatar asked Sep 19 '25 23:09

Daniel Birowsky Popeski


1 Answers

UPDATE FOR TS2.8+

Since conditional types were released, the manipulation needed to strongly type theMap has gotten a lot easier. Here we will use Extract<U, X> to take a union type U and return only those constituents that are assignable to X:

type Union = { type: "A"; a: string } | { type: "B"; b: number };

const theMap: {
  [K in Union["type"]]: (u: Extract<Union, { type: K }>) => string
} = {
  A: ({ a }) => "this is a: " + a,
  B: ({ b }) => "this is b: " + b
};

Super simple! Unfortunately, the compiler no longer allows you to call theMap(u.type)(u) since TS2.7 or so. The function theMap(u.type) is correlated to the value u, but the compiler doesn't see that. Instead it sees theMap(u.type) and u as independent union types and won't let you call one on the other without a type assertion:

const handle = (u: Union): string =>
  (theMap[u.type] as (v: Union) => string)(u); // need this assertion

or without manually walking through the possible union values:

const handle = (u: Union): string =>
  u.type === "A" ? theMap[u.type](u) : theMap[u.type](u); // redundant

I've generally been recommending people use assertions for this.

I've got an open issue about such correlated types but I don't know if there will ever be support for it. Anyway, good luck again!


TS2.7 and below answer:

Given the type Union as defined, it's hard (or maybe impossible) to coax TypeScript into giving you a way to express both the exhaustiveness check (theMap contains exactly one handler for each constituent type of the union) and the soundness constraint (each handler in theMap is for a specific constituent type of the union).

However, it's possible to define Union in terms of a more general type, from which you can also express the above constraints. Let's look at the more general type first:

type BaseTypes = {
  A: { a: string };
  B: { b: number };
}

Here, BaseTypes is a mapping from the type property of the original Union to the constituent types with type removed from them. From this, Union is equivalent to ({type: 'A'} & BaseTypes['A']) | ({type: 'B'} & BaseTypes['B']).

Let's define some operations on type maps like BaseTypes:

type DiscriminatedType<M, K extends keyof M> = { type: K } & M[K];
type DiscriminatedTypes<M> = {[K in keyof M]: DiscriminatedType<M, K>};
type DiscriminatedUnion<M, V=DiscriminatedTypes<M>> = V[keyof V];

You can verify that Union is equivalent to DiscriminatedUnion<BaseTypes>:

type Union = DiscriminatedUnion<BaseTypes>

Additionally, it's helpful to define NarrowedFromUnion:

type NarrowedFromUnion<K extends Union['type']> = DiscriminatedType<BaseTypes, K>

which takes a key K and produces the constituent of the union with that type. So NarrowedFromUnion<'A'> is one leg of the union and NarrowedFromUnion<'B'> is the other, and together they make up Union.

Now we can define the type of theMap:

const theMap: {[K in Union['type']]: (u: NarrowedFromUnion<K>) => string } = {
  A: ({ a }) => 'this is a: ' + a,
  B: ({ b }) => 'this is b: ' + b
};

It's a mapped type containing one property for each type in Union, which is a function from that specific type to a string. This is exhaustive: if you leave out one of A or B, or put the B function on the A property, the compiler will complain.

That means we are able to omit the explicit annotation on {a} and {b}, since the type of theMap is now enforcing this constraint. That's good, because the explicit annotation from your code was not really safe; you could have switched the annotations around and not been warned by the compiler, because all it knew was that the input was a Union. (This kind of unsound type narrowing of function parameters is called bivariance and it's a mixed blessing in TypeScript.)

Now we should make handle be generic in the type of the Union parameter passed in:

Update for TS2.7+, the following function needs a type assertion due to lack of support for what I've been calling correlated types.

const handle = <K extends Union['type']>(u: NarrowedFromUnion<K>): string =>
  (theMap[u.type] as (_: typeof u) => string)(u);

Okay, that was a lot. Hope it helps. Good luck!

like image 113
jcalz Avatar answered Sep 22 '25 14:09

jcalz