1. ホーム
  2. TypeScript

【TypeScript】declaration merging(宣言のマージ)の使い方|interface の統合と型の拡張を解説

Share

TypeScript には、同じ名前で宣言したものが自動的に1つにまとめられるという、少し不思議な仕組みがあります。これが宣言のマージ(Declaration Merging)です。仕組みを知らないと「なぜか型にプロパティが増えている」「外部ライブラリの型を後から書き足せる」といった挙動に戸惑いますが、理解すると型の拡張がとても柔軟になります。この記事では、同名 interface のマージから、namespace と関数・クラスの合成、declare moduledeclare global による既存ライブラリの型拡張(module augmentation)まで、動くコードとあわせて解説します。

宣言のマージとは何か

宣言のマージとは、同じスコープ内で同じ名前を持つ複数の宣言を、TypeScript が1つの定義として統合する仕組みです。通常のプログラミングでは、同じ名前を2回宣言するとエラーになりそうなものですが、TypeScript の型の世界ではいくつかの宣言どうしが「合体」します。

この仕組みがもっともよく登場するのが interface です。同じ名前の interface を複数書くと、それぞれのプロパティがすべて合わさった1つの interface として扱われます。まずはこの基本から見ていきましょう。

同名 interface はプロパティが合体する

同じ名前で interface を複数回宣言すると、それぞれが持つプロパティがすべて1つに統合されます。次の例では、3か所に分けて書いた User が、最終的に3つのプロパティを持つ1つの型になります。

interface-merge.ts
// 同じ名前の 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 は別々の型ではなく、idnameemail をすべて持つ1つの User にマージされます。そのため、いずれかのプロパティが欠けているとエラーになります。ただし、同じプロパティ名で異なる型を宣言すると衝突してエラーになる点には注意してください(同じ型であれば問題ありません)。

interface-conflict.ts
interface Box {
  value: string;
}

interface Box {
  // エラー: 後続のプロパティ宣言は同じ型でなければなりません。
  // プロパティ 'value' の型は 'string' である必要がありますが、
  // ここでは型が 'number' になっています。
  value: number;
}

この「同名 interface があとから合体する」性質こそが、後半で紹介する外部ライブラリの型拡張の土台になります。逆に、type エイリアスは同じ名前で二度宣言できず、マージもされません。この違いは記事の後半のテーブルで詳しく比較します。

interface と namespace を組み合わせて型を入れ子にする

interface と同じ名前の namespace を宣言すると、両者がマージされます。namespace の中で export した型は、interface 名を通じてドット記法で参照できる入れ子の型になります。1つの名前のもとに、本体の型と関連する型をまとめて整理したいときに便利です。

interface-namespace.ts
// 本体となる 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-namespace.ts
// 関数を宣言する
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-namespace.ts
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.d.ts
// 既存モジュール 'express-serve-static-core' の型を拡張する
import 'express';

declare module 'express-serve-static-core' {
  // 元から定義されている Request interface にプロパティを足す
  interface Request {
    user?: {
      id: number;
      name: string;
    };
  }
}

この宣言を置いておくと、プロジェクト全体で Requestuser プロパティが増えたものとして扱われます。ライブラリのソースを書き換えることなく、型定義だけを安全に拡張できるわけです。

handler.ts
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 インターフェースを拡張します。

global.d.ts
// このファイルをモジュールにするための空 export
export {};

declare global {
  // グローバルの Window interface を拡張する
  interface Window {
    myApp: {
      version: string;
      debug: boolean;
    };
  }
}
app.ts
// 拡張済みなので、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-alias.ts
type Animal = {
  name: string;
};

// エラー: 識別子 'Animal' が重複しています。
type Animal = {
  age: number;
};

type は「既存の型に別名を付ける」だけの宣言で、同じ名前は1つしか持てません。そのため、あとから拡張したい・ライブラリの型に足し込みたい、という用途では interface を使う必要があります。両者の違いを整理すると次のようになります。

観点interfacetype エイリアス
同名の再宣言できる(複数書ける)できない(重複エラー)
宣言のマージされる(プロパティが合体)されない
ライブラリ型の拡張
(module augmentation)
向いている使えない
ユニオン型・タプルなどの表現できないできる

ユニオン型や複雑な型の組み立ては type の得意分野ですが、「あとから拡張される可能性がある型」「ライブラリの型に足し込みたい型」は interface で宣言しておくのが定石です。宣言のマージを活かせるかどうかが、両者を使い分ける大きな判断材料になります。

マージが効かないときに確認すること

module augmentation を書いたのに型が反映されない、というのはよくあるつまずきです。原因はたいてい次のいずれかです。

拡張対象のモジュール名が違っている

declare module に書くのは、実際に型が定義されているモジュール名です。Express のように、公開パッケージ名(express)と型本体があるパッケージ名(express-serve-static-core)が異なることがあります。拡張が効かないときは、対象の型がどのモジュールで宣言されているかを型定義ファイルで確認してください。名前が1文字でも違うと、マージではなく別モジュールの新規宣言として扱われてしまいます。

ファイルがモジュールとして認識されていない

declare global や module augmentation は、そのファイルが「モジュール」であることが前提です。ファイル内に importexport が1つもないと、TypeScript はそのファイルをグローバルスクリプトとみなし、declare global がエラーになります。何もインポート・エクスポートしていないファイルでは、先頭に export {}; を1行足してモジュール化してください。

型定義ファイルがコンパイル対象に含まれていない

.d.ts に拡張を書いても、そのファイルが tsconfig.jsoninclude の範囲外にあると読み込まれません。拡張用の型定義ファイルは src 配下などコンパイル対象のディレクトリに置くか、tsconfig.jsonincludetypeRoots で確実に拾われるように設定します。エディタでは効くのにビルドで効かない、という場合はこの設定を疑うとよいでしょう。

まとめ

宣言のマージは、同じ名前を持つ複数の宣言を TypeScript が1つに統合する仕組みです。もっとも基本となるのは同名 interface のマージで、それぞれのプロパティが合体して1つの型になります。interfacenamespace を組み合わせれば関連する型を入れ子にでき、関数やクラスに namespace をマージすれば静的メンバー的なプロパティや補助関数を型安全に足せます。実用面では、同名 interface がマージされる性質を利用した declare module による module augmentation や、declare global によるグローバル拡張が強力で、ライブラリのソースを触らずに型を拡張できます。一方 type エイリアスは同名で再宣言できずマージもされないため、あとから拡張したい型は interface で宣言しておくのが定石です。拡張が効かないときは、モジュール名・ファイルのモジュール化・コンパイル対象の3点を確認してみてください。

参考ページ