11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[TypeScriptシリーズ - Part 7] Declaration Merging

11
Posted at

📝 注記
私は日本語が得意ではありません。この記事はAIの翻訳サポートを受けて書いています。ご了承ください。

📖 目次

  1. 問題の提示 – どんな時にこのテクニックが必要か
  2. 悪い例 – まずはダメなコードを見せる
  3. 良い例 – TypeScriptの高度機能で解決する
  4. Playgroundリンク – その場で試せる
  5. 課題 – シニア向けのチャレンジ問題
  6. まとめ

1. 問題の提示 – どんな時にこのテクニックが必要か

あなたは大規模なTypeScriptプロジェクトで、以下のような課題に直面しています。

課題1: ライブラリの型定義を拡張したいが、元のファイルを変更できない

// サードパーティライブラリの型定義
interface Window {
  // 元の定義にはないプロパティを追加したい
}

課題2: 同じ名前のインターフェースを複数のファイルで定義したい

// あるファイル
interface User {
  id: number;
  name: string;
}

// 別のファイルで同じUserにプロパティを追加したい
interface User {
  email: string;  // これがマージされてほしい
}

課題3: 関数とオブジェクトを同じ名前で定義したい(jQueryスタイル)

// $は関数としても、オブジェクトとしても使える
$("#id");    // 関数として呼び出し
$.ajax({}); // オブジェクトのメソッドとして使用

問いかけ:

どうすればTypeScriptに同じ名前の宣言を自動的にマージさせられるでしょうか?


2. 悪い例 – まずはダメなコードを見せる

// ❌ 宣言マージを知らないと起こる問題

// 問題1: インターフェースの拡張ができないと思ってしまう
// user.ts
interface User {
  id: number;
  name: string;
}

// admin.ts - 同じUserにプロパティを追加したいが...
// ❌ エラーになると思い込んでいる
interface User {
  isAdmin: boolean;
}

// 問題2: 関数にプロパティを追加できない
function $(selector: string) {
  return document.querySelector(selector);
}

$.ajax = function(url: string) {
  return fetch(url);
};
// ❌ Property 'ajax' does not exist on type '(selector: string) => Element | null'

// 問題3: ネームスペースの拡張で非公開メンバーにアクセスしようとする
namespace Utils {
  let privateHelper = "secret";
  export function format(str: string) {
    return str.trim();
  }
}

namespace Utils {
  export function log(str: string) {
    console.log(`[LOG] ${str}`);
    // return privateHelper; // ❌ 別のネームスペース宣言からはアクセスできない
  }
}

なぜ悪いのか:

問題 説明
拡張性の制限 サードパーティの型を拡張する方法を知らない
コード分割の困難 同じ型定義を複数のファイルに分散できない
パターンの実装不可 jQueryのようなAPIパターンを型安全に実装できない
モジュール設計の複雑化 ネームスペースの可視性ルールを理解していない

3. 良い例 – TypeScriptの高度機能で解決する

基本: Declaration Mergingとは?

Declaration Merging(宣言マージ)は、TypeScriptコンパイラが同じ名前を持つ複数の宣言を1つの定義に自動結合する機能です。

TypeScriptの宣言は以下の3つのカテゴリーに分類されます。

宣言の種類 Namespace Type Value
Namespace
Class
Enum
Interface
Type Alias
Function
Variable

ユースケース1: インターフェースのマージ

// ✅ 同じ名前のインターフェースは自動マージされる

// user.ts
interface User {
  id: number;
  name: string;
}

// user-extension.ts
interface User {
  email: string;
  isActive: boolean;
}

// admin.ts
interface User {
  role: "admin" | "user" | "guest";
}

// 最終的なUser型(すべてのプロパティがマージされる)
const user: User = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  isActive: true,
  role: "admin"
};  // ✅ すべてのプロパティが必要

ユースケース2: 関数のオーバーロードとマージ順序

// ✅ インターフェースの関数シグネチャはオーバーロードとしてマージされる

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;       // 最後のグループの2番目
  clone(animal: Sheep): Sheep;   // 2番目のグループ
  clone(animal: Animal): Animal; // 最初のグループ(最後)
}

