Declaration merge in TypeScript

introduce

There are some unique concepts in TypeScript that can describe the model of JavaScript objects at the type level. A particularly unique example of this is the concept of "Declaration consolidation". Understanding this concept will help you manipulate existing JavaScript code. At the same time, it will also help to understand more high-level abstract concepts.

For the purpose of this document, "Declaration merging" means that the compiler merges two independent declarations for the same name into a single declaration. The combined declaration has the characteristics of the original two declarations at the same time. Any number of statements can be combined; Not limited to two statements.

Basic concepts

Declarations in TypeScript create one of three entities: namespace, type, or value. Creating a namespace declaration creates a new namespace that contains the name used when accessing with the (.) symbol. Creating a type declaration is to create a type with the declared model and bind it to a given name. Finally, the declaration that creates the value creates the value you see in the JavaScript output.

Merge interface

The simplest and most common type of declaration merge is interface merge. Basically, the merging mechanism is to put the members of both sides into an interface with the same name.

interface Box {
    height: number;
    width: number;
}

interface Box {
    scale: number;
}

let box: Box = {height: 5, width: 6, scale: 10};

Non function members of the interface should be unique. If they are not unique, they must be of the same type. If a non function member with the same name is declared in both interfaces and their types are different, the compiler will report an error.

For function members, each function declaration with the same name is treated as an overload of the function. At the same time, it should be noted that when interface A is merged with subsequent interface A, the latter interface has higher priority.

interface Cloner {
    clone(animal: Animal): Animal;
}

interface Cloner {
    clone(animal: Sheep): Sheep;
}

interface Cloner {
    clone(animal: Dog): Dog;
    clone(animal: Cat): Cat;
}

The three interfaces are combined into one declaration:

interface Cloner {
    clone(animal: Dog): Dog;
    clone(animal: Cat): Cat;
    clone(animal: Sheep): Sheep;
    clone(animal: Animal): Animal;
}

Note that the declaration order in each group of interfaces remains the same, but the order between each group of interfaces is that later interface overloads appear in the front position.

One exception to this rule is when a special function signature appears. If the type of a parameter in the signature is a single string literal (for example, not the union type of string literal), it will be promoted to the top of the overload list.

For example, the following interfaces will be merged:

interface Document {
    createElement(tagName: any): Element;
}
interface Document {
    createElement(tagName: "div"): HTMLDivElement;
    createElement(tagName: "span"): HTMLSpanElement;
}
interface Document {
    createElement(tagName: string): HTMLElement;
    createElement(tagName: "canvas"): HTMLCanvasElement;
}

The merged Document will look like this:

interface Document {
    createElement(tagName: "canvas"): HTMLCanvasElement;
    createElement(tagName: "div"): HTMLDivElement;
    createElement(tagName: "span"): HTMLSpanElement;
    createElement(tagName: string): HTMLElement;
    createElement(tagName: any): Element;
}

Merge namespace

Like interfaces, namespaces with the same name merge their members. Namespaces create namespaces and values, and we need to know how they are combined.

For namespace merging, the interface with the same name exported by the module is merged to form a single namespace, including the merged interface.

For the merging of values in the namespace, if the namespace with the given name already exists, the exported members of the subsequent namespace will be added to the existing module.

Examples of merging Animals declarations:

namespace Animals {
    export class Zebra { }
}

namespace Animals {
    export interface Legged { numberOfLegs: number; }
    export class Dog { }
}

Equivalent to:

namespace Animals {
    export interface Legged { numberOfLegs: number; }

    export class Zebra { }
    export class Dog { }
}

In addition to these merges, you also need to understand how non exported members are handled. Non exported members are visible only within their original (pre merge) namespace. This means that after merging, members merged from other namespaces cannot access non exported members.

namespace Animal {
    let haveMuscles = true;

    export function animalsHaveMuscles() {
        return haveMuscles;
    }
}

namespace Animal {
    export function doAnimalsHaveMuscles() {
        return haveMuscles;  // Error, because haveMuscles is not accessible here
    }
}

Because haveMuscles is not exported, only the animalsHaveMuscles function shares the original unconsolidated namespace and can access this variable. The doAnimalsHaveMuscles function is part of the merged namespace, but it cannot access unexported members.

Namespace is merged with class and function and enumeration types

Namespaces can be merged with declarations of other types. As long as the definition of the namespace matches the definition of the type to be merged. The merge result contains both declaration types. TypeScript uses this function to implement some design patterns in JavaScript.

Merge namespaces and classes

This allows us to represent inner classes.

class Album {
    label: Album.AlbumLabel;
}
namespace Album {
    export class AlbumLabel { }
}

The merge rules are consistent with those described in the merge namespace section above. We must export the AlbumLabel class so that the merged class can access it. The result of the merge is a class with an inner class. You can also use namespaces to add static properties to classes.

In addition to the internal class mode, it is also common for you to create a function in JavaScript and extend it later to add some properties. TypeScript uses declaration merging to achieve this and ensure type safety.

function buildLabel(name: string): string {
    return buildLabel.prefix + name + buildLabel.suffix;
}

namespace buildLabel {
    export let suffix = "";
    export let prefix = "Hello, ";
}

console.log(buildLabel("Sam Smith"));

Similarly, namespaces can be used to extend enumerations:

enum Color {
    red = 1,
    green = 2,
    blue = 4
}

namespace Color {
    export function mixColor(colorName: string) {
        if (colorName == "yellow") {
            return Color.red + Color.green;
        }
        else if (colorName == "white") {
            return Color.red + Color.green + Color.blue;
        }
        else if (colorName == "magenta") {
            return Color.red + Color.blue;
        }
        else if (colorName == "cyan") {
            return Color.green + Color.blue;
        }
    }
}

Global extension

You can also add declarations inside the module to the global scope.

// observable.ts
export class Observable<T> {
    // ... still no implementation ...
}

declare global {
    interface Array<T> {
        toObservable(): Observable<T>;
    }
}

Array.prototype.toObservable = function () {
    // ...
}

Tags: Javascript TypeScript

Posted on Tue, 26 Oct 2021 09:11:03 -0400 by jeicrash