I didn't know how to formulate my question properly, so I'll give an example.
type ValueType = "NUM" | "STR";
type TypeOf<T>
= T extends "NUM" ? number
: T extends "STR" ? string
: never;
interface TypedValue<T = ValueType> {
type: T;
data: TypeOf<T>;
}
// Compiles, as intended
const test1: TypedValue = { type: "NUM", data: 123 };
// Does not compile, as intended
const test2: TypedValue<"NUM"> = { type: "NUM", data: "123" };
// Should not compile, but does...
const test3: TypedValue = { type: "NUM", data: "123" };
It seems that Typescript generates many concrete types for the interface TypedValue
:
so
interface TypedValue<T = ValueType, D = TypeOf<T>>
corresponds to
interface TypedValue<"NUM", number>
interface TypedValue<"NUM", string>
interface TypedValue<"NUM", never>
interface TypedValue<"STR", number>
interface TypedValue<"STR", string>
interface TypedValue<"STR", never>
and maybe more, while I actually want this generic type to correspond to just
interface TypedValue<"NUM", number>
interface TypedValue<"STR", string>
How can I avoid this type distribution, e.g. how can I tie one type parameter to another type parameter in typescript?
I know about the trick to suppress type distribution using
type TypeOf<T>
= [T] extends ["NUM"] ? number
: [T] extends ["STR"] ? string
: never;
But I can't seem solve the puzzle myself, and I really want to dig deeper in this magical type system, so any help is welcome :) Pretty sure jcalz knows how to tackle this ;)
EDIT It finally clicked after Titian Cernicova-Dragomir answer! Personally I understand the solution better with the following code snippet:
type Pairs1<T> = [T, T];
type Pairs2<T> = T extends (infer X) ? [X, X] : never;
type P1 = Pairs1<"A" | "B">; // => ["A" | "B", "A" | "B"]
type P2 = Pairs2<"A" | "B">; // => ["A", "A"] | ["B" | "B"]
What seems to happen, the Typescript compiler will check T extends (infer X)
for each union member "A"|"B"
, which always succeeds, but it now binds the matching type variable to a non-union type variable X
. And the infer X
is actually not needed, but it helped me to understand it better.
Infinite gratitude, I've been struggling with this for a long time.
So now I finally understand the following excerpt from the Typescript manual:
In instantiations of a distributive conditional type T extends U ? X : Y
, references to T
within the conditional type are resolved to individual constituents of the union type (i.e. T
refers to the individual constituents after the conditional type is distributed over the union type). Furthermore, references to T
within X
have an additional type parameter constraint U
(i.e. T
is considered assignable to U
within X
).
The problem is no so much with the conditional part of your solution but more with the way variables type annotations work coupled with default type parameters.
If not type is specified for a variable its type will be inferred. If you specify a type, no inference will occur. So when you say const test3: TypedValue
, no inference for the generic type parameter will occur and the default value for the type parameter will be used. So const test3: TypedValue
is equivalent to const test3: TypedValue<"NUM" | "STR">
which is equivalent to const test3: { type: "NUM" | "STR"; data: number | string; }
Since this is the type of the variable, the object literal will just be checked against the type and it is compatible with it (type
is "NUM"
, compatible with "NUM" | "STR"
, data
is of type string
compatible with string | number
)
You can convert your type into a true discriminated union using exactly the distributive behavior or conditional types:
type ValueType = "NUM" | "STR";
type TypeOf<T>
= T extends "NUM" ? number
: T extends "STR" ? string
: never;
type TypedValue<T = ValueType> = T extends any ? {
type: T;
data: TypeOf<T>;
}: never;
// Compiles, as intended
const test1: TypedValue = { type: "NUM", data: 123 };
// Does not compile, as intended
const test2: TypedValue<"NUM"> = { type: "NUM", data: "123" };
// does not compile now
const test3: TypedValue = { type: "NUM", data: "123" };
With the definition above TypedValue
without a type parameter is equivalent to :
{
type: "NUM";
data: number;
} | {
type: "STR";
data: string;
}
This means that a type
to STR
can never be compatible with a data
of type number
and a type
to NUM
can never be compatible with a data
of type string
.
The conditional type in TypedValue
is not used to express an actual condition, every T
will extend any
. The point of the conditional type is to distribute over T
. This means that if T
is a union, the result will be the type { type: T; data: TypeOf<T>; }
applied to every member of the union. Read more about the distributive behavior of conditional types here
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