// 特殊なケース: 文字列リテラル型は先頭に移動
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;
}

ユースケース3: ネームスペースのマージ

// ✅ 同じ名前のネームスペースはマージされる

// database/models.ts
namespace Models {
  export interface User {
    id: number;
    name: string;
  }
  export class UserModel {
    static find(id: number): User {
      return { id, name: "Alice" };
    }
  }
}

// database/relations.ts
namespace Models {
  export interface Post {
    id: number;
    title: string;
  }
  export class PostModel {
    static findByUser(userId: number): Post[] {
      return [{ id: 1, title: "Hello" }];
    }
  }
}

// マージ後、以下のように使用可能
const user  = Models.UserModel.find(1);
const posts = Models.PostModel.findByUser(1);

// 注意: 非エクスポートメンバーは元のネームスペース内でのみ可視
namespace First {
  let privateVar = "secret";
  export function canSee() {
    return privateVar; // ✅ OK: 同じ宣言内
  }
}

namespace First {
  export function cannotSee() {
    // return privateVar; // ❌ エラー: privateVarはここから見えない
  }
}

ユースケース4: クラスとネームスペースのマージ(内部クラス)

// ✅ クラスと同名のネームスペースで内部クラスを表現

class Album {
  label: Album.AlbumLabel;

  constructor(label: Album.AlbumLabel) {
    this.label = label;
  }
}

namespace Album {
  export class AlbumLabel {
    constructor(public name: string) {}
  }

  export function createDefault(): Album {
    return new Album(new AlbumLabel("Default"));
  }
}

// 使用例
const label        = new Album.AlbumLabel("Greatest Hits");
const album        = new Album(label);
const defaultAlbum = Album.createDefault();

ユースケース5: 関数とネームスペースのマージ(jQueryスタイル)

// ✅ 関数と同名のネームスペースで拡張可能な関数を実装

function $(selector: string): Element | null {
  return document.querySelector(selector);
}

namespace $ {
  export function ajax(options: {
    url: string;
    method?: string;
    data?: any;
  }): Promise<Response> {
    return fetch(options.url, {
      method: options.method || "GET",
      body: options.data
    });
  }

  export function getJSON<T>(url: string): Promise<T> {
    return fetch(url).then(res => res.json());
  }

  export let version = "1.0.0";
}

// 使用例
const element = $("#app");
$.ajax({ url: "/api/users" });
$.getJSON<User[]>("/api/users");
console.log($.version);

ユースケース6: Enumとネームスペースのマージ

// ✅ Enumに静的メソッドを追加

enum Color {
  Red   = 1,
  Green = 2,
  Blue  = 4
}

namespace Color {
  export function mix(color1: Color, color2: Color): Color {
    return (color1 | color2) as Color;
  }

  export function getColorName(color: Color): string {
    switch (color) {
      case Color.Red:   return "Red";
      case Color.Green: return "Green";
      case Color.Blue:  return "Blue";
      default:          return "Mixed";
    }
  }

  export const allColors = [Color.Red, Color.Green, Color.Blue];
}

// 使用例
const purple = Color.mix(Color.Red, Color.Blue);
console.log(Color.getColorName(purple)); // "Mixed"

ユースケース7: モジュール拡張(Module Augmentation)

📝 注意: モジュール拡張を使う場合、拡張ファイルを必ずインポートしてから使用してください。インポート順序によって動作が変わることがあります。

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

  subscribe(callback: (value: T) => void) {
    callback(this.value);
  }
}

// observable-map.ts - Observableにmapメソッドを追加
import { Observable } from "./observable";

// 型定義の拡張
declare module "./observable" {
  interface Observable<T> {
    map<U>(fn: (value: T) => U): Observable<U>;
  }
}

// 実装(プロトタイプに追加)
Observable.prototype.map = function<T, U>(
  this: Observable<T>,
  fn: (value: T) => U
): Observable<U> {
  return new Observable(fn(this.value));
};

// consumer.ts
import { Observable } from "./observable";
import "./observable-map"; // ← 拡張を明示的にインポート

const obs    = new Observable(42);
const mapped = obs.map(x => x * 2); // Observable<number>
mapped.subscribe(val => console.log(val)); // 84

ユースケース8: グローバル拡張(Global Augmentation)

📝 注意: グローバル拡張は組み込み型(ArrayStringなど)を変更するため、ライブラリ開発やプロジェクト全体に影響します。慎重に使用してください。

// global.d.ts
import { Observable } from "./observable"; // Observableを使う場合はインポートが必要

declare global {
  interface Array<T> {
    first(): T | undefined;
    last(): T | undefined;
  }

  interface String {
    toSlug(): string;
  }
}

// 実装(例: utils/array-extensions.ts)
Array.prototype.first = function<T>(this: T[]): T | undefined {
  return this[0];
};

Array.prototype.last = function<T>(this: T[]): T | undefined {
  return this[this.length - 1];
};

String.prototype.toSlug = function(this: string): string {
  return this.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]+/g, "");
};

// 使用例
const arr  = [1, 2, 3];
const first = arr.first(); // 1
const last  = arr.last();  // 3

const slug = "Hello World".toSlug(); // "hello-world"

4. Playgroundリンク – その場で試せる

理論だけでは実感しにくいので、実際に動かして確認してみましょう。
TypeScript Playgroundはブラウザ上でTypeScriptを実行できる公式ツールです。インストール不要、すぐに試せます。

🔗 Playground URL: https://www.typescriptlang.org/play/

何を確認できるのか?

下のコードをコピーしてPlaygroundに貼り付けた後、boxgreet にホバーしてみてください。複数の宣言がひとつの型にマージされていることが確認できます。

// ① インターフェースマージ
interface Box {
  height: number;
  width: number;
}

interface Box {
  scale: number;
  color?: string;
}

// ② ホバーするとマージされた型が見える
const box: Box = {
  height: 10,
  width:  20,
  scale:  1.5,
  color:  "red"
};

// ③ ネームスペースマージ
namespace Greetings {
  export function hello() { return "Hello"; }
}

namespace Greetings {
  export function world() { return "World"; }
}

console.log(Greetings.hello(), Greetings.world()); // "Hello" "World"

// ④ 関数とネームスペースのマージ
function greet(name: string): string {
  return `${greet.prefix}${name}${greet.suffix}`;
}

namespace greet {
  export let prefix = "Hello, ";
  export let suffix = "!";
}

// ⑤ greet にホバーして型を確認しよう
console.log(greet("TypeScript")); // "Hello, TypeScript!"

ホバーすると何が見える?

box にホバーすると heightwidthscalecolor の4つのプロパティがすべて含まれた型が表示されます。greet にホバーすると関数シグネチャに加えて prefixsuffix プロパティも持っていることが確認できます。


5. 課題 – シニア向けのチャレンジ問題

課題1: インターフェースマージの順序

以下のインターフェースがマージされた結果、どのような型になるか予想してください。

interface Processor {
  process(data: string): string;
}

interface Processor {
  process(data: number): number;
}

interface Processor {
  process<T>(data: T[]): T[];
  process(data: "special"): "special_result";
}

💡 ヒント: 文字列リテラル型は最優先、その後は後ろのグループから順に並びます。

✅ 解答を見る(クリック)
// マージ結果(文字列リテラルが最優先、後のグループが先に来る)
interface Processor {
  process(data: "special"): "special_result"; // 文字列リテラル
  process<T>(data: T[]): T[];                 // 最後のグループのジェネリック
  process(data: number): number;              // 2番目のグループ
  process(data: string): string;              // 最初のグループ(最後)
}

課題2: jQueryライクなAPIの実装

以下の要件を満たす query 関数を宣言マージを使って実装してください。

  • query(selector: string): Element | null — DOM要素を取得
  • query.all(selector: string): NodeListOf<Element> — 全要素を取得
  • query.version: string — バージョン情報
  • query.on(element: Element, event: string, handler: () => void): void — イベントリスナー追加

