TypeScript Advanced Usage Details

Introduction

As a powerful static type checking tool, TypeScript is now visible in many large, medium-sized applications and popular JS libraries.As a weakly typed language, JS modifies the type of a variable without taking a moment to write code, resulting in unexpected runtime errors.However, TypeScript helps us solve this problem during compilation, not only by introducing strong type checking in JS, but also by running the compiled JS code in any browser environment, Node environment, and any JS engine that supports ECMAScript 3 (or later).Recently, the company is just about ready to use TypeScript to restructure its existing system. There were not many opportunities to use TypeScript before, especially for some useful advanced uses. So take this opportunity to consolidate your knowledge in this area and point out any mistakes.

1. Class Inheritance

In ES5, we usually encapsulate common parts of some components through functions or prototype-based inheritance for easy reuse, whereas in TypeScript, we can create reusable components using class inheritance in an object-oriented manner similar to Java.We can create a class with the class keyword and use the new operator to instantiate an object based on it.To abstract the common parts of multiple classes, we can create a parent class and let the subclasses inherit the parent class through the extends keyword, thereby reducing redundant code writing and increasing code reusability and maintainability.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 prototype through super, but this in the method points to an instance of the subclass
        super.print();
        console.log(this.y);
    }
}

const child = new Child();
console.log(child.print()) // -> 1 2

In the example above, the Child subclass overrides the print method of the parent class and uses super.print() internally to invoke the common logic of the parent class to achieve logical reuse.The class keyword, a syntax sugar used as a constructor, is compiled by TypeScript and converted to ES5 code that is recognized by a well-compatible browser.Class is very common in object-oriented programming paradigms, so in order to understand the implementation behind it, we may want to take a moment to see what the code looks like after it has been compiled and transformed (of course, this part can be skipped by familiar students).

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 prototype through super, but this in the method points to an instance of the subclass
        _super.prototype.print.call(this);
        console.log(this.y);
    };
    return Child;
}(Parent));
var child = new Child();
console.log(child.print()); // -> 1 2

That's the converted full code. For easy comparison, keep the original comment information here. A closer look at this code will reveal the following points:
1) The super() method in the Child subclass constructor is converted to var _this = _super.call(this) || this, where _super refers to the Parent parent, so the meaning of this code is to call the Parent constructor and bind this to the instance of the subclass so that the subclass instance can have the x attribute of the Parent class.Therefore, in order to implement attribute inheritance, we must call the super() method in the subclass constructor, which will compile if not called.

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

3) The extends keyword is ultimately converted to the u extends(Child, _super) method, where _super refers to the parent parent class Parent. For easy viewing, the _extends method is presented here separately for investigation.

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 One
        extendStatics(d, b);
        
        // Part Two
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    };
})();

In the above code, there are two main parts to understand, the first part is the extendStatics(d, b) method, and the second part is the two lines of code following the method.

Part One:

Although there is a relatively large amount of code within the extendStatics method, it is not difficult to find that it is actually mainly for compatibility with the ES5 version of the execution environment.The Object.setPrototypeOf method was added in ES6 to set the object's prototype manually, but in the ES5 environment we usually set it by a non-standard u proto_u property. The principle of Object.setPrototypeOf method is to set the object's prototype by this property, which is implemented as follows:

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

In the extendStatics(d, b) method, D refers to the child class Child and B to the parent class Parent, so the function of this method can be explained as follows:

// Point u proto_u property of Child to Parent
Child.__proto__ = Parent;

This line of code can be interpreted as inheritance of a constructor, or inheritance of static properties and methods, that is, properties and methods are not mounted on the prototype of the constructor but are mounted directly on the constructor itself, because functions themselves can also be treated as objects in JS and can be assigned any other property, as shown in the following examples:

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 access attributes in the subclass Child as Child.someProperty, we use Child. u proto_u to find the same-name attributes of the parent class if they don't exist in the subclass, in order to achieve both static attributes and path finding of static methods.

Part Two:

In the second section, only the following two lines of code are included:

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

