Martin Hallén
Martin Hallén

Reputation: 1552

Map multiple compatible types from a Union type

I'm working on an application where we have defined a type and need to deduce multiple interfaces out from that single interface.

Example:

Our main type looks something like this. It is not important if this type is defined as a mapped type or union type, so whatever solution is easiest would be the best.

type A = {
  type: "A"
  input: {
    type: "inputA"
  }
  output: {
    type: "outputB"
  }
}

type B = {
  type: "B"
  input: {
    type: "inputB"
  }
  output: {
    type: "outputB"
  }
}

type Definitions = A | B

From those definitions, we want to construct a type which encapsulates the function interfaces.


type Program<F, K> = F extends { type: K; input: infer I; output: infer O }
  ? (input: I) => O
  : never

type Programs = { [K in Definitions["type"]]: Program<Definitions, K> }

// Correctly deduced type:
// type Programs = {
//     A: (input: {
//         type: "inputA";
//     }) => {
//         type: "outputA";
//     };
//     B: (input: {
//         type: "inputB";
//     }) => {
//         type: "outputB";
//     };
// }

We can then provide these functions, something like this:

const a = (input: { type: "inputA" }): { type: "outputA" } => {
  return { type: "outputA" }
}

const b = (input: { type: "inputB" }): { type: "outputB" } => {
  return { type: "outputB" }
}

const programs: Programs = {
  A: a,
  B: b,
}

So good so far. However, I can't seem to wrap my head around how to dynamically call these functions. It is important that the run1 and run2 functions are typesafe.

const run1 = (definition: Definitions) => programs[definition["type"]](definition.input)

// throws error
// Argument of type '{ type: "inputA"; } | { type: "inputB"; }' is not assignable to parameter of type 'never'.
//   The intersection '{ type: "inputA"; } & { type: "inputB"; }' was reduced to 'never' because property 'type' has conflicting types in some constituents.
//   Type '{ type: "inputA"; }' is not assignable to type 'never'.

// or 

type Input<F> = F extends { input: infer I } ? I : never
type Output<F> = F extends { output: infer O } ? O : never
type Type<F> = F extends { type: infer T } ? T : never
const run2 = <F>(type: Type<F>, input: Input<F>): Output<F> => {
  return programs[type](input)
}

// yields error
// Type 'Type<F>' cannot be used to index type 'Programs'.

Anything I'm missing?

Playground here

Upvotes: 0

Views: 439

Answers (2)

cdimitroulas
cdimitroulas

Reputation: 2539

I think the issue is that programs[definition["type"]] returns the union of all the programs, so it's not callable (that's why the compiler error says not assignable to never).

You can get around this by using a switch statement:

const run1 = (definition: Definitions) => {
  switch (definition.type) {
    case "A":
      return programs["A"](definition.input)
    case "B":
      return programs["B"](definition.input)
  }
}

You can add an explicit return type to let Typescript warn you if you've missed a case in the switch statement as well:

const run1 = (definition: Definitions): Definitions["output"] => {
  switch (definition.type) {
    case "A":
      return programs["A"](definition.input)
    case "B":
      return programs["B"](definition.input)
  }
}

Playground link

Upvotes: 2

You are unable to call programs[definition["type"]] because of contravariance.

Consider this example:

type A = {
  type: "A"
  input: {
    type: "inputA"
  }
  output: {
    type: "outputA"
  }
}

type B = {
  type: "B"
  input: {
    type: "inputB"
  }
  output: {
    type: "outputB"
  }
}

type Definitions = A | B


type Program<F, K> = F extends { type: K; input: infer I; output: infer O }
  ? (input: I) => O
  : never

type Programs = { [K in Definitions["type"]]: Program<Definitions, K> }

const a = (input: { type: "inputA" }): { type: "outputA" } => {
  return { type: "outputA" }
}

const b = (input: { type: "inputB" }): { type: "outputB" } => {
  return { type: "outputB" }
}

const programs: Programs = {
  A: a,
  B: b,
}


const run1 = (definition: Definitions) => {
  const { input } = definition
  const fn = programs[definition["type"]] // expects union of A and B

  if (input.type === 'inputA') {
    fn()
  }
}

Because of fn arguments are in contravariant position, TS does intersection under the hood. Hence, {type:'A'} & {type:'B'} === never

That's why you are unable to call this function.

Here you can find a very good answer & explanation

You want union to intersection? Distributive conditional types and inference from conditional types can do that. (Don't think it's possible to do intersection-to-union though, sorry) Here's the evil magic:

Hence, if you want to make it work, I believe you should stick with @cdimitroulas approach.

AFAIK, TS does not play well with nested properties of union type. But I might be wrong

Upvotes: 2

Related Questions