16
7

More than 5 years have passed since last update.

TypeScript Handbook を読む (17. Declaration Merging)

Last updated at Posted at 2017-08-18

TypeScript Handbook を読み進めていく第十七回目。

  1. Basic Types
  2. Variable Declarations
  3. Interfaces
  4. Classes
  5. Functions
  6. Generics
  7. Enums
  8. Type Inference
  9. Type Compatibility
  10. Advanced Types
  11. Symbols
  12. Iterators and Generators
  13. Modules
  14. Namwspaces
  15. Namespaces and Modules
  16. Module Resolution
  17. Declaration Merging (今ココ)
  18. JSX
  19. Decorators
  20. Mixins
  21. Triple-Slash Directives
  22. Type Checking JavaScript Files

Declaration Merging

原文

Introduction

TypeScript 独自の考え方として、‘宣言のマージ’ が挙げられます。
これは同じ名前が付けられた宣言をひとつの宣言にマージするもので、マージできる宣言の数に上限はありません。

Basic Concepts

TypeScript において、宣言とは名前空間、型、値のいずれかを作成するものです。
各宣言で何が作られるのかを理解しておくと宣言のマージの理解に役立つでしょう。

Declaration Type Namespace Type Value
Namespace X X
Class X X
Enum X X
Interface X
Type Alias X
Function X
Variable X

表の見方は、例えば class Dog {} (クラス) と宣言すると Dog という型と (変数等に格納可能な) Dog という値が作成される、という意味

Merging Interfaces

一番簡単な型宣言のマージの例は、インタフェースのマージだと思います。
この時、各宣言のメンバは同じ名前のインタフェースにまとめられます。

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

interface Box {
    scale: number;
}

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

関数ではないメンバの名前は一意であるか、もしくは同じ型である必要があります。
もし関数ではないメンバの名前が重複しており、かつ、型が異なる場合はコンパイル時にエラーとなります。

また、同じ名前を持つメンバ関数は関数のオーバーロードとみなされます。
ここでの注意点は、後からマージされたインタフェースのメンバ関数ほど、高い優先度を持つということです。

例えば、以下のように宣言した場合、

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

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

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

以下のように宣言がマージされます。

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

同じ宣言内でのメンバ関数の順序は維持されているものの、より後の宣言ほどオーバーロードの優先度が高くなっていることに注意してください。

DogCat の順は 3 番目の Cloner で宣言した通りだけど、AnimalSheepDog (と Cat) は 3 番目、2 番目、1 番目の順になっている

ただし、唯一の例外として、単一の リテラル文字列型を引数にとる (つまり、リテラル文字列型の共用型でない) メンバ関数はオーバーロードの優先度が引き上げられます。

例えば、以下のように宣言した場合、

TypeScript
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;
}

以下のように宣言がマージされます。

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

リテラル文字列型同士の優先度は前述の通り (同じ宣言内の順は維持されるが、後の宣言ほど先に来る) になるものの、リテラル文字列型 > 文字列型 > any 型 の順に並び替えられている

Merging Namespaces

インタフェースのマージと同じように、名前空間のマージも各名前空間のメンバをマージするものです。
ただし、名前空間を宣言すると名前空間と値が作られるため、それらがどのようにマージされるのかを理解する必要があります。

名前空間のマージは、各名前空間でエクスポートされているインタフェースを単一の名前空間にまとめることで実現されます。

また、値としての名前空間のマージは、既存の名前空間の宣言を拡張 (エクスポートされているメンバを追加) することで実現されます。

例として、Animals 名前空間をマージする例を見てみましょう。

TypeScript
namespace Animals {
    export class Zebra { }
}

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

これは以下のように宣言することと等価です。

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

    export class Zebra { }
    export class Dog { }
}

エクスポートされていないメンバは元の (マージ前の) 名前空間の中でしか参照することはできません。
つまり、他の名前空間からマージされてきたメンバからはエクスポートされていないメンバを参照できないということです。

以下の例では、haveMuscles はエクスポートされておらず、animalsHaveMuscles 関数からのみ、参照することができます。
また、doAnimalsHaveMuscles 関数は同じ Animal 名前空間に所属していますが、エクスポートされていないメンバ (haveMuscles) を参照することはできません。

TypeScript
namespace Animal {
    let haveMuscles = true;

    export function animalsHaveMuscles() {
        return haveMuscles;
    }
}

namespace Animal {
    export function doAnimalsHaveMuscles() {
        return haveMuscles;  // エラー、 ここでは haveMuscles を参照できない
    }
}

Merging Namespaces with Classes, Functions, and Enums

他の種類の宣言を含む名前空間をマージすることも可能ですが、その場合、マージする型の後に名前空間を宣言する必要があります。

Merging Namespaces with Classes

クラスを含む名前空間をマージすることで、内部関数を実現することができます。

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

この場合の可視性のルールは Merging Namespace で述べたものと同じです。
そのため、内部クラスにアクセスするために AlbumLabel クラスをエクスポートする必要があります。

内部クラスの他に、JavaScript では関数にプロパティを追加することもよくあると思いますが、宣言言のマージを利用することで、それを型安全に実現することができます。

TypeScript
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"));

また、enum に静的メンバを追加するために名前空間を使用することもできます。

TypeScript
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;
        }
    }
}

Disallowed Merges

TypeScript では何でもマージできるわけではありません。
今のところ、クラスを他のクラスや変数とマージすることはできません。

Module Augmentation

JavaScript では宣言のマージを行うことはできませんが、以下のように既存のオブジェクトをパッチすることは可能です。

JavaScript
// observable.js
export class Observable<T> {
    // ... 実装は読者に任せる ...
}

// map.js
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
    // ... こちらも読者に任せる
}

これは TypeScript でも動作しますが、コンパイラは Observable.prototype.map に関する情報を持っていません。
そのため、モジュールの増補を行うことでコンパイラに情報を伝えることができます。

TypeScript
// observable.ts は先ほどと同じ

// map.ts
import { Observable } from "./observable";
declare module "./observable" {
    interface Observable<T> {
        map<U>(f: (x: T) => U): Observable<U>;
    }
}
Observable.prototype.map = function (f) {
    // ... こちらも読者に任せる
}


// consumer.ts
import { Observable } from "./observable";
import "./map";
let o: Observable<number>;
o.map(x => x.toFixed());

declare module "./observable" {} が増補部分

モジュール名は import/export と同じように解決されます。(詳細は Modules 参照)
そして、増補内の宣言は元の宣言と同じファイルに記述されているかのようにマージされます。
ただし、増補内で新しいトップレベルの宣言を行うことはできず、既存の宣言にパッチすることしかできません。

Global augmentation

モジュールの中からグローバルスコープに対して宣言を追加することも可能です。

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

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

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

グローバルな増補もモジュールの増補と同じ振る舞い、制約を持ちます。

16
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
7