TypeScript advanced usage

Posted by kimberlc on Sun, 30 Jan 2022 09:17:20 +0100

typescript advanced usage

introduction
As a powerful static type checking tool, TypeScript can be seen in many large and medium-sized applications and popular JS libraries. As a weakly typed language, JS will modify the type of variables without paying attention in the process of writing code, resulting in some unexpected runtime errors. However, TypeScript can help us solve this problem in the compilation process. It not only introduces strong type checking in JS, but also the compiled JS code can run in any browser environment, Node environment and any JS engine supporting ECMAScript 3 (or higher). Recently, the company just plans to use TypeScript to reconstruct the existing system. In the past, there were few opportunities to use TypeScript, especially some useful advanced usage. Therefore, take this opportunity to consolidate and consolidate the knowledge points in this regard. If there are mistakes, please point out them.

1. Class inheritance
In ES5, we generally use function or prototype based inheritance to encapsulate some common parts of components for reuse. However, in TypeScript, we can use class inheritance in an object-oriented manner like in Java language to create reusable components. We can use the class keyword to create a class and use the new operator to instantiate an object based on it. In order to abstract the common part of multiple classes, we can create a parent class and let the child class inherit the parent class through the extends keyword, so as to reduce the writing of some redundant code and increase the reusability and maintainability of the code. Examples are as follows:

class Parent {
    readonly x: number;
    constructor() {
        this.x = 1;
    }
     
    print() {
        console.log(this.x);
    }
}
 
class Child extends Parent {
    readonly y: number;
    constructor() {
        // Note that the super() method must be called first here
        super();
        this.y = 2;
    }
     
    print() {
        // Call the method on the parent class prototype through super, but this in the method points to the instance of the child class
        super.print();
        console.log(this.y);
    }
}
 
const child = new Child();
console.log(child.print()) // -> 1 2


In the above example, the Child subclass overrides the print method of the parent class and uses super internally Print () to call the public logic of the parent class, so as to realize logic reuse. Class keyword is used as the syntax sugar of constructor. After TypeScript compilation, it will eventually be converted into ES5 code recognized by browser with good compatibility. Class is very common in the object-oriented programming paradigm, so in order to find out the implementation mechanism behind it, we might as well spend more time to see what the code looks like after compilation and conversion (of course, students who are already familiar with this part can skip it directly).

var __extends = (this && this.__extends) || (function () {
    var extendStatics = function (d, b) {
        extendStatics = Object.setPrototypeOf ||
            ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
            function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
        return extendStatics(d, b);
    }
    return function (d, b) {
        extendStatics(d, b);
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    };
})();
var Parent = /** @class */ (function () {
    function Parent() {
        this.x = 1;
    }
    Parent.prototype.print = function () {
        console.log(this.x);
    };
    return Parent;
}());
var Child = /** @class */ (function (_super) {
    __extends(Child, _super);
    function Child() {
        var _this = 
        // Note that the super() method must be called first here
        _super.call(this) || this;
        _this.y = 2;
        return _this;
    }
    Child.prototype.print = function () {
        // Call the method on the parent class prototype through super, but this in the method points to the instance of the child class
        _super.prototype.print.call(this);
        console.log(this.y);
    };
    return Child;
}(Parent));
var child = new Child();
console.log(child.print()); // -> 1 2


The above is the complete code after conversion. In order to facilitate comparison, the original annotation information is retained here. After careful study of this code, we will find the following points:
1) The super() method in the constructor of subclass Child is converted to var_ this = _ super. Call (this) | this, here_ Super refers to the Parent of the Parent class. Therefore, the meaning of this code is to call the constructor of the Parent class and bind this to the instance of the Child class. In this way, the Child class instance can have the x attribute of the Parent class. Therefore, in order to implement the property inheritance, we must call the super() method in the subclass constructor. If it is not called, it will not compile.

2) Super. In the print method of subclass Child The print () method is converted to_ super.prototype.print.call(this), the meaning of this code is to call the print method on the parent class prototype and point this in the method to the Child class instance. Since we have inherited the x attribute of the parent class in the previous operation, here we will directly print the value of the x attribute of the Child class instance.

3) The extends keyword is eventually converted to__ Extensions (child, _super) method, where_ Super refers to the Parent class. For the convenience of viewing, it will be used here_ Extensions method is proposed separately for research.

var __extends = (this && this.__extends) || (function () {
    var extendStatics = function (d, b) {
        extendStatics = Object.setPrototypeOf ||
            ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
            function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
        return extendStatics(d, b);
    }
    return function (d, b) {
        // Part I
        extendStatics(d, b);
         
        // Part II
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    };
})();


The above code can be mainly divided into two parts for understanding. The first part is the extendstatistics (D, b) method, and the second part is the two lines of code behind the method.

Part I:

Although there is a large amount of code in the extendstatistics method, it is not difficult to find that it is mainly for compatibility with the execution environment of ES5 version. Added object in ES6 The setprototypeof method is used to set the prototype of the object manually, but in the environment of ES5, we usually use a non-standard method__ proto__ Property, object The principle of setprototypeof method is actually to set the prototype of the object through this attribute. Its implementation method is as follows:

Object.setPrototypeOf = function(obj, proto) {
    obj.__proto__ = proto;
    return obj;
}


In the extendstatistics (d, b) method, d refers to Child and B refers to Parent. Therefore, the function of this method can be explained as follows:

// The subclass Child__ proto__ Property points to the Parent class
Child.__proto__ = Parent;


This line of code can be understood as the inheritance of the constructor, or the inheritance of static attributes and static methods, that is, the attributes and methods are not attached to the prototype of the constructor, but directly attached to the constructor itself, because in JS, the function itself can also be used as an object and can be given any other attributes. Examples are as follows:

function Foo() {
  this.x = 1;
  this.y = 2;
}
 
Foo.bar = function() {
  console.log(3);
}
 
Foo.baz = 4;
console.log(Foo.bar()) // -> 3
console.log(Foo.baz) // -> 4


So when we use Child. In the subclass Child When someproperty accesses a property, if it does not exist in the subclass, it will be accessed through Child__ proto__ Find the attribute with the same name of the parent class. In this way, you can find the path of static attributes and static methods.

Part II:

The second part contains only the following two lines of code:

function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());


Where d refers to Child and b refers to Parent. Students who are familiar with several methods of inheritance in JS can see at a glance that parasitic combinatorial inheritance is used here by borrowing an intermediate function__ () to avoid the impact on the prototype of the Parent class when modifying the method on the prototype of the Child class. We know that after instantiating an object through a constructor in JS, the object will have a__ proto__ Property and point to the prototype property of its constructor. An example is as follows:

function Foo() {
  this.x = 1;
  this.y = 2;
}
 
const foo = new Foo();
foo.__proto__ === Foo.prototype; // -> true


In this example, if an object is instantiated through the subclass Child, the following associations will be generated:

const child = new Child();
child.__proto__ === (Child.prototype = new __());
child.__proto__.__proto__ === __.prototype === Parent.prototype; 
 
// The above code is equivalent to the following method
Child.prototype.__proto__ === Parent.prototype;


Therefore, when we pass child. In the instance child object of subclass child When somemethod() calls a method, if the method does not exist in the instance, it will follow__ proto__ If you continue to look up, you will eventually pass through the prototype of the Parent class, that is, to realize the inheritance of methods in this way.

Based on the analysis of the above two parts, we can summarize the following two points:

// Indicates that the inheritance of constructors, or static properties and methods, always points to the parent class
1. Child.__proto__ === Parent;
 
// The inheritance of the representation method always points to the prototype attribute of the parent class
2. Child.prototype.__proto__ === Parent.prototype;


2. Access modifier
TypeScript provides us with access modifiers to restrict access to internal attributes outside the class. The access modifiers mainly include the following three types:

Public: public modifier. Its modified properties and methods are public and can be accessed anywhere. By default, all properties and methods are public.
Private: a private modifier whose modified properties and methods are not visible outside the class.
Protected: protected modifier, which is similar to private, but its modified properties and methods are allowed to be accessed inside subclasses.
We use some examples to compare several modifiers:

class Human {
    public name: string;
    public age: number;
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}
 
const man = new Human('tom', 20);
console.log(man.name, man.age); // -> tom 20
man.age = 21;
console.log(man.age); // -> 21


In the above example, since we set the access modifier to public, it is allowed for us to access the name and age attributes through the instance man, and it is also allowed to re assign the age attribute. However, in some cases, if we want some attributes to be invisible and not allowed to be modified, we can use the private modifier:

class Human {
    public name: string;
    private age: number; // Modify here to use the private modifier
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}
 
