5

I am seeing confusing behavior in tsc 3.2.2. I have two types that I expect to be equivalent but are not (according to VSCode intellisense - is there a better way to check?).

First, I have a discriminated union discriminated by a type key. The idea is that I will look up the proper type via the discriminant and then remove the type key to get the payload type:

interface A { type: 'a', x: number }
interface B { type: 'b', y: string }
type Request = A | B

I have some helper types. Omit comes from the TS docs, and Discriminate takes a discrimated union, the discriminant key, and the value for that key to use in the lookup and produces the matching type from the union:

type Omit<T, K> = Pick<T, Exclude<keyof T, K>>
type Discriminate<T, U extends keyof T, V extends T[U]> = 
  T extends Record<U, V> ? T : never

Now I define a helper to get the Request variant by type key:

type RequestType<T extends Request['type']> = Discriminate<Request, 'type', T>
type A2 = RequestType<'a'> // Equals A, good so far

Now I add a helper to get the Request payload type by type key:

type RequestPayload<T extends Request['type']> = Omit<RequestType<T>, 'type'>
type APayload = RequestPayload<'a'> // {} - That's not right!

However, if I calculate the payload type more directly, it works:

type APayload2 = Omit<RequestType<'a'>, 'type'> // { x: number } - Correct

What is the difference between APayload and APayload2? Is this maybe a bug? I think it's far more likely that I'm missing something. They seem like they should be identical.

1 Answer 1

4

If you look at the tooltip for the definition of RequestType, it's actually a union type:

type RequestType<T extends "a" | "b"> = Discriminate<A, "type", T> | Discriminate<B, "type", T>

When you use Omit on it, keyof in the Omit goes only over the keys that are present in all the members of the union, that is, Omit only sees the type key and nothing else, and when you omit it the resulting type comes as empty.

You need to use special version of Omit to fix it. You need UnionOmitthat "distributes" Omit over the members of the union then immediately assembles the union back again:

type UnionOmit<T, K> = T extends {} ? Pick<T, Exclude<keyof T, K>> : never;


type RequestPayload<T extends Request['type']> = UnionOmit<RequestType<T>, 'type'>
type APayload = RequestPayload<'a'>  // {x: number}
Sign up to request clarification or add additional context in comments.

6 Comments

I feel kinda dumb that I didn't notice it was a union. But based on your answer, I'd expect the last line of my example to get the wrong result too. Can you explain the difference?
The last line of your example uses Omit on one member of the union, selected by 'a' - if you write for example type T = RequestType<'a'>; you will see in the tooltip that T = A.
It also makes me wonder why it's a union. I didn't expect a union in a type parameter to get distributed without a conditional type.
because RequestType is a type alias for Descriminate, and that's a conditional type, using it's first type parameter in a condition T extends Record<U, V> ?
Yeah I guess I expected it to substitute the type arg once a concrete type was there. I find this behavior to be confusing still because they look the same.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.