Skip to main content

Declaration Merging_TypeScript Notes 16

Free2019-04-21#TypeScript#TypeScript扩展声明#TypeScript重写声明#TypeScript修改声明#TypeScript mixin#TypeScript声明合并规则

The significance of declaration merging lies in allowing extension of existing (type) declarations

1. Introduction

Similar to declaration merging in CSS:

.box {
  background: red;
}
.box {
  color: white;
}

/* Equivalent to */
.box {
  background: red;
  color: white;
}

TypeScript also has such a mechanism:

interface IPerson {
  name: string;
}
interface IPerson {
  age: number;
}

// Equivalent to
interface IPerson {
  name: string;
  age: number;
}

In short, multiple declarations describing the same thing will be merged into one

2. Basic Concepts

In TypeScript, a declaration may create namespaces, types, or values. For example, declaring a Class creates both type and value simultaneously:

class Greeter {
  static standardGreeting = "Hello, there";
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    return "Hello, " + this.greeting;
  }
}

let greeter: Greeter; // Greeter type
greeter = new Greeter("world"); // Greeter value

(Excerpted from [Classes and Types](/articles/类-typescript 笔记 4/#articleHeader8))

Therefore, declarations can be divided into 3 categories:

  • Declarations that create namespaces: Create a namespace name accessed with dot notation (.)

  • Declarations that create types: Create a type specifying a "shape", named with the given name

  • Declarations that create values: Create a value that also exists in the output JavaScript

Specifically, among TypeScript's 7 types of declarations, namespaces have namespace and value meanings, classes and enums have both type and value meanings, interfaces and type aliases have only type meanings, functions and variables have only value meanings:

Declaration TypeNamespaceTypeValue
NamespaceXX
ClassXX
EnumXX
InterfaceX
Type AliasX
FunctionX
VariableX

3. Merging Interfaces

The simplest and most common declaration merging is interface merging. The basic rule is to put members of interfaces with the same name together:

interface Box {
  height: number;
  width: number;
}
interface Box {
  scale: number;
}
// Equivalent to
interface MergedBox {
  height: number;
  width: number;
  scale: number;
}

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

Non-function members must be unique. If not unique, function members with the same type will be ignored, while different types will throw a compilation error:

interface Box {
  color: string
}
// Error Subsequent property declarations must have the same type.
interface Box {
  color: number
}

For function members, those with the same name are treated as [function overloads](/articles/函数-typescript 笔记 5/#articleHeader9):

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

Will be merged into:

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

Merging within the same declaration maintains declaration order, while between different declarations later declarations take priority (that is, function members defined in later interface declaration statements come first in the merge result), while non-function members are arranged in dictionary order after merging

Specially, if a function signature contains a string literal type parameter, it will be placed at the top of the merged overload list:

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

Merge result:

interface IDocument {
  // Special signatures at top
  createElement(tagName: "div"): HTMLDivElement;
  createElement(tagName: "span"): HTMLSpanElement;
  createElement(tagName: "canvas"): HTMLCanvasElement;
  // Following two still follow later declarations take priority
  createElement(tagName: string): HTMLElement;
  createElement(tagName: "canvas"): HTMLCanvasElement;
}

4. Merging Namespaces

Similar to interfaces, multiple namespaces with the same name will also undergo member merging. The special thing is that namespaces also have value meanings, making the situation slightly more complex

  • Namespace merging: Interfaces exposed by each (same-name) namespace are merged, while interfaces within individual namespaces are also merged

  • Value merging: Members exposed in later-declared namespaces are added to earlier ones

For example:

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 { }
}

Specially, non-exported members remain visible only within the source namespace (even with namespace merging mechanism):

namespace Animal {
  let haveWings = true;

  export function animalsHaveWings() {
    return haveWings;
  }
}
namespace Animal {
  export function doAnimalsHaveWings() {
    // Error Cannot find name 'haveWings'.
    return haveWings;
  }
}

Because namespaces have scope isolation, non-exported members won't be hung on the namespace:

var Animal;
(function (Animal) {
  var haveWings = true;
  function animalsHaveWings() {
    return haveWings;
  }
  Animal.animalsHaveWings = animalsHaveWings;
})(Animal || (Animal = {}));
(function (Animal) {
  function doAnimalsHaveWings() {
    // Error Cannot find name 'haveWings'.
    return haveWings;
  }
  Animal.doAnimalsHaveWings = doAnimalsHaveWings;
})(Animal || (Animal = {}));

Merging with Classes, Functions, and Enums

Besides being able to merge with other namespaces, namespaces can also merge with classes, functions, and enums

This capability allows (at the type level) extending existing classes, functions, and enums, used to describe common patterns in JavaScript, such as adding static members to classes, adding static properties to functions, etc.

P.S. Requires namespace declarations to appear later, otherwise error:

// Error A namespace declaration cannot be located prior to a class or function with which it is merged.
namespace A {
  function f() { }
}
class A {
  fn() { }
}

Because it will cause overwriting, not merging:

// Compilation result
var A;
(function (A) {
  function f() { }
})(A || (A = {}));
var A = /** @class */ (function () {
  function A() {
  }
  A.prototype.fn = function () { };
  return A;
}());

Merging with Classes

Can use namespaces to add static members to existing Classes, for example:

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

Consistent with merging rules between namespaces, so must expose class AlbumLabel, allowing members in other declarations to access

Merging with Functions

Similar to namespace merging with classes, merging with functions can extend static properties on existing functions:

function buildLabel(name: string): string {
  return buildLabel.prefix + name + buildLabel.suffix;
}
namespace buildLabel {
  export let suffix = "";
  export let prefix = "Hello, ";
}
// test
buildLabel('Lily') === "Hello, Lily"

Merging with Enums

enum Color {
  red = 1,
  green = 2,
  blue = 4
}
namespace Color {
  export function mixColor(colorName: string) {
    if (colorName == "yellow") {
      return Color.red + Color.green;
    }
    else {
      return -1;
    }
  }
}

// test
Color.mixColor('white');

Having enums with static methods looks strange, but similar scenarios do exist in JavaScript, equivalent to adding behavior to property sets:

// JavaScript
const Color = {
  red: 1,
  green: 2,
  blue: 4
};
Color.mixColor = function(colorName) {/* ... */};

5. Class Mixin

Class declarations don't merge with other class or variable declarations, but similar effects can be achieved through Class Mixin:

function applyMixins(derivedCtor: any, baseCtors: any[]) {
  baseCtors.forEach(baseCtor => {
    Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
      Object.defineProperty(derivedCtor.prototype, name, Object.getOwnPropertyDescriptor(baseCtor.prototype, name));
    });
  });
}

