TS is at the front end of the pack because of its powerful type system. And TS’s type system is Turing complete, which means it can be used to perform logical operations, what we jokingly call type gymnastics 😂. As a result, we often see extremely complex type definitions in some libraries.
There are always two sides to the story. A powerful type system brings complex syntax and type definitions that can exceed the amount of code that implements the business code itself, making it slightly more expensive to get started.
This article will unlock the mysterious veil of type gymnastics, step by step from the most simple advanced type to the use of complex type programming, take you to play type gymnastics.
Value space and type space
In TS, there are two Spaces: the value space and the type space. The value space is used to hold entity content that will be compiled into JS, while the type space is used to hold various type information that will be completely erased after compilation. The two Spaces are related to each other, but do not affect each other.
Take class as an example:
class Person {
name: string;
constructor() {
this.name = "Zhang"; }}// p is of type Person, which comes from the type space
let p = new Person(); // When using new Person, the Person is a value from the value space
Copy the code
Examples such as class and enum span two Spaces, but when used as values or types, they correctly retrieve information from different Spaces, even though they are named the same in both Spaces.
The type programming that follows is done in type space, which is syntactically different and limited from regular value space, but we can understand them by analogy.
Basic type programming
keyin
Code reuse is an important operation in programming, so how to reuse types?
type FriendList = {
count: number;
friends: {
firstName: string;
lastName: string; } []; };type Friends = FriendList["friends"];
/ / equivalent to the
// type Friends = {
// firstName: string;
// lastName: string;
/ /} [];
type Friend = Friends[number];
/ / equivalent to the
// type Friend = {
// firstName: string;
// lastName: string;
// };
Copy the code
The keyin approach is very similar to the way you get attributes in an object. And TS only supports array access to member types, not array access. Syntax. And if the current type is an array or tuple, you can further get the subitem type of the array by using [number] or [1].
keyof
Keyof is used to get object attribute names and use these attribute names to form the union type:
type Friend = {
firstName: string;
lastName: string;
};
type Keys=keyof Friend; // "firstName" | "lastName"
Copy the code
If the keyof operation is of type class, all public property names for that class are retrieved:
class Person {
public name: string;
protected age: number;
private sex: boolean;
public foo() {}
protected bar() {}
private baz(){}}type Keys=keyof Person; // "name" | "foo"
Copy the code
The mapping and remapping
TS can also iterate over and modify each item in a type, such as the built-in Readonly utility type, which can convert each item in a type to Readonly:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Friend = {
firstName: string;
lastName: string;
};
type ReadonlyFriend=Readonly<Friend>;
/ / equivalent to the
// type ReadonlyFriend = {
// readonly firstName: string;
// readonly lastName: string;
// };
Copy the code
Where [P in keyof T] means to traverse each term of T, which we can compare to the value space for… in… Syntax.
Since it is possible to add type restrictions, it is also natural to erase them with the +/- operator:
type RemoveReadonly<T> = {
-readonly [P in keyof T]: T[P];
};
// Readonly is equivalent to + Readonly in the utility type, but it is usually ignored, just as the + prefix is not written in positive numbers
type ReadonlyFriend = {
readonly firstName: string;
readonly lastName: string;
};
type Friend = RemoveReadonly<ReadonlyFriend>;
/ / equivalent to the
// type Friend = {
// firstName: string;
// lastName: string;
/ /};
Copy the code
TS also added the remapping function after 4.1. Specifically, a Key can be converted to another Key by using the AS keyword:
type CapitalKey<T> = {
[P in keyof T as `${Capitalize<P>}`]: T[P];
};
type Friend = {
firstName: string;
lastName: string;
};
type CFriend = CapitalKey<Friend>;
/ / equivalent to the
// type CFriend = {
// FirstName: string;
// LastName: string;
// };
Copy the code
Capitalize is also a built-in Intrinsic string types of TS that is appropriate for use with the remapping feature.
Extends and Infer join forces
All logical operations in TS depend on extends, such as T extends U? X: Y, indicating that the return type is X if T is a subclass of U, and Y otherwise. Like ternary expressions, with generics, you can implement a type utility function:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
Copy the code
Among them:
IsString
Can be analogous to a value space function that takes argumentsT
IsString<string>
It’s like a function call, where the argument isstring
Extends also has a handy feature called type distribution: if the argument preceding extends is a bare union type, it breaks down the union type for evaluation (iterating through all the subtypes in order to determine the condition). The final result is then formed into a new union type.
// Type is a bare Type and will be distributed
type NakedToArray<Type> = Type extends any ? Type[] : never;
type t1 = NakedToArray<string | number>; // string[] | number[];
// [Type] is not a bare Type and will not be distributed
type ToArray<Type> = [Type] extends [any]? Type[] :never;
type t2 = ToArray<string | number>; // (string | number)[]
Copy the code
Infer is always used with the extends keyword. We call the infer behavior pattern matching. Look at the built-in ReturnType tool type:
type ReturnType<T extends(... args:any) = >any> = T extends (
...args: any
) => infer R
? R
: any;
type Foo = () = > string;
type R = ReturnType<Foo>; // string
Copy the code
Pattern matching simply means matching type parameters using extends. If a match is successful, the result is saved into local type variables declared by infer.
For example, pattern matching is like matching a portrait of a criminal (T extends (… Args: any => infer R), and the police will match the suspects (argument Foo) by their portraits. If a match is matched, the suspects will be identified (infer R), for example, Sam (string).
Pattern matching can specify matches of any granularity:
/ / array infer
type Infer1<T> = T extends (infer S)[] ? S : never;
type a1 = Infer1<string>; // never
type a2 = Infer1<string[] >;// string
// Infer
type Infer2<T> = T extends [infer S] ? S : never;
type b1 = Infer2<[string.number>;// never
type b2 = Infer2<[string>;// string
Infer; // Infer
// R is also a tuple type with a minimum of zero numbers
type Infer3<T> = T extends [infer S, ...infer R] ? [S, R] : never;
type c1 = Infer3<[]>; // never
type c2 = Infer3<[string>;// [string, []]
type c3 = Infer3<[string.number>;// [string, [number]]
// String literal infer
type Infer4<T> = T extends `${infer S}` ? S : never;
type d1 = Infer4<"str">; // str
type d2 = Infer4<1>; // never
// String literal infer
type Infer5<T> = T extends `${infer S}${infer R}` ? [S, R] : never;
type e1 = Infer5<"">; // never
type e2 = Infer5<"s">; // ["s", ""]
type e3 = Infer5<"st">; // ["s", "t"]
// String literal infer
// Get the literal before and after the delimiter
type Infer6<T> = T extends `${infer S}__${infer R}` ? [S, R] : never;
type f1 = Infer6<"">; // never
type f2 = Infer6<"str1__str2">; // ["str1", "str2"]
type f3 = Infer6<"str1__str2__str3">; // ["str1", "str2__str3"]
// Other types of infer
type Infer7<T> = T extends Promise<infer R> ? R : never;
type g1 = Infer7<"">; // never
type g2 = Infer7<Promise<number> >;// number
Copy the code
See online demo and more granular infer.
So how does TS loop? The answer is recursion.
For example, splitting a string literal type into a tuple type:
type StrToTuple<T extends string> = T extends `${infer F}${infer L}`
? [F, ...StrToTuple<L>]
: [];
type t1=StrToTuple<"foo"> // ["f", "o", "o"]
Copy the code
View the online Demo.
As with value space recursion, if the recursion is very deep, it will overflow. The depth of the recursion in TS is about 50 levels, and we can use tail recursion optimization to increase the depth to 1000 levels (see the official documentation for details) :
type StrToTupleOptimize<T extends string,R extends string[]=[]> = T extends `${infer F}${infer L}`
? StrToTupleOptimize<L,[...R,F]>
: R;
Copy the code
View the online Demo.
never
Never is a very useful type in type programming because it represents a type that does not exist, so in type programming any type that contains never will ignore never. Let’s look at the built-in tool types Exclude:
type Exclude<T, U> = T extends U ? never : T;
type t1 = "a" | "b" | "c" | "d";
type t2 = "a" | "b";
/ / in accordance with the distribution characteristics of t3 should be never | never | | "c" "d", and never will be omitted, so the end result is a "c" | "d"
type t3 = Exclude<t1, t2>; // "c" | "d"
Copy the code
challenge
Implement the addition of two numbers
type Add<T1 extends number,T2 extends number> =??type Five=Add<3.2>; / / 5
Copy the code
The type system does not Add directly, so obviously type Add
= T1 + T2.
Here we need to use the property of tuples: tuples are arrays of fixed length, so TS can know exactly the length of tuples, that is:
type Tuple1=[1.2.3];
type LengthOfTuple1=Tuple1["length"]; / / 3
Copy the code
So the idea of addition is:
-
Construct tuples of length T1 and T2 respectively
-
The length of the combined tuple is the result of the addition operation
// Construct a tuple of length T
type GetTuple<T extends number, R extends any[] = []> = R["length"] extends T
? R
: GetTuple<T, [...R, any>;type Add<T1 extends number, T2 extends number> = [
...GetTuple<T1>,
...GetTuple<T2>
]["length"];
type Five = Add<3.2>; / / 5
Copy the code
View the online Demo.
The ability of tuples to obtain length information is very common in type programming and is the basis of many advanced operations.
Implementing the Curry definition
Next comes the difficulty of implementing a type definition for a Corrified function. Currization is an important concept in functional programming. Through currization, ordinary functions can be converted into function chains that can be called many times, and delayed binding of parameters can be realized:
const add = (x: number, y: number, z: number) = > x + y + z;
// Assuming that the curry function already exists, we won't worry about its implementation details here
const curriedAdd = curry(add);
// All the following operations are legal
const result1 = curriedAdd(1.2.3);
const result2 = curriedAdd(1) (2) (3);
const result3 = curriedAdd(1) (2.3);
const result4 = curriedAdd()()(1) (2.3);
Copy the code
It can be seen that the characteristics of Currization are:
- A Currified function can take arguments of any length, and the arguments must be of the same type as the original function
- When the number of arguments received by a function is less than the number of arguments received by the original function, a new function is returned to collect the remaining arguments
- Once the number of arguments received by the function matches that of the original function, the operation of the original function is performed and the result is returned
To implement Curry, we first need to know the parameters and return values of the function passed in:
// From the generic function, get the parameter list P and return value R of the passed function
declare function curry<P extends any[].R> (fn: (... args: P) => R) :Curried<P.R>;
type Curried<P, R> = [P, R];
const add = (x: number, y: number, z: number) = > x + y + z;
curry(add) P = [number, number, number], R = number
Copy the code
According to the above characteristics, we will first implement a utility function:
// Get the length of the argument list
type Length<T extends any[]> = T["length"];
// Get the remaining items of the tuple except the first item
type Tail<T extends any[]> = T extends [infer F, ...infer L] ? L : [];
// Subtract the first n elements of the tuple
// R is a variable used for counting, so you can see that the number of tuples is fixed
type Drop<
T extends any[],
N extends number,
R extends any[] = []
> = T extends[]? [] : Length<R>extends N ? T : Drop<Tail<T>, N, [...R, any>;type d1 = Drop<[1.2.3.4].1>; / / [2, 3, 4]
type d2 = Drop<[1.2.3.4].3>; / / [4]
type d3 = Drop<[1.2.3.4].5>; / / []
type d4 = Drop<[1.2.3.number[]].4>; / / []
// Get all possible combinations of tuples
type PartialTuple<T extends any[]> = T extends [infer F, ...infer L]
? [] | [F] | [F, ...PartialTuple<L>]
: T;
type p1 = PartialTuple<[1.2.3>;/ / [1, 2, 3] | | [1, 2] [1] | []
Copy the code
Next, implement the body of Curried:
type Curried<P extends any[], R> = <T extends PartialTuple<P>>( ... args: T ) => Length<T> extends Length<P> ? R : Curried<Drop<P, Length<T>>, R>;Copy the code
View the online Demo.
However, if the rest parameter is present, the above notation is a bit problematic:
const add = (x: number, y: number. z:number[]) = >
x + y + z.reduce((sum, i) = > sum + i);
// The main reason is that the result of the following l is number, so there is a problem in determining whether the existing parameters satisfy the original function's parameters
type l=Length<[number.number. number[]]>Copy the code
At this point, the way of judging needs to be changed:
type Curried<P extends any[], R> = <T extends PartialTuple<P>>( ... args: T ) => Drop<P,Length<T>> extends [] ? R : Curried<Drop<P, Length<T>>, R>; // We can determine whether the target parameter number has been reached by dropping the quantity of T length from P and whether the remaining tuple is an empty tupleCopy the code
View the online Demo.
Some types of programming tips
Check whether it is never
type isNever<T> = [T] extends [never]?true : false;
type i1 = isNever<never>; // true
type i2 = isNever<string>; //false
Copy the code
Note that T must be wrapped, not bare. This is because TS distributes the bare type to the left of extends, but when it encounters never, TS decides that it doesn’t make sense to distribute never and returns never without logical judgment.
Overloaded function type inference
For overloaded function types, TS takes the last type definition:
declare function foo(x: string) :number;
declare function foo(x: number) :string;
declare function foo(x: string | number) :string | number;
type p1 = Parameters<typeof foo>; // string | number
type r1 = ReturnType<typeof foo>; // string | number
// The overloading definition above is equivalent to
type Foo = {
(x: string) :number;
(x: number) :string;
(x: string | number) :number | string;
};
type p2 = Parameters<Foo>; // string | number
type r2 = ReturnType<Foo>; // string | number
// also equivalent to
type Foo1 = ((x: string) = > number) &
((x: number) = > string) &
((x: string | number) = > number | string);
type p3 = Parameters<Foo>; // string | number
type r3 = ReturnType<Foo>; // string | number
Copy the code
Turn the union intersection computes
When strictFunctionTypes is enabled, function parameter positions are invert. And in the contravariant position, multiple candidate types of a variable of the same type will be inferred as intersecting types:
type UnionToIntersection<T> = (T extends any ? (x: T) = > void : never) extends (
x: infer R
) => void
? R
: never;
type u1 = UnionToIntersection<"str" | string>; // string
type u2 = UnionToIntersection<"str1" | "str2">; // "str1" & "str2" -> never
Copy the code
Turn the union a tuple
Needs to be “a” | | “b” “c” into [” a “, “b”, “c”), the need to use the above mentioned two tips:
- We first convert union to the function type union
- The function union becomes function overload by using the property of union turning intersection
- Finally, when using overloaded type inference, the last one prevails and the last union type is separated out
- The remaining union types repeat the process until the end
type LastOfUnion<T> = UnionToIntersection<
T extends any ? (x: T) = > void : never
> extends (x: infer L) => void
? L
: never;
type UnionToTuple<T, R extends any[] = []> = isNever<T> extends true
? R
: UnionToTuple<Exclude<T, LastOfUnion<T>>, [LastOfUnion<T>, ...R]>;
type t1 = UnionToTuple<"1" | "2" | "3" | "4">; // ["1", "2", "3", "4"]
Copy the code
View the online Demo.
Determine that two types are equal
type IsEqual<T1, T2> = (<U>() = > U extends T1 ? true : false) extends <
U
>() = > U extends T2 ? true : false
? true
: false;
type e1 = IsEqual<{ name: string }, { name: string} >.// true
type e2 = IsEqual<{ name: string }, { readonly name: string} >.// false
type e3 = IsEqual<number.string>; // false
Copy the code
Why is this so? See How does the Equals Work in typescript? I have made it very clear that I will not repeat it here.
Grind leveling
It is recommended to visit Type-challenges, which contain a number of type programming challenges. Believe that after customs clearance, you will be able to play type gymnastics 👍👍👍!
So far is the entire content of this article, I hope to help you, if there is any wrong place, please comment 😁.