3

Suppose there is a union type Thing grouping together types Foo, Bar and Baz with the discriminating property tag.

interface Foo {
  tag: 'Foo'
  foo: string
}

interface Bar {
  tag: 'Bar'
  bar: number
}

interface Baz {
  tag: 'Baz'
  baz: boolean
}

type Union = Foo | Bar | Baz

Now I would like to create a mapped type where I would iterate over tags of Union and use the corresponding interface in the type mapped to the tag. The question is: Is it possible to retrieve a type from a union type by its tag value?

interface Tagged {
  tag: string
}

type TypeToFunc<U extends Tagged> = {
  // Is it possilbe to retrieve the type for the given tag from the union type?
  // What to put in place of the ???
  readonly [T in U['tag']]: (x: ???) => string
}

const typeToFunc: TypeToFunc<Union> = {
  // x must be of type Foo
  Foo: x => `FOO: ${x.foo}`,
  // x must be of type Bar
  Bar: x => `BAR: ${x.bar}`,
  // x must be of type Baz
  Baz: x => `BAZ: ${x.baz}`,
}

If not, is there any other way to achieve this kind of mapping?

Peter Hudec
  • 2,462
  • 3
  • 22
  • 29

1 Answers1

14

In TypeScript v2.7 and earlier, there is no programmatic way to do this. It is easier to have TypeScript build unions programmatically than it is to inspect them. Therefore, you could do this instead:

interface UnionSchema {
  Foo: {foo: string},
  Bar: {bar: number},
  Baz: {baz: boolean}
}

type Union<K extends keyof UnionSchema = keyof UnionSchema> = {
  [P in K]: UnionSchema[K] & {tag: K}
}[K]

Now you can use Union as you did before, but the individual union constituents can be referred to as Union<'Foo'>, Union<'Bar'>, and Union<'Baz'>. For convenience you can still give them names:

interface Foo extends Union<'Foo'> {}
interface Bar extends Union<'Bar'> {}
interface Baz extends Union<'Baz'> {}

And type your function like this:

type TypeToFunc<U extends Union> = {
  readonly [T in U['tag']]: (x: Union<T>) => string
}
const typeToFunc: TypeToFunc<Union> = {
  // x must be of type Foo
  Foo: x => `FOO: ${x.foo}`,
  // x must be of type Bar
  Bar: x => `BAR: ${x.bar}`,
  // x must be of type Baz
  Baz: x => `BAZ: ${x.baz}`,
}

Starting in TypeScript v2.8, there will be a feature called conditional types which allows a lot more expressivity in the type system. You can write a general union discriminator like this:

type DiscriminateUnion<T, K extends keyof T, V extends T[K]> = 
  T extends Record<K, V> ? T : never

And then, with your original definitions:

interface Foo {
  tag: 'Foo'
  foo: string
}

interface Bar {
  tag: 'Bar'
  bar: number
}

interface Baz {
  tag: 'Baz'
  baz: boolean
}

type Union = Foo | Bar | Baz

You get the almost magical:

type TypeToFunc<U extends Union> = {
  readonly [T in U['tag']]: (x: DiscriminateUnion<Union,'tag',T>) => string
}

which also works. You can try this out now if you install typescript@next from npm... otherwise you'll need to wait.


Hope that helps; good luck!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 1
    It certainly helped, thanks. What struck me though was the `[K]` item access right after the `Union` declaration. It's an interesting trick. But when I tried to use `type Foo = Union<'Foo'>` instead of `interface Foo extends Union<'Foo'> {}`, it complained that `Foo` is already defined. It turned out that it was the `[K]` which was adding it to the global namespace. I solved it by wrapping the `Union` declaration into a namespace. – Peter Hudec Feb 12 '18 at 19:24
  • I can't reproduce that issue (`Foo` is already defined). Oh well! – jcalz Feb 12 '18 at 20:13
  • I really want to understand what is going on here: `type DiscriminateUnion = T extends Record ? T : never` – Audi Nugraha Aug 12 '18 at 14:36
  • 1
    @jcalz `DiscriminateUnion` doesn't work when the tags are unions themselves (in my use case, I have many tags and e.g. two of them refer to the same union member) e.g.: ```interface member { tag: 'exam' | 'park'; } type test = DiscriminateUnion; // inferred never ``` I guess there needs to be some other constraint on `V` to cover this case. EDIT: `type DiscriminateUnion = T extends Record ? T : T extends Record ? V extends U ? T : never : never;` Seems to do the trick. – Ran Lottem Aug 01 '19 at 13:23