Use utility functions to stick everything from other class prototypes onto the target class prototype, giving it capabilities (behavior) of other classes:

class Editable {
    public value: string;
    input(s: string) { this.value = s; }
}
class Focusable {
    focus() { console.log('Focused'); }
    blur() { console.log('Blured'); }
}
// Get types from other classes
class Input implements Editable, Focusable {
    // Editable interface to implement
    value: string;
    input: (s: string) => void;
    // Focusable interface to implement
    focus: () => void;
    blur: () => void;
}
// Get behavior from other classes
applyMixins(Input, [Editable, Focusable]);

// log 'Focused'
new Input().focus();

P.S. Where implements Editable, Focusable takes types from source classes, similar to interfaces, specifically see Interfaces Extending Classes

6. Module Augmentation

// Source file observable.js
export class Observable {
  constructor(source) { this.source = source; }
}

// Source file map.js
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
  return new Observable(f(this.source));
}

This module extension method is common in JavaScript, but will get errors under TypeScript:

// Source file observable.ts
export class Observable<T> {
  constructor(public source: T) { }
}

// Source file map.ts
import { Observable } from "./observable";
// Error Property 'map' does not exist on type 'Observable<any>'.
Observable.prototype.map = function (f) {
  return new Observable(f(this.source));
}

At this point, can use module augmentation to inform the compiler (type system) of newly added members in the module:

// Source file map.ts
import { Observable } from "./observable";
// Module augmentation
declare module "./observable" {
    interface Observable<T> {
        map<U>(f: (x: T) => U): Observable<U>;
    }
}
Observable.prototype.map = function (f) {/* ... */}

Among them, module name resolution is consistent with import/export, specifically see [Module Resolution Mechanism_TypeScript Notes 14](/articles/模块解析机制-typescript 笔记 14/), while newly added extension members in module declarations will be merged into the source module (as if originally declared in the same file). Can extend existing modules this way, but with 2 limitations:

  • Cannot add top-level declarations in module augmentation, can only extend existing declarations

  • Cannot extend default exports, can only extend named exports (because default is a reserved word, cannot extend by name, specifically see Can not declaration merging for default exported class)

P.S. The above example may encounter declare module "./observable" error in Playground and other environments:

Invalid module name in augmentation, module './observable' cannot be found.

Ambient module declaration cannot specify relative module name.

Caused by module file not existing, can compile normally in real file modules

Global Augmentation

Can also extend "global modules" (i.e., correct things in global scope) in a similar way, for example:

// Source file observable.ts
export class Observable<T> {
  constructor(public source: T) { }
}
declare global {
  interface Array<T> {
    toObservable(): Observable<T>;
  }
}
Array.prototype.toObservable = function () {
  return new Observable(this);
}

declare global indicates extending global scope, newly added things will be merged into global declarations like Array

References

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment