TypeScript Handbook を読み進めていく第十七回目。
- Basic Types
- Variable Declarations
- Interfaces
- Classes
- Functions
- Generics
- Enums
- Type Inference
- Type Compatibility
- Advanced Types
- Symbols
- Iterators and Generators
- Modules
- Namwspaces
- Namespaces and Modules
- Module Resolution
- Declaration Merging (今ココ)
- JSX
- Decorators
- Mixins
- Triple-Slash Directives
- 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
一番簡単な型宣言のマージの例は、インタフェースのマージだと思います。
この時、各宣言のメンバは同じ名前のインタフェースにまとめられます。
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
let box: Box = {height: 5, width: 6, scale: 10};
関数ではないメンバの名前は一意であるか、もしくは同じ型である必要があります。
もし関数ではないメンバの名前が重複しており、かつ、型が異なる場合はコンパイル時にエラーとなります。
また、同じ名前を持つメンバ関数は関数のオーバーロードとみなされます。
ここでの注意点は、後からマージされたインタフェースのメンバ関数ほど、高い優先度を持つということです。
例えば、以下のように宣言した場合、
interface Cloner {
clone(animal: Animal): Animal;
}
interface Cloner {
clone(animal: Sheep): Sheep;
}
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
}
以下のように宣言がマージされます。
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
clone(animal: Sheep): Sheep;
clone(animal: Animal): Animal;
}
同じ宣言内でのメンバ関数の順序は維持されているものの、より後の宣言ほどオーバーロードの優先度が高くなっていることに注意してください。
Dog
、Cat
の順は 3 番目のCloner
で宣言した通りだけど、Animal
、Sheep
、Dog
(とCat
) は 3 番目、2 番目、1 番目の順になっている
ただし、唯一の例外として、単一の リテラル文字列型を引数にとる (つまり、リテラル文字列型の共用型でない) メンバ関数はオーバーロードの優先度が引き上げられます。
例えば、以下のように宣言した場合、
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;
}
以下のように宣言がマージされます。
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
名前空間をマージする例を見てみましょう。
namespace Animals {
export class Zebra { }
}
namespace Animals {
export interface Legged { numberOfLegs: number; }
export class Dog { }
}
これは以下のように宣言することと等価です。
namespace Animals {
export interface Legged { numberOfLegs: number; }
export class Zebra { }
export class Dog { }
}
エクスポートされていないメンバは元の (マージ前の) 名前空間の中でしか参照することはできません。
つまり、他の名前空間からマージされてきたメンバからはエクスポートされていないメンバを参照できないということです。
以下の例では、haveMuscles
はエクスポートされておらず、animalsHaveMuscles
関数からのみ、参照することができます。
また、doAnimalsHaveMuscles
関数は同じ Animal
名前空間に所属していますが、エクスポートされていないメンバ (haveMuscles
) を参照することはできません。
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
クラスを含む名前空間をマージすることで、内部関数を実現することができます。
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel { }
}
この場合の可視性のルールは Merging Namespace で述べたものと同じです。
そのため、内部クラスにアクセスするために AlbumLabel
クラスをエクスポートする必要があります。
内部クラスの他に、JavaScript では関数にプロパティを追加することもよくあると思いますが、宣言言のマージを利用することで、それを型安全に実現することができます。
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 に静的メンバを追加するために名前空間を使用することもできます。
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 では宣言のマージを行うことはできませんが、以下のように既存のオブジェクトをパッチすることは可能です。
// observable.js
export class Observable<T> {
// ... 実装は読者に任せる ...
}
// map.js
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
// ... こちらも読者に任せる
}
これは TypeScript でも動作しますが、コンパイラは Observable.prototype.map
に関する情報を持っていません。
そのため、モジュールの増補を行うことでコンパイラに情報を伝えることができます。
// 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
モジュールの中からグローバルスコープに対して宣言を追加することも可能です。
// observable.ts
export class Observable<T> {
// ... still no implementation ...
}
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
// ...
}
グローバルな増補もモジュールの増補と同じ振る舞い、制約を持ちます。