TypeScript series Tutorial 9 "type conversion" -- conditional type

Type conversion is not only the most fun of TS, but also the soul of language. If you want to play well, you need to be proficient in various means and tools. Here are some common means of type conversion.

Condition type

Determining output based on input is the core of most useful programs, and js is no exception. The condition judgment type can determine the output type according to the input relationship.

interface Animal {
    live(): void;
  }
  interface Dog extends Animal {
    woof(): void;
  }
  
  type Example1 = Dog extends Animal ? number : string;
          
//   type Example1 = number
  
  type Example2 = RegExp extends Animal ? number : string;

 
The condition type looks a bit like a js trinomial expression

  SomeType extends OtherType ? TrueType : FalseType;

When the type on the left of extensions can be assigned to the type on the right, you will get the type in the first branch ("true" branch); otherwise, you will get the type in the latter branch ("false" branch).

From the above example, conditional types may not become useful immediately - we can tell ourselves whether Dog extends Animal and choose numbers or strings! But the power of conditional types comes from using them with generics.

Let's take an example of the createLabel function:

interface IdLabel {
  id: number /* some fields */;
}
interface NameLabel {
  name: string /* other fields */;
}

function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

These overloads of createLabel describe a JavaScript function that selects based on the type of input. Please note the following:

This becomes cumbersome if a library has to make the same choice repeatedly throughout the API.

We must create three overloads: when we determine the type, one overload in each case (one for string and one for number) and the other overload in the most general case (using string | number). For each new type that createLabel can handle, the number of overloads increases exponentially.

 

We can code this logic as a condition type instead:

type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

 
Using the condition type, modify the overloaded createLabel function and use.

interface IdLabel {
    id: number /* some fields */;
  }
  interface NameLabel {
    name: string /* other fields */;
  }

type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
    throw "unimplemented";
  }
  
  let a = createLabel("typescript");
     
//   let a: NameLabel
  
  let b = createLabel(2.8);
     
//   let b: IdLabel
  
  let c = createLabel(Math.random() ? "hello" : 42);
//   let c: NameLabel | IdLabel

Condition type constraint

Usually, the check in the condition type will give us some new information. Just as narrowing down with type protection can provide us with more specific types, the real branch of conditional types will further constrain generics through the types we check.

Take the following example:

type MessageOf<T> = T["message"];
//Type '"message"' cannot be used to index type 'T'.

In the above example, TS will check for errors because it is not known whether T has a message attribute. We should restrict T, and TS will no longer compile and report errors:

type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
  message: string;
}

type EmailMessageContents = MessageOf<Email>;

//type EmailMessageContents = string

If we want MessageOf to support all types, if there is no message attribute, we will return never by default. We can add a condition type outside the constraint

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}

type EmailMessageContents = MessageOf<Email>;
              
// type EmailMessageContents = string

type DogMessageContents = MessageOf<Dog>;
             
// type DogMessageContents = never

In the correct branch, TS knows that T has the message attribute

 

As another example, we can also write a type called flat to flatte n the array type to its element type, but do not use them in other cases:

type Flatten<T> = T extends any[] ? T[number] : T;

// Extracts out the element type.
type Str = Flatten<string[]>;
     
// type Str = string

// Leaves the type alone.
type Num = Flatten<number>;
     
// type Num = number

When Flatten is an array type, the index access type is used to obtain the type of the element in string []. For other types, the type itself is returned.

 

Condition type use infer

We just find ourselves using conditional types to apply constraints, and then extract types. This is a very common operation, and the condition type makes it easier.

Conditional types provide us with a way to infer from the types we compare in the true branch using the infer keyword. For example, we can infer the element type in Flatte instead of "manually" fetching it using the index access type:

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

Here, we use the infer keyword to declaratively introduce a new generic type variable named Item instead of specifying how to retrieve the element type of T in the true branch. This makes it unnecessary for us to consider how to mine and explore the structure of the types we are interested in.

 
We can use inference keywords to write some useful helper type aliases. For example, for simple cases, we can extract the return type from the function type:

type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
  ? Return
  : never;

type Num = GetReturnType<() => number>;
     
//type Num = number

type Str = GetReturnType<(x: string) => string>;
     
//type Str = string

type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;
      
//type Bools = boolean[]

When inferring from a type that has multiple call signatures, such as the type of an overloaded function, infers from the last signature (which may be the most allowed "catch all" situation). Overload resolution cannot be performed based on the list of parameter types.

declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;

type T1 = ReturnType<typeof stringOrNum>;
     
//type T1 = string | number

Distributed condition type

When conditional types act on generic types, they become distributed when given a union type. For example, take the following as an example:

type ToArray<Type> = Type extends any ? Type[] : never;

If you pass in a union type to ToArray, the condition type will apply the members of each union type, and then Union.

type ToArray<Type> = Type extends any ? Type[] : never;

type StrArrOrNumArr = ToArray<string | number>;
           
//type StrArrOrNumArr = string[] | number[]

1. What happens here is that StrOrNumArray is distributed in:

  string | number;

2. And map each member type of the union to a valid:

  ToArray<string> | ToArray<number>;

3. Finally get

  string[] | number[];

Usually, distributivity is expected behavior. To avoid this behavior, you can enclose each side of the extends keyword in square brackets.

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// 'StrOrNumArr' is no longer a union.
type StrOrNumArr = ToArrayNonDist<string | number>;
         
//type StrOrNumArr = (string | number)[]

Keywords: TypeScript

Added by Hillary on Sat, 01 Jan 2022 01:53:41 +0200