TypeScript には、同じ名前で宣言したものが自動的に1つにまとめられるという、少し不思議な仕組みがあります。これが宣言のマージ(Declaration Merging)です。仕組みを知らないと「なぜか型にプロパティが増えている」「外部ライブラリの型を後から書き足せる」といった挙動に戸惑いますが、理解すると型の拡張がとても柔軟になります。この記事では、同名 interface のマージから、namespace と関数・クラスの合成、declare module や declare global による既存ライブラリの型拡張(module augmentation)まで、動くコードとあわせて解説します。
目次
宣言のマージとは何か
宣言のマージとは、同じスコープ内で同じ名前を持つ複数の宣言を、TypeScript が1つの定義として統合する仕組みです。通常のプログラミングでは、同じ名前を2回宣言するとエラーになりそうなものですが、TypeScript の型の世界ではいくつかの宣言どうしが「合体」します。
この仕組みがもっともよく登場するのが interface です。同じ名前の interface を複数書くと、それぞれのプロパティがすべて合わさった1つの interface として扱われます。まずはこの基本から見ていきましょう。
同名 interface はプロパティが合体する
同じ名前で interface を複数回宣言すると、それぞれが持つプロパティがすべて1つに統合されます。次の例では、3か所に分けて書いた User が、最終的に3つのプロパティを持つ1つの型になります。
// 同じ名前の interface を複数回宣言する
interface User {
id: number;
}
interface User {
name: string;
}
interface User {
email: string;
}
// 3つの宣言がマージされ、User は id・name・email を持つ型になる
const user: User = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
};
console.log(user.id, user.name, user.email);
3つの interface User は別々の型ではなく、id・name・email をすべて持つ1つの User にマージされます。そのため、いずれかのプロパティが欠けているとエラーになります。ただし、同じプロパティ名で異なる型を宣言すると衝突してエラーになる点には注意してください(同じ型であれば問題ありません)。
interface Box {
value: string;
}
interface Box {
// エラー: 後続のプロパティ宣言は同じ型でなければなりません。
// プロパティ 'value' の型は 'string' である必要がありますが、
// ここでは型が 'number' になっています。
value: number;
}
この「同名 interface があとから合体する」性質こそが、後半で紹介する外部ライブラリの型拡張の土台になります。逆に、type エイリアスは同じ名前で二度宣言できず、マージもされません。この違いは記事の後半のテーブルで詳しく比較します。
interface と namespace を組み合わせて型を入れ子にする
interface と同じ名前の namespace を宣言すると、両者がマージされます。namespace の中で export した型は、interface 名を通じてドット記法で参照できる入れ子の型になります。1つの名前のもとに、本体の型と関連する型をまとめて整理したいときに便利です。
// 本体となる interface
interface Config {
name: string;
level: Config.Level; // 下の namespace で定義した型を参照
}
// 同名の namespace をマージして、関連する型を入れ子にする
namespace Config {
export type Level = 'low' | 'high';
}
const config: Config = {
name: 'app',
level: 'high', // Config.Level に含まれる値のみ許可される
};
// namespace 側の型は Config.Level として参照できる
const level: Config.Level = 'low';
console.log(config.name, level);
Config という1つの名前で、オブジェクトの形(interface)と、それに付随する型(namespace 内の Level)の両方を扱えるようになりました。関連する型をグローバルにばらまかず、名前の下にまとめられるのが利点です。
関数やクラスに namespace で静的メンバーを足す
namespace は、関数やクラスと同じ名前で宣言することでもマージできます。これを使うと、関数に静的なプロパティを持たせたり、クラスに補助的な型や定数を付け足したりできます。JavaScript では関数もオブジェクトなのでプロパティを持てますが、その形に型を付ける手段として namespace マージが役立ちます。
まずは関数の例です。関数 greet に、namespace を通じて defaultName というプロパティを追加します。
// 関数を宣言する
function greet(name: string): string {
return `こんにちは、${name}さん`;
}
// 同名の namespace で、関数に持たせるプロパティの型を宣言する
namespace greet {
export let defaultName: string;
}
// 実体(値)としてプロパティを代入する
greet.defaultName = 'ゲスト';
console.log(greet('Alice')); // こんにちは、Aliceさん
console.log(greet.defaultName); // ゲスト
console.log(greet(greet.defaultName)); // こんにちは、ゲストさん
greet は呼び出せる関数でありながら、greet.defaultName というプロパティも持ちます。namespace 側は型の宣言だけを担い、実際の値は greet.defaultName = 'ゲスト' のように代入して用意する点がポイントです。
クラスも同様です。クラスと同名の namespace をマージすると、そのクラスに関連する型や定数を「クラス名.○○」の形でまとめられます。
class Button {
constructor(public label: string) {}
}
// 同名の namespace をマージして、関連する型を入れ子にする
namespace Button {
export type Variant = 'primary' | 'secondary';
export function create(label: string): Button {
return new Button(label);
}
}
// クラス名の下にまとめた型・関数を参照できる
const variant: Button.Variant = 'primary';
const btn = Button.create('送信');
console.log(btn.label, variant); // 送信 primary
Button はインスタンス化できるクラスでありながら、Button.Variant という型や Button.create() という補助関数も持ちます。クラスに関連するものを1つの名前空間に集約でき、コードの見通しがよくなります。
既存ライブラリの型を declare module で拡張する
宣言のマージがもっとも実用的に役立つのが、外部モジュールの型をあとから拡張する module augmentation(モジュール拡張)です。同名 interface がマージされる性質を利用して、ライブラリが提供している interface に自分のプロパティを足し込みます。
典型例が Express です。リクエストオブジェクトに独自のプロパティ(ログイン中のユーザー情報など)を持たせたいとき、declare module で Express の Request インターフェースを拡張します。
// 既存モジュール 'express-serve-static-core' の型を拡張する
import 'express';
declare module 'express-serve-static-core' {
// 元から定義されている Request interface にプロパティを足す
interface Request {
user?: {
id: number;
name: string;
};
}
}
この宣言を置いておくと、プロジェクト全体で Request に user プロパティが増えたものとして扱われます。ライブラリのソースを書き換えることなく、型定義だけを安全に拡張できるわけです。
import type { Request, Response } from 'express';
function handler(req: Request, res: Response): void {
// 拡張した user プロパティに型安全にアクセスできる
if (req.user) {
res.send(`ようこそ、${req.user.name}さん`);
} else {
res.send('ログインしてください');
}
}
ポイントは、拡張したいモジュール名(ここでは Express の型本体がある 'express-serve-static-core')を declare module に正確に書き、その中で元と同じ名前の interface を宣言することです。ファイルの先頭で対象モジュールを import しておくことで、そのファイルがモジュールとして扱われ、拡張が正しく適用されます。
declare global でグローバルな型を拡張する
モジュールではなく、window オブジェクトのようなグローバルな型を拡張したいときは declare global を使います。たとえば、アプリ独自の設定を window.myApp として持たせたい場合、次のように Window インターフェースを拡張します。
// このファイルをモジュールにするための空 export
export {};
declare global {
// グローバルの Window interface を拡張する
interface Window {
myApp: {
version: string;
debug: boolean;
};
}
}
// 拡張済みなので、window.myApp に型安全にアクセスできる
window.myApp = {
version: '1.0.0',
debug: true,
};
console.log(window.myApp.version); // 1.0.0
declare global の中で interface Window を宣言すると、TypeScript が元々持っているグローバルの Window にマージされ、window.myApp が型として認識されます。export {} を1行入れているのは、このファイルを「グローバルスクリプト」ではなく「モジュール」として扱わせるためです。モジュール内でグローバルを拡張するときは declare global が必須になります。
type エイリアスがマージできない理由と interface との違い
ここまで見てきたマージは、そのほとんどが interface の性質に支えられています。よく似た type エイリアスは、同じ名前で二度宣言することができず、マージもされません。
type Animal = {
name: string;
};
// エラー: 識別子 'Animal' が重複しています。
type Animal = {
age: number;
};
type は「既存の型に別名を付ける」だけの宣言で、同じ名前は1つしか持てません。そのため、あとから拡張したい・ライブラリの型に足し込みたい、という用途では interface を使う必要があります。両者の違いを整理すると次のようになります。
| 観点 | interface | type エイリアス |
|---|---|---|
| 同名の再宣言 | できる(複数書ける) | できない(重複エラー) |
| 宣言のマージ | される(プロパティが合体) | されない |
| ライブラリ型の拡張 (module augmentation) | 向いている | 使えない |
| ユニオン型・タプルなどの表現 | できない | できる |
ユニオン型や複雑な型の組み立ては type の得意分野ですが、「あとから拡張される可能性がある型」「ライブラリの型に足し込みたい型」は interface で宣言しておくのが定石です。宣言のマージを活かせるかどうかが、両者を使い分ける大きな判断材料になります。
マージが効かないときに確認すること
module augmentation を書いたのに型が反映されない、というのはよくあるつまずきです。原因はたいてい次のいずれかです。
拡張対象のモジュール名が違っている
declare module に書くのは、実際に型が定義されているモジュール名です。Express のように、公開パッケージ名(express)と型本体があるパッケージ名(express-serve-static-core)が異なることがあります。拡張が効かないときは、対象の型がどのモジュールで宣言されているかを型定義ファイルで確認してください。名前が1文字でも違うと、マージではなく別モジュールの新規宣言として扱われてしまいます。
ファイルがモジュールとして認識されていない
declare global や module augmentation は、そのファイルが「モジュール」であることが前提です。ファイル内に import や export が1つもないと、TypeScript はそのファイルをグローバルスクリプトとみなし、declare global がエラーになります。何もインポート・エクスポートしていないファイルでは、先頭に export {}; を1行足してモジュール化してください。
型定義ファイルがコンパイル対象に含まれていない
.d.ts に拡張を書いても、そのファイルが tsconfig.json の include の範囲外にあると読み込まれません。拡張用の型定義ファイルは src 配下などコンパイル対象のディレクトリに置くか、tsconfig.json の include や typeRoots で確実に拾われるように設定します。エディタでは効くのにビルドで効かない、という場合はこの設定を疑うとよいでしょう。
まとめ
宣言のマージは、同じ名前を持つ複数の宣言を TypeScript が1つに統合する仕組みです。もっとも基本となるのは同名 interface のマージで、それぞれのプロパティが合体して1つの型になります。interface と namespace を組み合わせれば関連する型を入れ子にでき、関数やクラスに namespace をマージすれば静的メンバー的なプロパティや補助関数を型安全に足せます。実用面では、同名 interface がマージされる性質を利用した declare module による module augmentation や、declare global によるグローバル拡張が強力で、ライブラリのソースを触らずに型を拡張できます。一方 type エイリアスは同名で再宣言できずマージもされないため、あとから拡張したい型は interface で宣言しておくのが定石です。拡張が効かないときは、モジュール名・ファイルのモジュール化・コンパイル対象の3点を確認してみてください。