In Lecture 14 we learned about the common TypeScript official built-in utility types (official wheels), which are essentially custom complex type constructors (generics, to be exact). In this tutorial we will learn how to build your own wheel and examine some common third-party wheel implementations.

Type of material

Before we start building the wheel, let’s familiarize ourselves with the materials we’re going to use, which may involve some knowledge from each lecture and some new grammar. If you can’t remember the generic features, go over them from at least lecture 10.

Without further ado, let’s take a look at some types of materials that are heavily dependent.

The generic

The first is generics (recap lecture 10). I believe that the essence of utility types is generics that construct complex types. If a utility type cannot accept type inputs, what is the difference between it and a normal type alias? So using generics for variable extraction and logical encapsulation is essentially building type wheels,

type isXX = 1 extends number ? true : false;
  type isYY = 'string' extends string ? true : false;
Copy the code

In lines 1 to 2 of the example, we reuse the extends keyword and the ternary operator to return Boolean literals true or false depending on the subtype relationship between type 1 and number, and ‘string’ and string, respectively. It also aliases the types isXX and isYY, which is obviously an inefficient approach, because we cannot reuse the logic in the judgment of other types of subtype relations. At this point, we need to extract the exact type into an input parameter and then encapsulate it into a reusable generic type.

type isSubTying<Child, Par> = Child extends Par ? true : false;
  type isXX2 = isSubTyping<1, number>; // true
  type isYY2 = isSubTyping<'string', string>; // true
  type isZZ2 = isSubTyping<true, boolean>; // true
Copy the code

The tool generics encapsulated in line 1 of the example are the tool type isSubTyping we used in Lecture 12, which returns the Boolean literal type true if the type input parameter Child is a subtype of Par, and false otherwise. Thus, we can use isSubTyping to determine the subtype relationship between any two other types.

For example, lines 2 through 4 in the example return the Boolean literal type true because 1 and number, ‘string’ and string, true, and Boolean are all subtypes and parent types.

Conditions in the

As we mentioned in generics, TypeScript supports conditional types that use ternary operations. The previous conditional judgments return different types. At the same time, ternary operations also support nesting.

In the conditional judgment logic of ternary operations, it mainly uses the extends keyword to determine the subtype relationship of two types.

type isSubTyping<Child, Par> = Child extends Par ? true : false;
  type isAssertable<T, S> = T extends S ? true :  S extends T ? true : false;
  type isNumAssertable = isAssertable<1, number>; // true
  type isStrAssertable = isAssertable<string, 'string'>; // true
  type isNotAssertable = isAssertable<1, boolean>; // false
Copy the code

In addition to defining isSubTyping in line 1, we also define in line 2 a generic isAssertable that uses nested ternary operations to determine whether type input parameter T can be asserted as type S (T as S). And the extends keyword is used to determine whether the input parameter T is a subtype of S or S is a subtype of T to determine the predictable relationship between them.

In lines 3 through 5 of the example, true is returned because 1 is a subtype of number and satisfies the conditional branch T extends S; Because the string literal ‘string’ is a subtype of string and satisfies the conditional branch S extends T, it returns true; Because 1 is neither a subtype nor a parent of Boolean and does not satisfy any conditional branches, false is returned.

Distributive Conditional Types

As we mentioned in Lecture 10, in conditional types, if the input is a union type, it is broken down into separate (atomic) types (members) and then type operations are performed.

 type BooleanOrString = string | boolean;
  type StringOrNumberArray<E> = E extends string | number ? E[] : E;
  type WhatIsThis = StringOrNumberArray<BooleanOrString>; // boolean | string[]
  type BooleanOrStringGot = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString; // string | boolean
Copy the code

In line 3 of the example, the combined string and Boolean BooleanOrString is split into two separate types, string and Boolean, when taken as arguments to the generic StringOrNumberArray. Again through the extends keyword to judge whether the string | subtype of number type.

Because the string is a subset, and Boolean not, so eventually we got WhatIsThis is of type Boolean | string [] * *. * * but, in the condition of non-generic type (line 4) in the sample, because BooleanOrString being treated as a whole, so BooleanOrStringGot is of type string | Boolean.

Similarly, you can unassign a type by forcing it to be treated as a whole,

type StringOrNumberArray<E> = [E] extends [string | number] ? E[] : E;
  type WhatIsThis = StringOrNumberArray<string | boolean>; // string | boolean
Copy the code

Line 1 in the sample, we will use [] into E wrapped up, even if the parameter is joint type string | Boolean, will also be treated as a whole, so the line 2 returns the string | Boolean.

Note: There is a “trap” when generics that contain conditional types accept never as their input parameter,

type GetSNums = never extends number ? number[] : never extends string ? string[] : never; // number[];
  type GetNever = StringOrNumberArray<never>; // never
Copy the code

In line 1 of the above example, because never is a subtype of all types, and of course a subtype of number, an array of number is returned; When never is passed in line 2 as an input parameter to instantiate the generic StringOrNumberArray defined previously, the type returned is never instead of number[].

You should know that the implementation of the generic StringOrNumberArray is no different from the logic to the right of “=” in line 1 of the example (except that never is pulled out as an input parameter). This is because never is an underlying type that cannot be assigned, and if it occurs atomically as an input parameter to the left of the extends keyword, then the instantiated type is also Never.

type UsefulNeverX<T> = T extends{}? T[] : []; type UselessNeverX<T, S> = Sextends{}? S[] : []; type UselessNeverY<T, S> = Sextends{}? T[] : []; type UselessNeverZ<T> = [T]extends({})? T[] : []; type ThisIsNeverX = UsefulNeverX<never>;// never
  type ThisIsNotNeverX = UselessNeverX<never, string>; // string[]
  type ThisIsNotNeverY = UselessNeverY<never, string>; // never[]
  type ThisIsNotNeverZ = UselessNeverZ<never>; // never[]
Copy the code

In line 1 of the example, because the input parameter T of the generic UsefulNeverX we defined is used by extends in the ternary operation, line 5 returns the type never. The generic input parameter T defined in lines 2 and 3 is not used by extends in the ternary operation, so lines 6 through 7 return the types string[] and never[], respectively. In line 4, because the input parameter T is used by extends as T[] rather than as an atom, line 8 also returns the type never[].

Type infer from conditional types

In addition, we can use the type inference operator from a conditional type to infer the components of a type input parameter, such as the type of the element in an array type input parameter.

{
  type ElementTypeOfArray<T> = T extends (infer E)[] ? E : never;
  type isNumber = ElementTypeOfArray<number[]>; // number
  type isNever = ElementTypeOfArray<number>; // never
}
Copy the code

In line 1 of the example, we define a generic ElementTypeOfArray with an input parameter T and define an array with an element type inferred parameter E by (infer E)[] in the condition judgment of triadic operations. When the input parameter T satisfies the condition that it is a subtype of the array type (infer E)[], the parameter E is returned, which is the element type of the array. Therefore, the input parameter [] at line 3 returns the number type and the input value never.

Infer can also derive arbitrary member types by creating arbitrary type inference parameters,

{
  type ElementTypeOfObj<T> = T extends { name: infer E; id: infer I } ? [E, I] : never;
  type isArray = ElementTypeOfObj<{ name: 'name'; id: 1; age: 30} >.// ['name', 1]
  type isNever = ElementTypeOfObj<number>; // never
}
Copy the code

In line 1 of the example, we define the generic ElementTypeOfObj whose input parameter is T, and infer the type of the name and ID attributes of the input parameters by the two infer types. In line 3, the tuple type [‘name’, 1] is extracted because the input parameter is the interface type containing the name and ID attributes. In line 4, the input parameter number does not satisfy the criteria in the ternary operation, so we return never.

Index access type

The index access type is actually more like a way of retrieving materials. First, we can extract the type of any member of the object (interface type) on demand through the attribute name, index, index signature (note: only the syntax of [index name] is used).

interface MixedObject {
    animal: {
      type: 'animal' | 'dog' | 'cat';
      age: number;
    };
    [name: number]: {
      type: string;
      age: number;
      nickname: string;
    };
    [name: string]: {
      type: string;
      age: number;
    };
  }
  type animal = MixedObject['animal'];
  type animalType = MixedObject['animal'] ['type'];
  type numberIndex = MixedObject[number];
  type numberIndex0 = MixedObject[0];
  type stringIndex = MixedObject[string];
  type stringIndex0 = MixedObject['string'];
Copy the code

In line 16 of the example, we get the type of animal property for the MixedObject interface through the ‘Animal’ index. In line 17, we get the type of the deeper type attribute through the multi-level attribute index.

Then, in lines 18 and 19, we get the same interface type defined in lines 6 through 10 using the number type index signature and the numeric index 0.

