Typescript: Incorrect union type inferred

Created on 20 Aug 2017  ·  11Comments  ·  Source: microsoft/TypeScript

I've found it frequently desirable to be able to "look up" the union variant associated with a type tag. For example, given a union

type Foo = { tag: 'n'; val: number } | { tag: 's'; val: string }

one wants to be able to write a type operator

type Lookup<T extends Foo['tag']> = // ???

such that Lookup<'n'> = number and Lookup<'s'> = string.

I don't _think_ this is possible in the current type system (would welcome correction on that point). Instead, we can use the trick of _starting_ with a Lookup type, and deriving the union type from it:

type Lookup = {
  n: number
  s: string
}

type Foo = {
  [T in keyof Lookup]: { tag: T; val: Lookup[T] }
}[keyof Lookup]

then the inferred type of Foo is

type Foo = {
    tag: "n";
    val: number;
} | {
    tag: "s";
    val: string;
}

as desired, and we have our lookup type: Lookup['n'] = number and Lookup['s'] = string.

So I use this pattern a lot, and I wanted to generalize it:

type Unionize<Lookup> = {
  [T in keyof Lookup]: { tag: T; val: Lookup[T] }
}[keyof Lookup]

Unfortunately, this doesn't do what you want:

type Foo = Unionize<{
  n: number
  s: string
}>

Here, Foo is inferred to be

type Foo = {
    tag: "n" | "s";
    val: string | number;
}

This seems like a bug!

Fixed

Most helpful comment

Workaround?

type Unionize<Lookup, X = { [T in keyof Lookup]: { tag: T; val: Lookup[T] } }> = X[keyof X]

All 11 comments

Looking at quickinfo on Unionize<Lookup>, I see:

type Unionize<Lookup> = { tag: keyof Lookup; val: Lookup[keyof Lookup]; }

which is "simplified" in the same surprising/incorrect way I see in #17908. I think these are the same underlying issue.

Workaround?

type Unionize<Lookup, X = { [T in keyof Lookup]: { tag: T; val: Lookup[T] } }> = X[keyof X]

@gcanti workaround is ingenious. The default forces type evaluation.

The first report of this issue seems to be https://github.com/Microsoft/TypeScript/issues/15756.

Wow, very cool @gcanti!

I didn't realize type parameter defaults could reference previous type parameters. This opens up a whole new world...

Using @gcanti's nifty type parameter default trick I put together a new typescript library, unionize, which allows generating tagged unions along with associated creation functions, predicates and match functions. Would welcome any feedback!

Just gotta say @gcanti's proposal is impressive :)

This seems like a bug!

This is the expected behavior. mapped types are a transformation on properties of a type, they do not generate a union type. Many operations that happen on mapped types e.g. inference, rely on the fact that they are homomorphic transformation.

@mhegazy: It may be expected behavior if you know how mapped types are implemented, but it's very surprising, as it violates the seemingly airtight principle of composition:

type X = ...
type F<T> = ...
type G<T> = ...
type FoG<T> = F<G<T>>
type FGX1 = F<G<X>> 
type FGX2 = FoG<X> 
// FGX1 should be equivalent to FGX2

Can you explain more about why generating a union type (i.e., T[A|B]T[A]|T[B]) breaks a constraint about mapped types?

Thanks!

For completeness' sake/History, the workaround was first found by @nirendy / @tycho01 during their exploration in #12215 (ref https://github.com/Microsoft/TypeScript/issues/12215#issuecomment-307614501, https://github.com/Microsoft/TypeScript/issues/16018#issuecomment-307626787).

Is this really "working as intended" in light of #18042 as a fix to #15756 ?

unionize should be working now after #15756.

FYI to anyone who gets here, #21316 now allows you to look up a union type by tag value.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

weswigham picture weswigham  ·  3Comments

jbondc picture jbondc  ·  3Comments

manekinekko picture manekinekko  ·  3Comments

blendsdk picture blendsdk  ·  3Comments

fwanicka picture fwanicka  ·  3Comments