💡 ヒント: 関数とネームスペースのマージ(ユースケース5)と同じパターンです。

✅ 解答を見る(クリック)
function query(selector: string): Element | null {
  return document.querySelector(selector);
}

namespace query {
  export function all(selector: string): NodeListOf<Element> {
    return document.querySelectorAll(selector);
  }

  export function on(
    element: Element,
    event: string,
    handler: () => void
  ): void {
    element.addEventListener(event, handler);
  }

  export let version = "1.0.0";
}

// 使用例
const element  = query("#app");
const elements = query.all(".item");
if (element) query.on(element, "click", () => console.log("Clicked"));
console.log(query.version);

課題3: サードパーティモジュールの拡張

express モジュールの Request インターフェースに user プロパティを追加するモジュール拡張を書いてください。

// expressの元の型定義(簡略版)
declare module "express" {
  interface Request {
    params: any;
    query: any;
    body: any;
  }
}

// ここに拡張を書く

💡 ヒント: declare module "express" を再度宣言して、Request インターフェースにプロパティを追加します。

✅ 解答を見る(クリック)
// types/express-extension.d.ts
import "express";

declare module "express" {
  interface Request {
    user?: {
      id: number;
      name: string;
      email: string;
    };
  }
}

// 使用例
app.get("/profile", (req, res) => {
  if (req.user) {
    res.json(req.user); // req.user が型安全に使用可能
  }
});

課題4(ボーナス): ネームスペースの可視性

以下のコードで、なぜ doAnimalsHaveMuscles がエラーになるのか説明してください。

namespace Animal {
  let haveMuscles = true;

  export function animalsHaveMuscles() {
    return haveMuscles; // ✅ OK
  }
}

namespace Animal {
  export function doAnimalsHaveMuscles() {
    return haveMuscles; // ❌ エラー: 'haveMuscles' が見つからない
  }
}

💡 ヒント: 非エクスポートメンバーのスコープがポイントです。

✅ 解答を見る(クリック)

非エクスポートメンバー(haveMuscles)は、宣言された元のネームスペースブロック内でのみ可視です。

animalsHaveMuscleshaveMuscles と同じネームスペースブロック内にあるためアクセスできますが、doAnimalsHaveMuscles別のネームスペースブロックに宣言されています。マージ後も非エクスポートメンバーのスコープは元のブロックのままなので、別ブロックからはアクセスできません。

解決策は haveMusclesexport するか、同じブロック内に移動することです。


6. まとめ

今日学んだこと

マージの種類 動作 ユースケース
Interface + Interface プロパティを結合、関数はオーバーロード 型定義の分割、拡張
Namespace + Namespace エクスポートされたメンバーを結合 コードの論理分割
Class + Namespace 内部クラス、静的メンバーの追加 名前空間付きクラス
Function + Namespace 関数にプロパティを追加 jQueryスタイルAPI
Enum + Namespace Enumに静的メソッドを追加 ユーティリティ関数
Module Augmentation モジュールの型を拡張 サードパーティ拡張
Global Augmentation グローバル型を拡張 ポリフィル、ヘルパー

重要な注意点

✅ できること ❌ できないこと
インターフェースを複数回宣言してマージ クラス同士のマージ
ネームスペースでクラスや関数を拡張 Type Aliasのマージ
モジュールの型を拡張(Module Augmentation) 異なる型のプロパティを同じ名前で宣言
グローバルスコープに型を追加 デフォルトエクスポートの拡張

シニアへのアドバイス

宣言マージを理解すると、サードパーティライブラリの型拡張、コードの整理、既存JavaScriptパターンの型安全な実装が可能になります。
ただし、過度な使用はコードの可読性を下げる可能性があるので、特に大規模チームではマージが発生している箇所を明確にドキュメント化することをお勧めします。
また、モジュール拡張はインポート順序によって動作が変わることがあるため、拡張ファイルのインポートを明示的に管理してください。

Have a nice day!

11
8
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
11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?