Finally, on lines 20 and 21, we get the same interface type defined on lines 11 through 14, using the string index signature and the string literal index ‘string’ (recall lesson 7).

keyof

Second, we can also use the keyof keyword to extract the object attribute name, index name, type of index signature,

type MixedObjectKeys = keyof MixedObject; // string | number
  type animalKeys = keyof animal; // 'type' | 'age'
  type numberIndexKeys = keyof numberIndex; // "type" | "age" | "nickname"
Copy the code

Line 1 in the example, we use keyof extracted MixedObject the properties of the interface and the index sign, it is by a string, number, and ‘animal’ type of joint type, after cut is string | number joint type. In line 2, we extract the combined type of the ‘type’ and ‘age’ string literal types. In line 3, we extract the union type of ‘type’, ‘age’, and ‘nickname’.

typeof

The last operator material introduced is Typeof.

If we use Typeof in an expression context, it is the type used to get expression values, and if we use typeof in a type context, it is the type used to get variables or attributes. Of course, in TypeScript, typeof’s main purpose is to get the typeof a variable or property in a type context. Let’s look at a concrete example.

{
  let StrA = 'a';
  const unions = typeof StrA; / / unions type is "string" | "number" | "bigint" | "Boolean" | "symbol" | "undefined" | "object" | "function"
  const str: typeof StrA = 'string'; // STRS is of type string
  type DerivedFromStrA = typeof StrA; // string
}
Copy the code

In line 3 of the example, typeof acts in the context of an expression and takes the typeof the StrA value. Because it is independent of the static type context, the typeof the variable unions is a combination of string literals like ‘string’, ‘number’, etc.

In line 4, typeof acts in the type context and extracts the typeof the variable StrA. Since line 1 infer that StrA’s type is string, the extracted type, STR’s type, is also string.

Of course, we could also use a type alias to specifically receive the type extracted from the variable StrA, such as line 5 in the example, DerivedFromStrA, whose type is string.

We can use typeof to extract the typeof any variable or attribute that does not have an explicit type annotation or whose value is incorporated into the type annotation (such as a function or class), which is a very convenient and useful design.

const animal = {
    id: 1.name: 'animal'
  };
  type Animal = typeof animal;
  const animalFun = () = > animal;
  type AnimalFun = typeof animalFun;
Copy the code

Mapping type

We can use index signature syntax and the in keyword to limit the scope of object properties,

type SpecifiedKeys = 'id' | 'name';
  type TargetType = {
    [key in SpecifiedKeys]: any;
  }; // { id: any; name: any; }
  type TargetGeneric<O extends string | number | symbol> = {
    [key in O]: any;
  }
  type TargetInstance = TargetGeneric<SpecifiedKeys>; // { id: any; name: any; }
Copy the code

In line 1 of the example, we define the joint type SpecifiedKeys, and in line 3, line 6, we qualify the AnimalNormal object and the properties of the generic AnimalGeneric to be members of SpecifiedKeys. So lines 2 and 8 end up with {id: any; name: any; }.

Note: We can only use in in type alias definitions. If we use in interfaces, we get a TS (1169) error,

interface ITargetInterface {
    [key in SpecifiedKeys]: any; // ts(1169)
  }
Copy the code

When defining a type, we can use a combination of in and keyof, and create a new type based on an existing type. Read-only, optional features that make the new type conform to the existing type are called mapped types.

Note: In and keyof can also only be used together in type alias definitions.

interface SourceInterface { readonly id: number; name? : string; } type TargetType = { [keyin keyof SourceInterface]: SourceInterface[key];
  }; // { readonly id: number; name? : string | undefined }
  type TargetGenericType<S> = {
    [key in keyof S]: S[key];
  };
  type TargetInstance = TargetGenericType<SourceInterface>; // { readonly id: number; name? : string | undefined }
Copy the code

In lines 6 and 9 of the example, we create a new type using in and keyof, based on the interface type SourceInterface and the generic input parameter S, respectively. Finally, TargetType on line 5 and TargetInstance on line 11 also get the read-only ID attribute and optional name attribute.

Also, we can use readonly,? Modifiers are used to describe readability and optionability of attributes. You can also add or remove specified modifiers by prefixing them with + and – (default: + and Add).

