Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to avoid distribution over multiple generic type parameters in Typescript?

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).

like image 350
Ziriax Avatar asked Jan 09 '19 10:01

Ziriax


1 Answers

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

like image 107
Titian Cernicova-Dragomir Avatar answered Nov 03 '22 19:11

Titian Cernicova-Dragomir