const man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age);
// -> Property 'age' is private and only accessible within class 'Human'.


After modifying the modifier of the age attribute to private, we use man When you access it, TypeScript will find that it is a private property at the compilation stage and will eventually report an error.

Note: there is no restriction on access to private properties in the compiled code of TypeScript.

The compiled code is as follows:

var Human = /** @class */ (function () {
    function Human(name, age) {
        this.name = name;
        this.age = age;
    }
    return Human;
}());
var man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age); // -> 20


Attributes or methods decorated with the private modifier are also not allowed to be accessed in subclasses. Examples are as follows:

class Human {
    public name: string;
    private age: number;
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}
 
class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
        console.log(this.age);
    }
}
 
const woman = new Woman('Alice', 18);
// -> Property 'age' is private and only accessible within class 'Human'.


In the above example, because the age attribute in the parent class Human is set to private, the age attribute cannot be accessed in the subclass Woman. In order to allow access to the age attribute in the subclass, we can use the protected modifier to modify it:

class Human {
    public name: string;
    protected age: number; // Modify here to use the protected modifier
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}
 
class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
        console.log(this.age);
    }
}
 
const woman = new Woman('Alice', 18); // -> 18


When we use the private modifier in the constructor, it means that the class is not allowed to be inherited or instantiated. The example is as follows:

class Human {
    public name: string;
    public age: number;
     
    // Modify here to use the private modifier
    private constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}
 
class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
    }
}
 
const man = new Human('Alice', 18);
// -> Cannot extend a class 'Human'. Class constructor is marked as private.
// -> Constructor of class 'Human' is private and only accessible within the class declaration.


When we use the protected modifier in the constructor, it means that this class is only allowed to be inherited. The example is as follows:

class Human {
    public name: string;
    public age: number;
     
    // Modify here to use the protected modifier
    protected constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}
 
class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
    }
}
 
const man = new Human('Alice', 18);
// -> Constructor of class 'Human' is protected and only accessible within the class declaration.


In addition, we can also directly put the modifier into the parameters of the constructor, for example:

class Human {
    // public name: string;
    // private age: number;
     
    public constructor(public name: string, private age: number) {
        this.name = name;
        this.age = age;
    }
}
 
const man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age);
// -> Property 'age' is private and only accessible within class 'Human'.


3. Interface and constructor signature
When there are many different classes in our project, and there may be some common ground between these classes, in order to describe this common ground, we can extract it into an interface for centralized maintenance, and use the implements keyword to implement this interface. An example is as follows:

interface IHuman {
    name: string;
    age: number;
    walk(): void;
}
 
class Human implements IHuman {
     
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }
 
    walk(): void {
        console.log('I am walking...');
    }
}


The above code can pass smoothly in the compilation stage, but we notice that the constructor constructor is included in the Human class. If we want to define a signature for the constructor in the interface and let the Human class implement the interface, see what happens:

interface HumanConstructor {
  new (name: string, age: number);    
}
 
class Human implements HumanConstructor {
     
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }
 
    walk(): void {
        console.log('I am walking...');
    }
}
// -> Class 'Human' incorrectly implements interface 'HumanConstructor'.
// -> Type 'Human' provides no match for the signature 'new (name: string, age: number): any'.


However, TypeScript will compile incorrectly, telling us that the HumanConstructor interface is implemented incorrectly. This is because when a class implements an interface, only the instance part will be compiled and checked, and the static part of the class will not be checked by the compiler. Therefore, here we try another way to directly operate the static part of the class. The example is as follows:

interface HumanConstructor {
  new (name: string, age: number);    
}
 
interface IHuman {
    name: string;
    age: number;
    walk(): void;
}
 
class Human implements IHuman {
     
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }
 
    walk(): void {
        console.log('I am walking...');
    }
}
 
// Define a factory method
function createHuman(constructor: HumanConstructor, name: string, age: number): IHuman {
    return new constructor(name, age);
}
 
const man = createHuman(Human, 'tom', 18);
console.log(man.name, man.age); // -> tom 18


In the above example, by creating an additional factory method createHuman and passing the constructor as the first parameter, when we call createHuman(Human, 'tom', 18), the compiler will check whether the first parameter conforms to the constructor signature of the HumanConstructor interface.

4. Declaration merge
The most common consolidation type in declaration consolidation is interface, so here we will introduce several common consolidation methods starting from interface.

