How to implemented a function returning with TypeMap return

Viewed 84

I have a discriminate union type Actions. For each action, I have a different handler function, returning a different result. I now need a generic handler function that based on action.type calls the respective handler function and returns its result. The type signature of this function should be such that TypeScript can infer the shape of the result based on the called parameter. I achieve this with the type signature const handler = <A extends Action>(a: A): HandlerMap[A['type']] => .... However, TypeScript complains in the implemented switch statement, that the return is invalid:

Type 'Result1' is not assignable to type 'HandlerMap[A["type"]]'.
  Type 'Result1' is not assignable to type 'Result1 & Result2'.
    Type 'Result1' is not assignable to type 'Result2'.

What am I missing/doing wrong?

Here's the full code:

type Action1 = { type: 'A1'; input: { i: string } }
type Result1 = { result: string }
const handler1 = (a: Action1): Result1 => ({ result: a.input.i + '!' })

type Action2 = { type: 'A2'; input: { x: number } }
type Result2 = { result: { r2: number } }
const handler2 = (a: Action2): Result2 => ({ result: { r2: a.input.x + 1 } })

type Action = Action1 | Action2

type HandlerMap = {
  A1: Result1
  A2: Result2
}

const handler = <A extends Action>(a: A): HandlerMap[A['type']] => {
  switch (a.type) {
    case 'A1':  return handler1(a) // <--Compiler complains here
    case 'A2':  return handler2(a)
    default:    return undefined
  }
}
3 Answers

I would add this approach:

const handler = <A extends Action>(a: A): HandlerMap[A['type']] => {

  const map: { 
    [K in keyof HandlerMap]: (a: Extract<Action, { type: K }>) => HandlerMap[K] 
  } = {
    "A1": (a) => handler1(a),
    "A2": (a) => handler2(a),
  }

  return map[a.type](a as any) as HandlerMap[A["type"]]
}

We could replace the switch with an object called map. This object contains a key K for each type with a callback. The return type of this callback is strictly typed to be HandlerMap[K].

Pros:

  • map is exhaustive and will give a compile time error if you forget to handle a case
  • the argument a in each callback is strictly typed to be the correct Action type. So doing something like a.input.i would be possible in the first callback
  • the return type of the callback is strictly typed. So returning handler1(a) in the second callback would lead to a compile time error

Cons:

  • we have to use as any in the return statement (thanks to the correlated union issue)

  • we also have to use as HandlerMap[A["type"]] because of TypeScript's eager resolution of the map[a.type]() to Result1 | Result2 instead of { "A1": ..., "A2": ... }[A["type"]](A).


Playground

You can actually circumvent the type checker for the function signature by moving the function signature into an overload:

function handler<A extends Action>(a: A): HandlerMap[A['type']];
function handler(a: Action) {
    switch (a.type) {
        case 'A1':  return handler1(a);
        case 'A2':  return handler2(a);
        default:    return undefined;
    }
}

Then TypeScript thinks the type of handler (inside the body of handler, at least) is handler(a: Action): ??? and it will infer the type for you. However, this doesn't mean you won't get the typings you originally wanted!

Because of the external function signature handler<A extends Action>(a: A): HandlerMap[A['type']], you still retain the original behavior.

Below, when you hover over handler, you'll actually see this signature from TypeScript, instead of handler(a: Action): ???.

Playground

If you look at the error in your code, it says Type 'Result2' is not assignable to type 'Result1 & Result2'.

The Result1 & Result2 part here is suspicious. What you really need is Result1 | Result2. So you can provide the function a return type exactly like you need:

type Results = Result1 | Result2

const handler = <A extends Action>(a: A): Results => {
  switch (a.type) {
    case 'A1':  return handler1(a);
    case 'A2':  return handler2(a);
  }
}

Do note that the relation in this type of Action1 -> Result 1 is not very strongly typed. This relation is only ensured in the definition of your handlers. The return type of any of the handlers can be from any of the Results

For example, you may have a handler defined like the following and it will still work for the case 'A2':

const handler3 = (a: Action2): Result1 => ({ result: a.input.x + '!' })

Playground link

Related