Among them, d refers to the subclass Child and b refers to the parent Parent. Students familiar with several ways to implement inheritance in JS can see at a glance that parasitic combinatorial inheritance is used here to avoid the impact on the parent prototype when modifying the method on the prototype of the subclass by borrowing an intermediate function ().We know that once an object is instantiated in JS through a constructor, it will have a u proto_u property and point to the prototype property of its constructor, as shown in the following example:

function Foo() {
  this.x = 1;
  this.y = 2;
}

const foo = new Foo();
foo.__proto__ === Foo.prototype; // -> true

For this example, if an object is instantiated through the subclass Child, the following associations occur:

const child = new Child();
child.__proto__ === (Child.prototype = new __());
child.__proto__.__proto__ === __.prototype === Parent.prototype; 

// The above code is equivalent to the following
Child.prototype.__proto__ === Parent.prototype;

So when we call a method through child.someMethod() in the child object of a child instance of the child subclass, if the method does not exist in the instance, it will continue to look up along u proto_ and eventually pass through the prototype prototype of the parent class Parent, which implements inheritance of the method.

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

// Represents inheritance of constructors, or inheritance of static properties and methods, always pointing to the parent class
1. Child.__proto__ === Parent;

// Inheritance of the representation, always pointing to the prototype property of the parent class
2. Child.prototype.__proto__ === Parent.prototype;

2. Access modifiers

TypeScript provides access modifiers (Access Modifiers) to restrict access to internal attributes outside the class, which include three main types:

  • Public: Public modifiers, whose properties and methods are public and can be accessed anywhere. By default, all properties and methods are public.
  • Private: A private modifier whose modifiers properties and methods are not visible outside the class.
  • protected: protected modifiers, similar to private s, but the attributes and methods they modify are accessible within subclasses.

We compare several modifiers with some examples:

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 example above, since we set the access modifier to public, we are allowed to access the name and age attributes through the instance man, as well as reassigning the age attribute.But in some cases, we want some attributes to be invisible and not allowed to be modified, so we can use the private modifier:

class Human {
    public name: string;
    private age: number; // Modified 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 property to private, we access it externally through man.age, and TypeScript will discover at compile time that it is a private property and will eventually error.

Note: Access to private properties is not restricted in the type script compiled code.

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 private modifiers are also inaccessible in subclasses, as shown in the following examples:

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 example above, because the age attribute is set to private in the parent Human, the age attribute cannot be accessed in the subclass Woman. 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; // Modified 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 with a constructor, it means that the class is not allowed to be inherited or instantiated, as shown in the following example:

class Human {
    public name: string;
    public age: number;
    
    // Modified 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 a constructor, it means that the class is only allowed to be inherited, as shown in the following example:

class Human {
    public name: string;
    public age: number;
    