4.1 interface consolidation
The example code is as follows:

interface A {
    name: string;
}
 
interface A {
    age: number;
}
 
// Equivalent to
interface A {
    name: string;
    age: number;
}
 
const a: A = {name: 'tom', age: 18};


The method of interface merging is easy to understand, that is, declare multiple interfaces with the same name, and each interface contains different attribute declarations. Finally, these attribute declarations from multiple interfaces will be merged into the same interface.

Note: non function members in all interfaces with the same name must be unique. If they are not unique, they must be of the same type, otherwise the compiler will report an error. For function members, the interface with the same name declared later will overwrite the interface with the same name declared earlier, that is, the function in the interface with the same name declared later is equivalent to an overload and has higher priority.

4.2 function merging
Function merging can be simply understood as function overloading, that is, it is realized by defining multiple functions with the same name with different type parameters or different type return values at the same time. The example code is as follows:

// Function definition
function foo(x: number): number;
function foo(x: string): string;
 
// Function implementation
function foo(x: number | string): number | string {
    if (typeof x === 'number') {
        return (x).toFixed(2);
    }
     
    return x.substring(0, x.length - 1);
}


In the above example, we define the foo function several times. The function parameter types and return value types are different each time. The last time is the specific implementation of the function. In the implementation, the compiler will not report an error only when it is compatible with all the previous definitions.

Note: TypeScript compiler will give priority to matching from the first function definition. Therefore, if there is an inclusion relationship between multiple function definitions, you need to put the most accurate function definition first, otherwise it will never be matched.

4.3 type alias Union
Type alias combination is different from interface combination. A type alias does not create a new type, but creates a new alias to reference multiple types. At the same time, it cannot be implemented and extended like an interface. An example is as follows:

type HumanProperty = {
    name: string;
    age: number;
    gender: number;
};
 
type HumanBehavior = {
    eat(): void;
    walk(): void;
}
 
type Human = HumanProperty & HumanBehavior;
 
let woman: Human = {
    name: 'tom',
    age: 18,
    gender: 0,
    eat() {
        console.log('I can eat.');
    },
    walk() {
        console.log('I can walk.');
    }
}
 
class HumanComponent extends Human {
    constructor(public name: string, public age: number, public gender: number) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }
     
    eat() {
        console.log('I can eat.');
    }
     
    walk() {
        console.log('I can walk.');
    }
}
// -> 'Human' only refers to a type, but is being used as a value here.


5. keyof index query
keyof in TypeScript is a bit similar to object in JS Keys() method, but the difference is that the former traverses the string index in the type, while the latter traverses the key name in the object. An example is as follows:

interface Rectangle {
    x: number;
    y: number;
    width: number;
    height: number;
}
 
type keys = keyof Rectangle;
// Equivalent to
type keys = "x" | "y" | "width" | "height";
 
// Generics are used here, forcing the parameter name of the second parameter to be included in all string indexes of the first parameter
function getRectProperty<T extends object, K extends keyof T>(rect: T, property: K): T[K] {
    return rect[property];
} 
 
let rect: Rectangle = {
    x: 50,
    y: 50,
    width: 100,
    height: 200
};
 
console.log(getRectProperty(rect, 'width')); // -> 100
console.log(getRectProperty(rect, 'notExist'));
// -> Argument of type '"notExist"' is not assignable to parameter of type '"width" | "x" | "y" | "height"'.


In the above example, we use keyof to restrict the parameter name of the function. Property must be included in all string indexes of type Rectangle. If it is not included, the compiler will report an error, which can be used to detect whether the property name of the object is written incorrectly at compile time.

6. Partial optional properties
In some cases, we hope that all attributes in the type are not required and exist only under certain conditions. We can use Partial to identify all attributes in the declared type as optional. An example is as follows:

// The type is already built into TypeScript
type Partial<T> = {
    [P in keyof T]?: T[P]
};
 
interface Rectangle {
    x: number;
    y: number;
    width: number;
    height: number;
}
 
type PartialRectangle = Partial<Rectangle>;
// Equivalent to
type PartialRectangle = {
    x?: number;
    y?: number;
    width?: number;
    height?: number;
}
 
let rect: PartialRectangle = {
    width: 100,
    height: 200
};


In the above example, because we use Partial to identify all attributes as optional, although the final rect object only contains the width and height attributes, the compiler still does not report an error. When we cannot clearly determine which attributes are included in the object, we can declare them through Partial.

