TypeScript advanced type


May 07, 2021 21:00 TypeScript


Table of contents


TypeScript advanced type

Cross Types (Intersections)

A cross-type is a combination of multiple types into one type. T his allows us to overlay the existing multiple types into a type that contains all the types of features we need. F or example, Person & Serializable & Loggable both Person and Serializable and Loggable This means that this type of object has all three types of members at the same time.

Most of us see the use of crossover types in mixins or other places that are not suitable for typical object-oriented models. ( There are a lot of occasions when this happens in JavaScript!) Here's a simple example of how to create a blend:

function extend<T, U>(first: T, second: U): T & U {
    let result = <T & U>{};
    for (let id in first) {
        (<any>result)[id] = (<any>first)[id];
    }
    for (let id in second) {
        if (!result.hasOwnProperty(id)) {
            (<any>result)[id] = (<any>second)[id];
        }
    }
    return result;
}

class Person {
    constructor(public name: string) { }
}
interface Loggable {
    log(): void;
}
class ConsoleLogger implements Loggable {
    log() {
        // ...
    }
}
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();

The union type

Union types are associated with cross-types, but they are used completely differently. O ccasionally you encounter this situation where a code base wants to pass in number string or string. For example, the following function:

/**
 * Takes a string and adds "padding" to the left.
 * If 'padding' is a string, then 'padding' is appended to the left side.
 * If 'padding' is a number, then that number of spaces is added to the left side.
 */
function padLeft(value: string, padding: any) {
    if (typeof padding === "number") {
        return Array(padding + 1).join(" ") + value;
    }
    if (typeof padding === "string") {
        return padding + value;
    }
    throw new Error(`Expected string or number, got '${padding}'.`);
}

padLeft("Hello world", 4); // returns "    Hello world"

There is a problem with padLeft and the type of padding parameter is specified any This means that we can pass in a parameter that is number string type, but TypeScript is not wrong.

let indentedString = padLeft("Hello world", true); // 编译阶段通过,运行时报错

In traditional object-oriented languages, we might abstract these two types into hierarchical types. T his is obviously very clear, but there is also over-design. O ne of the benefits of the original padLeft version is that it allows us to pass in the original type. T his is simple and convenient to use. This new approach doesn't apply if we just want to use functions that already exist.

Instead any we can use the union type as a parameter for padding

/**
 * Takes a string and adds "padding" to the left.
 * If 'padding' is a string, then 'padding' is appended to the left side.
 * If 'padding' is a number, then that number of spaces is added to the left side.
 */
function padLeft(value: string, padding: string | number) {
    // ...
}

let indentedString = padLeft("Hello world", true); // errors during compilation

A union type represents a value that can be one of several types. W e use a vertical bar | Separates each type, so number | string | boolean that can be number string or boolean

If a value is a union type, we can only access members that are common in all types of this union type.

interface Bird {
    fly();
    layEggs();
}

interface Fish {
    swim();
    layEggs();
}

function getSmallPet(): Fish | Bird {
    // ...
}

let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim();    // errors

The type of union here may be a bit complicated, but it's easy to get used to it. I f the type of a value is A | B what we can be sure of is that it A that are common in A and B I n this example, Bird has a fly member. W e can't be Bird | Fish of type Fish has fly method. If the variable is a Fish runtime, calling pet.fly() is an error.

Type protection and differentiation of types

Union types are ideal for situations where there are different types of values that can be received. W hat do we do when we want to know for sure if we get Fish T he common way to distinguish between two possible values in JavaScript is to check for their existence. As mentioned earlier, we can only access members that are common in all types of federation types.

let pet = getSmallPet();

// 每一个成员访问都会报错
if (pet.swim) {
    pet.swim();
}
else if (pet.fly) {
    pet.fly();
}

For this code to work, we'll use type assertions:

let pet = getSmallPet();

if ((<Fish>pet).swim) {
    (<Fish>pet).swim();
}
else {
    (<Bird>pet).fly();
}

User-defined type protection

You can note that we used type assertions multiple times. If only we had checked the type once, we would have been able to figure out the pet in each branch that followed.

The type protection mechanism in TypeScript makes it a reality. T ype protection is an expression that checks at runtime to make sure that the type is in a scope. To define a type protection, we simply define a function whose return value is a type assertion:

function isFish(pet: Fish | Bird): pet is Fish {
    return (<Fish>pet).swim !== undefined;
}

In this example, pet is Fish type predicate. The predicate parameterName is Type and the parameterName must be a parameter name from the current function signature.

Whenever isFish is called isFish TypeScript reduces the variable to that specific type, as long as it is compatible with the original type of the variable.

// 'swim''fly' 调用都没有问题了

if (isFish(pet)) {
    pet.swim();
}
else {
    pet.fly();
}

Note that TypeScript not only knows Fish if pet is else a Fish type Fish in the pet Bird

typeof type protection

Now let's go back and see how the padLeft federation type. We can write using type assertions like this:

function isNumber(x: any): x is number {
    return typeof x === "number";
}

function isString(x: any): x is string {
    return typeof x === "string";
}

function padLeft(value: string, padding: string | number) {
    if (isNumber(padding)) {
        return Array(padding + 1).join(" ") + value;
    }
    if (isString(padding)) {
        return padding + value;
    }
    throw new Error(`Expected string or number, got '${padding}'.`);
}

However, it is painful to have to define a function to determine whether the type is the original type. F ortunately, we typeof x === "number" into a function now, because TypeScript can recognize it as a type protection. That means we can check the type directly in the code.

function padLeft(value: string, padding: string | number) {
    if (typeof padding === "number") {
        return Array(padding + 1).join(" ") + value;
    }
    if (typeof padding === "string") {
        return padding + value;
    }
    throw new Error(`Expected string or number, got '${padding}'.`);
}

There are typeof forms of type protection that can be identified: typeof v === "typename" and typeof v !== "typename" must "number" "string" "boolean" "symbol" "typename" But TypeScript doesn't prevent you from comparing with other strings, and languages don't recognize those expressions as type protection.

instanceof type protection

If you've typeof type protection and are familiar with instanceof you might have guessed what this section says.

instanceof protection is a way to refine types by constructors. For example, let's take a look at an example of a previous string fill:

interface Padder {
    getPaddingString(): string
}

class SpaceRepeatingPadder implements Padder {
    constructor(private numSpaces: number) { }
    getPaddingString() {
        return Array(this.numSpaces + 1).join(" ");
    }
}

class StringPadder implements Padder {
    constructor(private value: string) { }
    getPaddingString() {
        return this.value;
    }
}

function getRandomPadder() {
    return Math.random() < 0.5 ?
        new SpaceRepeatingPadder(4) :
        new StringPadder("  ");
}

// 类型为SpaceRepeatingPadder | StringPadder
let padder: Padder = getRandomPadder();

if (padder instanceof SpaceRepeatingPadder) {
    padder; // 类型细化为'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
    padder; // 类型细化为'StringPadder'
}

instanceof requirement of instanceof is a constructor that TypeScript will refine to:

  1. The type of prototype of this constructor, if its type any
  2. Construct the union of the types returned by the signature

In this order.

The type alias

A type alias gives a new name to a type. Type aliasses are sometimes similar to interfaces, but can work on raw values, union types, yuans, and any other type you need to write by hand.

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
    if (typeof n === 'string') {
        return n;
    }
    else {
        return n();
    }
}

The alias does not create a new type - it creates a new name to reference that type. Aliasing the original type is usually of little use, although it can be used as a form of documentation.

Like interfaces, type aliases can also be generic - we can add type parameters and pass in to the right side of the alias declaration:

type Container<T> = { value: T };

We can also use type alias to refer to ourselves in a property:

type Tree<T> = {
    value: T;
    left: Tree<T>;
    right: Tree<T>;
}

However, the type alias does not appear on the right side of the notable statement:

type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
    name: string;
}

var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;

However, the type alias cannot appear anywhere to the right of the declaration.

type Yikes = Array<Yikes>; // error

Interface vs. type alias

As we mentioned, type aliases can be like interfaces;

First, the interface creates a new name that can be used anywhere else. T ype aliases do not create new names -- for example, error messages do not use aliases. In the following sample code, hover over interfaced showing that it aliased it shows the literal type of object. Interface