    // Modified 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.

Additionally, modifiers can be placed directly in the constructor's parameters, as shown in the following 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

To describe this common point, we can extract it into an interface for centralized maintenance and implement it using the implements keyword, as shown below:

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 passes smoothly during the compilation phase, 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'.

TypeScript, however, compiles errors, telling us that the HumanConstructor interface is incorrectly implemented because when a class implements an interface, only the instance part is compiled and the static part of the class is not checked by the compiler.So here we try to manipulate the static part of the class in a different way, as shown in the following example:

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 example above, by creating an extra factory method createHuman and passing in the constructor as the first parameter, the compiler checks if the first parameter meets the constructor signature of the HumanConstructor interface when we call createHuman (Human,'tom', 18).

4. Declaration Merge

Interfaces are the most common type of merge in declarative merges, so here's how to start with interfaces.

4.1 Interface Merge

The sample 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 way interfaces are merged is easy to understand, that is, multiple interfaces with the same name are declared, each containing different property declarations, and eventually these property declarations from multiple interfaces are merged into the same interface.

Note: Non-function members in all interfaces with the same name must be unique. If not, the type must be the same or the compiler will error.For function members, the interface with the same name declared after overrides the interface with the same name previously declared, that is, the function in the interface with the same name declared after is equivalent to an overload and has higher priority.

4.2 Function Merge

The merging of functions can be simply understood as overloading a function by defining several functions with the same name that have different types of parameters or return values at the same time. The example code is as follows:

// Function Definition
function foo(x: number): number;
function foo(x: string): string;

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

In the example above, we define the foo function several times, each time the function parameter type is different, the return value type is different, and the last time is the concrete implementation of the function. In the implementation, the compiler will not error until all previous definitions are compatible.

Note: The TypeScript compiler takes precedence over the initial function definitions for matching, so if multiple function definitions have inclusion relationships, you need to put the most accurate function definitions first or they will never be matched.

4.3 Type Alias Union

Type alias union differs from interface Union in that it does not create a new type, but rather creates a new alias to reference multiple types and cannot be implemented and inherited like an interface. Examples are 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

The keyof in TypeScript is somewhat similar to the Object.keys() method in JS, but the difference is that the former traverses the string index in the type, and the latter traverses the key names in the object, as shown in the following example:

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 example above, we used keyof to restrict the function's parameter name property from being included in all string indexes of type Rectangle. If not, the compiler will make an error, which can be used at compile time to detect if the object's property name is written incorrectly.

6. Partial Optional Properties

In some cases, we want all attributes in a type to be unnecessary, and only under certain conditions can we use Partial to identify all attributes in a declared type as optional, as shown in the following example:

// This 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 example above, because we used Partial to identify all the attributes as optional, the compiler did not error even though the final rect object contained only the width and height s attributes, so we can declare through Partial when we can't explicitly determine which attributes are contained in the object.

7. Pick Partial Selection

In some scenarios, we may need to extract a subtype from a declared type and include some or all of the attributes of the parent type in the subtype, which we can implement with Pick, as shown in the following sample code:

// This 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 example above, 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 compilation checks for other attributes.

8. Never 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 any type of subtype or assign to any type, but on the contrary, none can be a subtype of the never type. Examples are as follows:

// Function throws an exception
function throwError(message: string): never {
    throw new Error(message);
}

// Function automatically infers the return value to be 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 the 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 property exclusion

In contrast to Pick, Pick picks up the attributes we need to care about, while Exclude excludes the attributes we don't need to care about. An example is as follows:

// This type is already built into TypeScript
// Conditional Type is used here, which is consistent with the trinomial 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 example above, by passing in the age and email attributes that we don't need to care about in ExcludeUser, Exclude helps us eliminate unwanted attributes, leaving behind attribute id s, name s, and gender s as attributes that we need to care about.In general, Exclude is rarely used alone and can work with other types to achieve more complex and useful functionality.

10. Omit attribute ignored

In the previous method, we used Exclude to exclude other unwanted attributes, but the above example has a high degree of write coupling. When other types need to be treated like this, the same logic must be implemented again. We might as well encapsulate it further to hide the underlying processing details and expose only simple public interfaces to the outside world, as shown in the following example:

// Implement using a 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 example above, we need to ignore the age and email attributes in the User interface, and simply pass the interface name and attributes into Omit, as well as other types, which greatly improves the scalability of the types and makes them easy to reuse.

summary

This article summarizes several techniques for using TypeScript. If you find many common types of declarations in our TypeScript project, you might want to use the techniques to optimize them for better maintainability and reusability.There were not many opportunities for the author to use TypeScript before, so I recently summarized it while learning. If there are errors in the article, I also hope to be able to correct them in the comments area.

Communication

If you think the content of this article is helpful to you, can you help us to focus on the author's Public Name [Front End Landscape]? Every week, you will try to create some original front end technology dry goods. After you pay attention to the Public Number, you can be invited to join the Front End Technology Exchange Group. We can communicate with each other and make progress together.

Articles have been synchronously updated to Github Blog Welcome to star, if you still have an article!

One of your compliments deserves more effort from me!

Growth in adversity, only continuous learning can become a better self, with your mutual encouragement!

Keywords: Javascript TypeScript Attribute ECMAScript Java

Added by coolpravin on Tue, 17 Dec 2019 04:03:07 +0200