7. Pick partial selection
In some application scenarios, we may need to extract a subtype from a declared type, and the subtype contains some or all of the attributes in the parent type. At this time, we can use Pick to implement it. The example code is as follows:

// The type is already built into TypeScript
type Pick<T, K extends keyof T> = {
    [P in K]: T[P]
};
 
interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}
 
type PickUser = Pick<User, "id" | "name" | "gender">;
// Equivalent to
type PickUser = {
    id: number;
    name: string;
    gender: number;
};
 
let user: PickUser = {
    id: 1,
    name: 'tom',
    gender: 1
};


In the above example, since we only care about the existence of id, name and gender in the user object, and other attributes are not explicitly specified, we can use Pick to Pick the attributes we care about from the user interface and ignore the compilation check of other attributes.

8. never exists
Never represents the types of values that never exist, such as throwing an exception or an infinite loop in a function. Never can be a subtype of any type or assigned to any type, but on the contrary, no type can be used as a subtype of never. Examples are as follows:

// Function throws an exception
function throwError(message: string): never {
    throw new Error(message);
}
 
// Function automatically infers that the return value is of type never
function reportError(message: string) {
    return throwError(message);
}
 
// Infinite loop
function loop(): never {
    while(true) {
        console.log(1);
    }
}
 
// The never type can be a subtype of any type
let n: never;
let a: string = n;
let b: number = n;
let c: boolean = n;
let d: null = n;
let e: undefined = n;
let f: any = n;
 
// No type can be assigned to never type
let a: string = '123';
let b: number = 0;
let c: boolean = true;
let d: null = null;
let e: undefined = undefined;
let f: any = [];
 
let n: never = a;
// -> Type 'string' is not assignable to type 'never'.
 
let n: never = b;
// -> Type 'number' is not assignable to type 'never'.
 
let n: never = c;
// -> Type 'true' is not assignable to type 'never'.
 
let n: never = d;
// -> Type 'null' is not assignable to type 'never'.
 
let n: never = e;
// -> Type 'undefined' is not assignable to type 'never'.
 
let n: never = f;
// -> Type 'any' is not assignable to type 'never'.


9. Exclude attribute exclusion
In contrast to Pick, Pick is used to Pick out the attributes we need to care about, while Exclude is used to Exclude the attributes we don't need to care about. An example is as follows:

// The type is already built into TypeScript
// The conditional type is used here, which is consistent with the effect of the ternary operator in JS
type Exclude<T, U> = T extends U ? never : T;
 
interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}
 
type keys = keyof User; // -> "id" | "name" | "age" | "gender" | "email"
 
type ExcludeUser = Exclude<keys, "age" | "email">;
// Equivalent to
type ExcludeUser = "id" | "name" | "gender";


In the above example, we pass in the age and email attributes we don't need to care about in ExcludeUser. Exclude will help us eliminate the attributes we don't need. The remaining attributes id, name and gender are the attributes we need to care about. In general, exclude is rarely used alone and can be used with other types to achieve more complex and useful functions.

10. Omit attribute
In the previous usage, we used Exclude to Exclude other unnecessary attributes, but the writing coupling in the above example is high. When other types also need to be processed in this way, we must implement the same logic again. We might as well further encapsulate and hide these underlying processing details and expose only simple public interfaces. Examples are as follows:

// Using the combination of Pick and Exclude
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
 
interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}
 
// Indicates that the age and email attributes in the User interface are ignored
type OmitUser = Omit<User, "age" | "email">;
// Equivalent to
type OmitUser = {
  id: number;
  name: string;
  gender: number;
};
 
let user: OmitUser = {
    id: 1,
    name: 'tom',
    gender: 1
};


In the above example, we need to ignore the age and email attributes in the User interface, so we only need to pass the interface name and attributes into Omit. The same is true for other types, which greatly improves the extensibility of types and facilitates reuse.

 

If you like my article, please pay attention to me. It is constantly updated
I hope the above contents can help you. Many friends will always encounter some problems and bottlenecks when they are advanced. There is no sense of direction when they write too many business codes. I don't know where to start to improve. For this, I have sorted out some materials. Advanced dry goods with multiple knowledge points can be shared with you for free, and those who need can go to the home page or comment Click to enter the code: csdn

Topics: Javascript Front-end TypeScript