I don't know why object value type can't be extracted properly with generic as below. Could you tell me why it doesn't work if you know?
export type ValueType = {
type: "string",
value: string,
} | {
type: "number",
value: number,
}
/** 1. Extract value type of "string" type */
type StringValueType = (ValueType & {type: "string"})["value"];
// type StringValueType = string
/** 2. Extract value type of each type by generic */
type ValueTypeWithGeneric<T extends ValueType["type"]> = (ValueType & {type: T})["value"];
type StringValueTypeWithGeneric = ValueTypeWithGeneric<"string">;
// type StringValueTypeWithGeneric = string | number
// I expect this type to be string
This is a design limitation in TypeScript; see microsoft/TypeScript#45428 for an authoritative answer. When faced with a type of the following form
type X<T> = ({ a: 0, b: "x" } & { a: T })["b"]
the compiler presumes that this will always be equivalent to
type X<T> = ({ a: 0 & T, b: "x" })["b"]
no matter what the generic type parameter T will be. And therefore the compiler reduces this eagerly to
type X<T> = "x"
So X<T> is just "x" independently of T:
type XT0 = X<0> // "x" 👍
type XT1 = X<1> // "x" 👎
But when you actually do this with a non-generic type in place of T, you see different behavior:
type X0 = ({ a: 0, b: "x" } & { a: 0 })["b"] // "x"
type X1 = ({ a: 0, b: "x" } & { a: 1 })["b"] // never
It is the discrepancy between X<1> and X1 which you have run into.
In TypeScript 3.8 and below, there was no discrepancy. Type X1 would evaluate to "x" also:
// TS3.8 and below
type X1 = ({ a: 0, b: "x" } & { a: 1 })["b"] // "x" in TS3.8-
Even though it's not possible to have a value with an a property of both type "0" and type "1", the compiler kept {a: 0, b: "x"} & {a: 1} as-is, and therefore the b property is seen to be of type "x".
But TypeScript 3.9 introduced support for reducing intersections by discriminant properties, as implemented in microsoft/TypeScript#. Now the compiler sees {a: 0, b: "x"} & {a: 1} as the never type, and thus the b property is also of the never type.
And that's great. But unfortunately the behavior in the face of generics was not updated to account for this change. When intersecting with generic {a: T}, no reduction takes place, no matter what. Oh well. It's a design limitation.
The recommended workaround here is to avoid using intersections for this purpose, and instead use the Extract<T, U> utility type whose intent is to filter a union type T to just those members assignable to U. So instead of {a: 0, b: "x"} & {a: T}, you can write Extract<{a: 0, b: "x"}, {a: T}> and get consistent behavior:
type Ex<T> = Extract<{ a: 0, b: "x" }, { a: T }>["b"]
type Ex0 = Ex<0> // "x" 👍
type Ex1 = Ex<1> // never 👍
And that means your example will start behaving as desired as well:
/** 1. Extract value type of "string" type */
type StringValueType = Extract<ValueType, { type: "string" }>["value"];
// type StringValueType = string 👍
/** 2. Extract value type of each type by generic */
type ValueTypeWithGeneric<T extends ValueType["type"]> = Extract<ValueType, { type: T }>["value"];
type StringValueTypeWithGeneric = ValueTypeWithGeneric<"string">;
// type StringValueTypeWithGeneric = string 👍
Playground link to code in TS4.6
Playground link to code in TS3.8
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With