type Alias = { num: number }
interface Interface {
    num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;

Another important difference is that type alias cannot be extends implements (you cannot extends implements yourself). Because objects in the software should be open to extensions, but for modifications that are closed, you should try to use interfaces instead of type aliases.

On the other hand, if you can't describe a type through an interface and you need to use a union type or a group type, you typically use a type alias.

String literal type

String literal types allow you to specify the fixed values that strings must have. I n practice, string literal types work well with union types, type protection, and type aliases. By combining these attributes, you can implement strings of similar enumeration types.

type Easing = "ease-in" | "ease-out" | "ease-in-out";
class UIElement {
    animate(dx: number, dy: number, easing: Easing) {
        if (easing === "ease-in") {
            // ...
        }
        else if (easing === "ease-out") {
        }
        else if (easing === "ease-in-out") {
        }
        else {
            // error! should not pass null or undefined.
        }
    }
}

let button = new UIElement();
button.animate(0, 0, "ease-in");
button.animate(0, 0, "uneasy"); // error: "uneasy" is not allowed here

You can only select one of the three allowed characters as an argument pass, and passing in other values results in an error.

Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'

String literal types can also be used to distinguish function overloads:

function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... more overloads ...
function createElement(tagName: string): Element {
    // ... code goes here ...
}

Identifiable Union

You can combine string literal types, union types, type protection, and type aliases to create an advanced pattern called identifiable federation, also known as tag federation or algegegelia data types. I dentifiable union is useful in functional programming. S ome languages automatically identify federations for you, while TypeScript is based on existing JavaScript patterns. It has four elements:

  1. Has a common string literal attribute - a recognizable feature.
  2. A type alias contains those types of union-union.
  3. Type protection on this property.
interface Square {
    kind: "square";
    size: number;
}
interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}
interface Circle {
    kind: "circle";
    radius: number;
}

First we declare the interface that will be federationd. E ach interface has kind property but has a different literal type of character. kind property is called a recognizable feature or label. O ther properties are specific to each interface. N ote that there is currently no connection between the interfaces. Let's put them together:

type Shape = Square | Rectangle | Circle;

Now let's use identifiable union:

function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
}

Integrity checks

When not all identifiable union changes are covered, we want the compiler to notify us. For example, if we add Triangle Shape we also need to update area :

type Shape = Square | Rectangle | Circle | Triangle;
function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
    // should error here - we didn't handle case "triangle"
}

There are two ways to do this. The first --strictNullChecks and specify a return value type:

function area(s: Shape): number { // error: returns number | undefined
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
}

Because switch does not cover all cases, TypeScript believes that this function sometimes undefined I f you explicitly specify a return value type of number you will see an error because the type of return value number | undefined However, there are some subtleties to --strictNullChecks support the old code well.

The second method uses the never which the compiler uses for integrity checks:

function assertNever(x: never): never {
    throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
        default: return assertNever(s); // error here if there are missing cases
    }
}

Here, assertNever s the never -- that is, the type that remains after all possible cases have been removed. I f you forget a case, s have a catch-up type, so you'll get an error. This approach requires you to define an additional function.

Polymorphic this type

The polymorphic this type represents a sub-type that contains a class or interface. T his is called F-bounded polymorphism. I t can easily be used to perform inheritance between coherent interfaces, for example. In the calculator's example, the this type is returned this operation:

class BasicCalculator {
    public constructor(protected value: number = 0) { }
    public currentValue(): number {
        return this.value;
    }
    public add(operand: number): this {
        this.value += operand;
        return this;
    }
    public multiply(operand: number): this {
        this.value *= operand;
        return this;
    }
    // ... other operations go here ...
}

let v = new BasicCalculator(2)
            .multiply(5)
            .add(1)
            .currentValue();

Since this class uses this you can inherit it, and the new class can use the previous method directly without any changes.

class ScientificCalculator extends BasicCalculator {
    public constructor(value = 0) {
        super(value);
    }
    public sin() {
        this.value = Math.sin(this.value);
        return this;
    }
    // ... other operations go here ...
}

let v = new ScientificCalculator(2)
        .multiply(5)
        .sin()
        .add(1)
        .currentValue();

Without this ScientificCalculator be able to inherit BasicCalculator while maintaining interface consistency. multiply return BasicCalculator which does not have sin method. However, this type, multiply this and here ScientificCalculator