type TargetGenericTypeReadonly<S> = {
    readonly [key in keyof S]: S[key];
  }
  type TargetGenericTypeReadonlyInstance = TargetGenericTypeReadonly<SourceInterface>; // { readonly id: number; readonly name? : string | undefined }
  type TargetGenericTypeOptional<S> = {
    [key inkeyof S]? : S[key]; } type TargetGenericTypeOptionalInstance = TargetGenericTypeOptional<SourceInterface>;// { readonly id? : number; readonly name? : string | undefined }
  type TargetGenericTypeRemoveReadonly<S> = {
    -readonly [key in keyof S]: S[key];
  }
  type TargetGenericTypeRemoveReadonlyInstance = TargetGenericTypeRemoveReadonly<SourceInterface>; // { id: number; name? : string | undefined }
  type TargetGenericTypeRemoveOptional<S> = {
    [key inkeyof S]-? : S[key]; } type TargetGenericTypeRemoveOptionalInstance = TargetGenericTypeRemoveOptional<SourceInterface>;// { readonly id: number; name: string }
Copy the code

In lines 1 to 3 of the example, we add the readonly modifier to all the attributes, so line 4 gets the type {readonly ID: number; readonly name? : string | undefined}.

In lines 5 to 7, we add? Optional modifier, so line 8 yields the type {readonly ID? : number; readonly name? : string | undefined}.

In lines 9 to 11, we remove the read-only modifier with -readonly, so line 12 gives us {id: number; name? : string | undefined}.

In lines 13 to 15, we pass -? Optional modifiers have been removed, so line 16 yields {readonly ID: number; Name: string}.

Remap keys using AS

Crossing over, since TypeScript 4.1, we can use type assertions in index signatures for mapped types,

type TargetGenericTypeAssertiony<S> = {
    [key in keyof S as Exclude<key, 'id'>]: S[key];
  }
  type TargetGenericTypeAssertionyInstance = TargetGenericTypeAssertiony<SourceInterface>; // { name? : string | undefined; }
Copy the code

In line 2 of the example, we assert that key is a union type excluding ‘id’, so the line 4 instance gets {name? : string | undefined; }.

These are the materials needed to customize the tool type. By combining materials with the official built-in tool type, we could have fun building wheels.

To build the wheels

In fact, in the previous examples, we have implemented custom tool types such as isAssertable and isSubTyping. Next, we will introduce the implementation of several third-party custom tools and types.

Exclude

Exclude<T, U>. Exclude<T, U>. Exclude members of the U subtype of the input parameter T.

type ExcludeSpecifiedNumber = Exclude<1 | 2.1>; / / 2
  type ExcludeSpecifiedString = Exclude<'id' | 'name'.'id'>; // 'name
  type ExcludeSpecifiedBoolean = Exclude<boolean, true>; // false
Copy the code

In lines 1 to 3 of the example, excluding the specified members of type 1, ‘id’, true yields type 2, ‘name’, false.

We can in the VS Code Ctrl/Command + click to view it Exclude in node_modules/typescript/lib/lib. Es5. In which s definition,

type Exclude<T, U> = T extends U ? never : T;
Copy the code

This is an obvious use of the assignment condition type, so the input parameter T is split into member types. Return never if the member type is a subtype of the input parameter U, otherwise return the member type.

When the refs are respectively joint type 1 | 2 and literal type 1, because the members of the joint type was dismantled after the subtypes of 1 is 1, and 2 members not 1 subtype, so return the joint type never | 2. Since never is a subtype of 2, the final type is reduced to 2.

Let’s start with a custom tool type called ReturnTypeOfResolved.

ReturnTypeOfResolved

The difference between ReturnTypeOfResolved and official ReturnType: if the ReturnType of input parameter F is an instance of a generic Promise, return the input that the Promise received.

We can use the definition of ReturnType to implement ReturnTypeOfResolved,

// type ReturnType
      
        any> = T extends (... args: any) => infer R ? R : any;
      
  type ReturnTypeOfResolved<F extends(... args: any) => any> = Fextends(... args: any[]) =>Promise<infer R> ? R : ReturnType<F>;
  type isNumber = ReturnTypeOfResolved<() = > number>; // number
  type isString = ReturnTypeOfResolved<() = > Promise<string>>; // string
Copy the code

In the example, the code commented in line 1 is an implementation of the official tool type ReturnType, and in line 2 we’ve customized a generic ReturnTypeOfResolved and constrained that the input parameter F must be a function type. When the return value of the input parameter F is a Promise type, we infer from the condition type that infer gets the Promise input type, so the return type of the input function number is returned in line 3. Line 4 returns a Promise input parameter type string.

Merge

Merge<A, B>, Merge<A, B>, Merge<A, B>

 type Merge<A, B> = {
    [key in keyof A | keyof B]: key extends keyof A
      ? key extends keyof B
        ? A[key] | B[key]
        : A[key]
      : key extends keyof B
      ? B[key]
      : never;
  };
  type Merged = Merge<{ id: number; name: string }, { id: string; age: number }>;
Copy the code

In line 2, we qualify the return type attribute key to be the combined type of the input parameter A and B attributes. When the key for A and B of the same name attribute, the combined attribute types for joint type [key] A [key] | B (line 1, 2, 4); When key is an attribute of A or B, the merged attribute type is A[key] or B[key] (line 5, 7).

Finally, we use the Merge in the exercise of 10 Merge two interface types, thus get the {id: number | string; name: string; Age: number}.

Equal

Let’s implement a custom utility type Equal<S, T> that can be used to determine whether the input parameters S and T are of the same type. Boolean literal type true if the same, false otherwise.

At this point, it is easy to think that if S is a subtype of T and T is a subtype of S, then S and T are of the same type, so the implementation of Equal looks like this:

type EqualV1<S, T> = S extends T ? T extends S ? true : false : false;
type ExampleV11 = EqualV1<1 | number & {}, number>; // true but boolean
type ExampleV12 = EqualV1<never, never>; // true but never
Copy the code

In line 1 of the example, we implement the generic EqualV1; The first into the parameter of line 2 is joint type, because distribution conditions in the set, so the first type into the ginseng was dismantled, eventually return type Boolean joint type (which is actually true | false). Similarly, in line 3, the type never is returned when the input parameter is never. Therefore, EqualV1 did not meet our expectations.

At this point, we need to use [] to untrap the conditional assignment type and never to ensure that custom generics only return true or false, so the modified version of the previous example EqualV2 looks like this:

type EqualV2<S, T> = [S] extends [T] ? [T] extends [S] ? true : false : false;
  type ExampleV21 = EqualV2<1 | number & {}, number>; // true
  type ExampleV22 = EqualV2<never, never>; // true
  type ExampleV23 = EqualV2<any, number>; // false but true
Copy the code

In lines 2 and 3 of the example, although we solved the union type and never problem, we still couldn’t distinguish the balm type any from the other types. In line 4, when the input arguments are any and number, false is expected, but true is returned.

We also need to use a modified version of EqualV3 that recognizes any as follows:

type IsAny<T> = 0 extends (1 & T) ? true : false;
  type EqualV3<S, T> = IsAny<S> extends true
    ? IsAny<T> extends true
      ? true
      : false
    : IsAny<T> extends true
    ? false
    : [S] extends [T]
    ? [T] extends [S]
      ? true
      : false
    : false;
  type ExampleV31 = EqualV3<1 | number & {}, number>; // true but false got
  type ExampleV32 = EqualV3<never, never>; // true
  type ExampleV34 = EqualV3<any, any>; // true
  type ExampleV33 = EqualV3<any, number>; // false
  type ExampleV35 = EqualV3<never, any>; // false 
Copy the code

In line 1 of the example, we define the generic IsAny that can distinguish any from other types, because only the type (any) intersected by any and 1 is the parent of 0, so it returns true if the input IsAny and false otherwise.

In lines 2-7, we define EqualV3 (which first specifically handles the case where at least one of the type inputs S and T is any), returning true if S and T are both any, and false otherwise. Therefore, in lines 15 through 17, EqualV3 is able to distinguish any from other types.

In lines 8-12, we reuse EqualV2’s logic and disarm the conditional assignment type by [], So line 13 ~ 14 EqualV3 can determine joint type 1 | number & {} and number, never and never be the same type.

At this point, our first wheel Equal (in fact, IsAny, the generic type used to distinguish any types, counts as a wheel as well) can roughly distinguish most types correctly.

Question to consider: Try to find a counter example of ExampleV3’s incorrect partitioning type and improve ExampleV3.

Summary and preview

This tutorial introduces a number of knowledge points and features. You can combine the above knowledge points, features, tool types, as well as the official tool type source code implementation analysis. Only in this way can we construct more complex tool types.

Question to consider: What is an assignment condition type?