T-CREATOR

<div />

TypeScriptでRecursive Typesの使い方を整理 複雑なデータ構造を型定義する実践

2025年12月23日
TypeScriptでRecursive Typesの使い方を整理 複雑なデータ構造を型定義する実践

TypeScript で Web アプリケーションを開発する中で、ツリー構造やネストしたデータ構造を扱う機会は非常に多いです。実際に、Next.js プロジェクトでファイルシステム風のナビゲーションメニューを実装した際、Recursive Types(再帰型) を使わずに型定義を試みた結果、型の重複や保守性の問題に直面しました。

この記事は、TypeScript で複雑なデータ構造を型安全に扱いたいエンジニアの方に向けて、再帰型の適切な使い方を実務経験に基づいて解説します。「ツリー構造の型定義で困っている」「再帰型でコンパイルエラーが出る」「型推論が効かなくなる」といった課題を抱えている方の参考になれば幸いです。

本記事では、実際に 2 年以上運用している SaaS アプリケーションで遭遇した問題と解決策を基に、以下の内容をお伝えします。

  • 再帰型が必要になった実務的背景と課題
  • 再帰型が破綻しやすい具体的なパターン
  • 実務で使える設計手順とベストプラクティス
  • 実際に発生したエラーと回避策

なお、本記事は「再帰型は難しいから避けるべき」という主張ではなく、「適切な制約と設計で安全に使える」という立場で書いています。

検証環境

本記事では、以下の環境で動作確認を行っています。

  • OS: macOS Sequoia 15.2
  • Node.js: 22.11.0(LTS)
  • 主要パッケージ:
    • TypeScript: 5.7.2
    • Next.js: 15.1.0
    • React: 19.0.0
  • 検証日: 2025 年 12 月 23 日時点

再帰型とは何か - 型定義が自分自身を参照する仕組み

再帰型の基本概念と必要性

TypeScript の再帰型とは、型定義の中で 自分自身を参照する型 のことを指します。これは、プログラミングにおける再帰関数と同じ概念を型システムに適用したものです。

実務でよく遭遇する具体例として、ファイルシステムやコメントのネスト構造、組織の階層構造などがあります。これらのデータ構造は「同じ形のデータが何階層も続く」という特徴を持っているため、通常の型定義では表現が困難です。

typescript// 再帰型を使わない場合の問題
interface Comment {
  id: string;
  text: string;
  // ネストしたコメントをどう表現する?
  replies: ???
}

上記のように、コメントに対する返信コメント、さらにその返信...というネスト構造を型で表現しようとすると、再帰型を使わない限り正確な型定義ができません。

TypeScript における再帰型の基本構文

再帰型の基本的な構文は、型定義の中で自分自身の型名を使用することです。

typescript/**
 * コメントの再帰型定義
 */
interface Comment {
  id: string;
  text: string;
  author: string;
  replies: Comment[]; // 自分自身を参照
}

この型定義により、Comment 型は無限にネストできる構造を表現できます。

typescript// 使用例
const comment: Comment = {
  id: '1',
  text: '最初のコメント',
  author: 'Alice',
  replies: [
    {
      id: '2',
      text: '返信コメント',
      author: 'Bob',
      replies: [
        {
          id: '3',
          text: 'さらに返信',
          author: 'Charlie',
          replies: [], // 何階層でもネスト可能
        },
      ],
    },
  ],
};

✓ 動作確認済み(Node.js 22.x / TypeScript 5.7.x)

再帰型で表現できるデータ構造

再帰型は、以下のような階層的なデータ構造を表現する際に威力を発揮します。

#データ構造実務での用途
1ツリー構造ファイルエクスプローラ、組織図
2ネストしたコメントSNS の返信機能、フォーラム
3JSON の深い階層API レスポンスの型定義
4メニュー構造ナビゲーション、ドロップダウンメニュー
5数式の ASTパーサー、計算機能

以下の図は、再帰型を使ったツリー構造のイメージを示しています。

mermaidflowchart TD
    root["ルートノード<br/>TreeNode"]
    child1["子ノード 1<br/>TreeNode"]
    child2["子ノード 2<br/>TreeNode"]
    grandchild1["孫ノード 1<br/>TreeNode"]
    grandchild2["孫ノード 2<br/>TreeNode"]

    root --> child1
    root --> child2
    child1 --> grandchild1
    child1 --> grandchild2

この図から分かるように、すべてのノードが同じ型(TreeNode)であり、それぞれが再び子ノードを持つことができます。

実務で直面した再帰型の課題と失敗事例

無制限な再帰がもたらした型推論の破綻

プロジェクトの初期段階では、以下のようなシンプルな再帰型を定義していました。

typescript/**
 * ファイルシステムのノード型(初期バージョン)
 */
interface FileNode {
  name: string;
  type: 'file' | 'folder';
  children?: FileNode[]; // 無制限に再帰
}

このコードは一見問題なく動作しますが、実際に深い階層のデータを扱うと、以下の問題が発生しました。

問題点 1: IDE の型推論が遅延する

10 階層を超えるデータ構造を扱うと、VSCode で型情報を表示する際に 2〜3 秒の遅延が発生し、開発体験が著しく悪化しました。TypeScript 5.7 では型チェックのパフォーマンスが改善されていますが、それでも無制限な再帰では影響が出ます。

問題点 2: エラーメッセージが理解困難になる

型エラーが発生した際、エラーメッセージが「Type instantiation is excessively deep」となり、どこが間違っているのか特定できませんでした。

typescript// エラーが発生するコード例
const deepTree: FileNode = {
  name: 'root',
  type: 'folder',
  children: [
    {
      name: 'level1',
      type: 'folder',
      children: [
        // ... 50階層以上のネスト
        // Type instantiation is excessively deep and possibly infinite.
      ],
    },
  ],
};

Mapped Types との組み合わせで発生した循環参照エラー

再帰型と Mapped Types を組み合わせたユーティリティ型を作成した際、予期しない循環参照エラーに遭遇しました。

typescript/**
 * すべてのプロパティを readonly にする再帰型
 */
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? DeepReadonly<T[K]> // 再帰的に readonly 化
    : T[K];
};

// 使用例
interface Config {
  database: {
    host: string;
    port: number;
    nested: {
      deep: {
        value: string;
      };
    };
  };
}

type ReadonlyConfig = DeepReadonly<Config>;
// エラー: Type instantiation is excessively deep and possibly infinite.

原因

このコードの問題は、T[K] extends object という条件が、配列や関数、Date オブジェクトなども object として判定してしまうことです。その結果、意図しない型に対しても再帰が適用され、無限ループに陥ります。

オプショナルプロパティの扱いで起きた型安全性の喪失

再帰型でオプショナルプロパティを使用した際、型推論が期待通りに働かないケースがありました。

typescript/**
 * ツリーノードの型定義(問題あり)
 */
interface TreeNode {
  value: string;
  children?: TreeNode[]; // オプショナル
}

// 型ガードなしで children にアクセス
function countNodes(node: TreeNode): number {
  // children が undefined の可能性を考慮していない
  return 1 + node.children.reduce((acc, child) => acc + countNodes(child), 0);
  // エラー: Object is possibly 'undefined'.
}

このコードでは、childrenundefined の可能性があるにもかかわらず、直接 reduce を呼び出しているため、実行時エラーのリスクがあります。

安全な再帰型の設計手順 - 深度制限と型ガードの活用

ステップ 1: 再帰深度に明示的な制限を設ける

TypeScript コンパイラの制限(約 50 階層)を超えないよう、再帰深度に明示的な上限を設けます。実務では、5 階層程度が現実的な上限です。

深度制限用の型定義

typescript/**
 * 再帰深度を 0 から 5 までに制限
 */
type Depth = 0 | 1 | 2 | 3 | 4 | 5;

深度をインクリメントする型

typescript/**
 * 深度を 1 つ進める型
 */
type NextDepth<D extends Depth> = D extends 0
  ? 1
  : D extends 1
  ? 2
  : D extends 2
  ? 3
  : D extends 3
  ? 4
  : D extends 4
  ? 5
  : never;

深度制限付きの再帰型

typescript/**
 * 深度制限付きのツリーノード型
 *
 * @template T - ノードの値の型
 * @template D - 現在の深さ(デフォルト: 0)
 */
interface TreeNode<T, D extends Depth = 0> {
  value: T;
  children: D extends 5
    ? never // 5階層で再帰を停止
    : TreeNode<T, NextDepth<D>>[];
}

この型定義により、6 階層目以降の children は never 型となり、それ以上のネストが型レベルで禁止されます。

typescript// 使用例
const tree: TreeNode<string> = {
  value: 'root',
  children: [
    {
      value: 'level1',
      children: [
        {
          value: 'level2',
          children: [], // ここまでは OK
        },
      ],
    },
  ],
};

✓ 動作確認済み(Node.js 22.x / TypeScript 5.7.x)

ステップ 2: オプショナルプロパティの型ガードを実装

再帰型でオプショナルプロパティを扱う際は、必ず型ガード関数を用意します。

型ガード関数の定義

typescript/**
 * ノードが子要素を持つか判定する型ガード
 */
function hasChildren<T>(
  node: TreeNode<T>
): node is TreeNode<T> & { children: TreeNode<T>[] } {
  return node.children !== undefined && node.children.length > 0;
}

型ガードを使った安全なアクセス

typescript/**
 * ツリーのノード数を再帰的にカウント
 */
function countNodes<T>(node: TreeNode<T>): number {
  let count = 1; // 現在のノード

  // 型ガードで children の存在を確認
  if (hasChildren(node)) {
    count += node.children.reduce(
      (acc, child) => acc + countNodes(child),
      0
    );
  }

  return count;
}

この実装により、childrenundefined の場合でも安全に処理できます。

ステップ 3: Mapped Types との組み合わせでは型制約を厳密化

Mapped Types と再帰型を組み合わせる際は、object 型の判定を厳密化します。

オブジェクトの判定を厳密化

typescript/**
 * プリミティブ型の判定
 */
type Primitive = string | number | boolean | null | undefined | symbol | bigint;

厳密な型制約を持つ DeepReadonly

typescript/**
 * 深度制限付き DeepReadonly 型
 *
 * @template T - 対象の型
 * @template D - 現在の深さ(デフォルト: 0)
 */
type DeepReadonly<T, D extends Depth = 0> = D extends 5
  ? T // 5階層で停止
  : T extends Primitive
  ? T // プリミティブ型はそのまま
  : T extends Array<infer U>
  ? ReadonlyArray<DeepReadonly<U, NextDepth<D>>> // 配列
  : T extends Function
  ? T // 関数はそのまま
  : T extends Date
  ? T // Date オブジェクトはそのまま
  : {
      // オブジェクトのプロパティを再帰的に readonly 化
      readonly [K in keyof T]: DeepReadonly<T[K], NextDepth<D>>;
    };

この型定義では、以下の制約を追加しています。

  • プリミティブ型、関数、Date は再帰の対象外
  • 配列は ReadonlyArray に変換
  • 深度 5 で再帰を停止
typescript// 使用例
interface AppConfig {
  api: {
    endpoint: string;
    timeout: number;
    headers: {
      authorization: string;
    };
  };
  createdAt: Date;
}

type ReadonlyAppConfig = DeepReadonly<AppConfig>;

const config: ReadonlyAppConfig = {
  api: {
    endpoint: 'https://api.example.com',
    timeout: 3000,
    headers: {
      authorization: 'Bearer token',
    },
  },
  createdAt: new Date(),
};

// すべてのプロパティが readonly
// config.api.endpoint = 'new-url'; // エラー
// config.api.headers.authorization = 'new-token'; // エラー

// Date オブジェクトは変更可能(意図した挙動)
config.createdAt.setFullYear(2026); // OK

✓ 動作確認済み(Node.js 22.x / TypeScript 5.7.x)

以下の図は、DeepReadonly の型変換フローを示しています。

mermaidflowchart LR
    input["入力型 T"]
    check_depth{"深度 5?"}
    check_primitive{"Primitive?"}
    check_array{"Array?"}
    check_function{"Function?"}
    check_date{"Date?"}

    readonly_obj["readonly<br/>オブジェクト"]
    readonly_array["ReadonlyArray"]
    as_is["そのまま"]

    input --> check_depth
    check_depth -->|Yes| as_is
    check_depth -->|No| check_primitive
    check_primitive -->|Yes| as_is
    check_primitive -->|No| check_array
    check_array -->|Yes| readonly_array
    check_array -->|No| check_function
    check_function -->|Yes| as_is
    check_function -->|No| check_date
    check_date -->|Yes| as_is
    check_date -->|No| readonly_obj

実践的な再帰型のパターンとユースケース

実際のプロジェクトで採用している、再帰型を活用した設計パターンをご紹介します。

パターン 1: ファイルシステム風のツリー構造

実務で最もよく使う再帰型のパターンは、ファイルとフォルダを表現するツリー構造です。

ファイルとフォルダの型定義

typescript/**
 * ファイルノードの型
 */
interface FileNode {
  type: 'file';
  name: string;
  size: number;
  extension: string;
}
typescript/**
 * フォルダノードの型(再帰型)
 */
interface FolderNode<D extends Depth = 0> {
  type: 'folder';
  name: string;
  children: D extends 5
    ? never
    : FileSystemNode<NextDepth<D>>[];
}
typescript/**
 * ファイルシステムノードの統合型
 */
type FileSystemNode<D extends Depth = 0> = FileNode | FolderNode<D>;

型ガード関数の実装

typescript/**
 * ノードがフォルダかどうか判定
 */
function isFolder<D extends Depth>(
  node: FileSystemNode<D>
): node is FolderNode<D> {
  return node.type === 'folder';
}
typescript/**
 * ノードがファイルかどうか判定
 */
function isFile(node: FileSystemNode): node is FileNode {
  return node.type === 'file';
}

再帰的な操作関数

typescript/**
 * ツリー内のすべてのファイルサイズを合計
 */
function calculateTotalSize(node: FileSystemNode): number {
  if (isFile(node)) {
    return node.size;
  }

  // フォルダの場合、子要素のサイズを再帰的に合計
  return node.children.reduce(
    (total, child) => total + calculateTotalSize(child),
    0
  );
}
typescript/**
 * 特定の拡張子のファイルを検索
 */
function findFilesByExtension(
  node: FileSystemNode,
  extension: string,
  results: FileNode[] = []
): FileNode[] {
  if (isFile(node)) {
    if (node.extension === extension) {
      results.push(node);
    }
  } else {
    // フォルダの場合、子要素を再帰的に検索
    for (const child of node.children) {
      findFilesByExtension(child, extension, results);
    }
  }

  return results;
}

使用例

typescriptconst fileSystem: FileSystemNode = {
  type: 'folder',
  name: 'root',
  children: [
    {
      type: 'file',
      name: 'index.ts',
      size: 1024,
      extension: '.ts',
    },
    {
      type: 'folder',
      name: 'components',
      children: [
        {
          type: 'file',
          name: 'Button.tsx',
          size: 2048,
          extension: '.tsx',
        },
        {
          type: 'file',
          name: 'Input.tsx',
          size: 1536,
          extension: '.tsx',
        },
      ],
    },
  ],
};

const totalSize = calculateTotalSize(fileSystem); // 4608
const tsxFiles = findFilesByExtension(fileSystem, '.tsx'); // 2件

✓ 動作確認済み(Node.js 22.x / TypeScript 5.7.x)

パターン 2: JSON Schema 風の型定義

API レスポンスの複雑な構造を表現する際に使用する、JSON Schema 風の再帰型パターンです。

JSON の値型を表現する再帰型

typescript/**
 * JSON の値を表す再帰型
 */
type JsonValue<D extends Depth = 0> =
  | string
  | number
  | boolean
  | null
  | (D extends 5 ? never : JsonArray<NextDepth<D>>)
  | (D extends 5 ? never : JsonObject<NextDepth<D>>);
typescript/**
 * JSON の配列型
 */
type JsonArray<D extends Depth = 0> = D extends 5
  ? never
  : JsonValue<D>[];
typescript/**
 * JSON のオブジェクト型
 */
type JsonObject<D extends Depth = 0> = D extends 5
  ? never
  : { [key: string]: JsonValue<D> };

型安全な JSON パーサー

typescript/**
 * JSON 文字列を型安全にパース
 */
function parseJson<T extends JsonValue>(json: string): T {
  const parsed = JSON.parse(json);
  return parsed as T;
}

使用例

typescript// API レスポンスの型定義
interface ApiResponse extends JsonObject {
  status: 'success' | 'error';
  data: {
    user: {
      id: number;
      name: string;
      metadata: JsonObject; // ネストした任意のデータ
    };
  };
}

const response = parseJson<ApiResponse>(`{
  "status": "success",
  "data": {
    "user": {
      "id": 1,
      "name": "Alice",
      "metadata": {
        "preferences": {
          "theme": "dark",
          "language": "ja"
        }
      }
    }
  }
}`);

console.log(response.data.user.name); // "Alice"

✓ 動作確認済み(Node.js 22.x / TypeScript 5.7.x)

パターン 3: コンポーネントツリーの型定義

React のコンポーネント階層を表現する再帰型パターンです。Next.js 15 の App Router と組み合わせて使用しています。

コンポーネントノードの型定義

typescript/**
 * コンポーネントのプロパティ型
 */
type ComponentProps = Record<string, unknown>;
typescript/**
 * コンポーネントノードの再帰型
 */
interface ComponentNode<D extends Depth = 0> {
  type: string;
  props: ComponentProps;
  children: D extends 5
    ? never
    : ComponentNode<NextDepth<D>>[];
}

仮想 DOM の構築関数

typescript/**
 * コンポーネントツリーを構築
 */
function createElement<D extends Depth = 0>(
  type: string,
  props: ComponentProps = {},
  ...children: ComponentNode<NextDepth<D>>[]
): ComponentNode<D> {
  return {
    type,
    props,
    children,
  } as ComponentNode<D>;
}

使用例

typescriptconst tree = createElement(
  'div',
  { className: 'container' },
  createElement(
    'header',
    { id: 'main-header' },
    createElement('h1', {},
      createElement('span', { className: 'title' })
    )
  ),
  createElement(
    'main',
    {},
    createElement('article', { id: 'content' })
  )
);

パターン 4: Utility Types としての再帰型活用

TypeScript の Utility Types を拡張した、実務で使える再帰型ユーティリティです。

DeepPartial - すべてのプロパティをオプショナルに

typescript/**
 * オブジェクトのすべてのプロパティを再帰的にオプショナルにする
 *
 * @template T - 対象の型
 * @template D - 現在の深さ(デフォルト: 0)
 */
type DeepPartial<T, D extends Depth = 0> = D extends 5
  ? T
  : T extends Primitive
  ? T
  : T extends Array<infer U>
  ? Array<DeepPartial<U, NextDepth<D>>>
  : T extends Function
  ? T
  : {
      [K in keyof T]?: DeepPartial<T[K], NextDepth<D>>;
    };

DeepRequired - すべてのプロパティを必須に

typescript/**
 * オブジェクトのすべてのプロパティを再帰的に必須にする
 *
 * @template T - 対象の型
 * @template D - 現在の深さ(デフォルト: 0)
 */
type DeepRequired<T, D extends Depth = 0> = D extends 5
  ? T
  : T extends Primitive
  ? T
  : T extends Array<infer U>
  ? Array<DeepRequired<U, NextDepth<D>>>
  : T extends Function
  ? T
  : {
      [K in keyof T]-?: DeepRequired<T[K], NextDepth<D>>;
    };

DeepPick - 特定のキーのみを再帰的に抽出

typescript/**
 * オブジェクトから特定のキーのみを再帰的に抽出
 *
 * @template T - 対象の型
 * @template K - 抽出するキー
 * @template D - 現在の深さ(デフォルト: 0)
 */
type DeepPick<T, K extends string, D extends Depth = 0> = D extends 5
  ? T
  : T extends Primitive
  ? T
  : T extends Array<infer U>
  ? Array<DeepPick<U, K, NextDepth<D>>>
  : {
      [P in keyof T as P extends K ? P : never]: DeepPick<
        T[P],
        K,
        NextDepth<D>
      >;
    };

使用例

typescriptinterface User {
  id: number;
  profile?: {
    name?: string;
    email?: string;
    settings?: {
      theme?: 'light' | 'dark';
      notifications?: boolean;
    };
  };
}

// すべてのプロパティを必須に
type RequiredUser = DeepRequired<User>;
/*
{
  id: number;
  profile: {
    name: string;
    email: string;
    settings: {
      theme: 'light' | 'dark';
      notifications: boolean;
    };
  };
}
*/

// すべてのプロパティをオプショナルに
type PartialUser = DeepPartial<RequiredUser>;
/*
{
  id?: number;
  profile?: {
    name?: string;
    email?: string;
    settings?: {
      theme?: 'light' | 'dark';
      notifications?: boolean;
    };
  };
}
*/

よくあるエラーと対処法

実際に開発中に遭遇した再帰型関連のエラーと、その解決方法をご紹介します。

エラー 1: Type instantiation is excessively deep and possibly infinite

再帰型を無制限に使用すると、TypeScript コンパイラが無限ループと判断してエラーを出します。

bashType instantiation is excessively deep and possibly infinite.

発生条件

深度制限を設けずに再帰型を定義した場合に発生します。

typescript// ❌ エラーが発生するコード
type DeepType<T> = {
  [K in keyof T]: T[K] extends object
    ? DeepType<T[K]> // 無制限に再帰
    : T[K];
};

// 深い階層のオブジェクトに適用
interface VeryDeep {
  level1: {
    level2: {
      level3: {
        // ... 50階層以上
      };
    };
  };
}

type Result = DeepType<VeryDeep>;
// エラー: Type instantiation is excessively deep and possibly infinite.

原因

TypeScript コンパイラは、再帰的な型展開に制限(約 50 階層)を設けています。この制限を超えると、無限ループの可能性があると判断してエラーになります。

解決方法: 深度制限を設ける

本記事で紹介した深度制限パターンを適用します。

typescript// ⭕ 解決策: 深度制限を設ける
type SafeDeepType<T, D extends Depth = 0> = D extends 5
  ? T // 5階層で停止
  : {
      [K in keyof T]: T[K] extends object
        ? SafeDeepType<T[K], NextDepth<D>>
        : T[K];
    };

// 正常に動作
type SafeResult = SafeDeepType<VeryDeep>;

解決後の確認

修正後、tsc --noEmit でコンパイルエラーが解消されることを確認しました。TypeScript 5.7.2 の環境で、5 階層までの再帰は問題なく型推論が機能します。

参考リンク

エラー 2: Object is possibly 'undefined' - オプショナルプロパティの扱い

再帰型でオプショナルプロパティを使用した際、型ガードなしでアクセスするとエラーになります。

bashObject is possibly 'undefined'.

発生条件

オプショナルプロパティに型ガードなしでアクセスした場合に発生します。

typescript// ❌ エラーが発生するコード
interface TreeNode {
  value: string;
  children?: TreeNode[];
}

function traverse(node: TreeNode): void {
  console.log(node.value);

  // children が undefined の可能性を考慮していない
  node.children.forEach((child) => traverse(child));
  // エラー: Object is possibly 'undefined'.
}

原因

children プロパティは TreeNode[] | undefined 型であり、undefined の可能性があります。そのため、直接 forEach を呼び出すことはできません。

解決方法 1: オプショナルチェーン演算子

typescript// ⭕ 解決策 1: オプショナルチェーン
function traverse(node: TreeNode): void {
  console.log(node.value);

  // children が存在する場合のみ forEach を実行
  node.children?.forEach((child) => traverse(child));
}

解決方法 2: 型ガード関数

typescript// ⭕ 解決策 2: 型ガード関数
function hasChildren(node: TreeNode): node is TreeNode & { children: TreeNode[] } {
  return node.children !== undefined && node.children.length > 0;
}

function traverse(node: TreeNode): void {
  console.log(node.value);

  if (hasChildren(node)) {
    // この中では children が必ず存在
    node.children.forEach((child) => traverse(child));
  }
}

解決後の確認

両方の解決策とも、型チェックが正常に動作し、実行時エラーも発生しないことを確認しました。

エラー 3: Circular reference - 循環参照の検出

型定義が相互に参照し合うと、循環参照エラーが発生します。

bashType alias 'A' circularly references itself.

発生条件

型が直接的または間接的に自分自身を参照している場合に発生します。

typescript// ❌ エラーが発生するコード
type NodeA = {
  value: string;
  nodeB: NodeB; // NodeB を参照
};

type NodeB = {
  value: number;
  nodeA: NodeA; // NodeA を参照(循環)
};
// エラー: Type alias 'NodeA' circularly references itself.

原因

型 A が型 B を参照し、型 B が型 A を参照するという循環参照が発生しています。TypeScript は型レベルでの無限ループを防ぐため、このような定義を禁止しています。

解決方法: インターフェースを使用

型エイリアスではなくインターフェースを使うと、循環参照が許可されます。

typescript// ⭕ 解決策: インターフェースを使用
interface NodeA {
  value: string;
  nodeB?: NodeB; // オプショナルにして循環を緩和
}

interface NodeB {
  value: number;
  nodeA?: NodeA;
}

// 使用例
const nodeA: NodeA = {
  value: 'A',
  nodeB: {
    value: 1,
  },
};

解決後の確認

インターフェースに変更後、コンパイルエラーが解消され、意図した型定義が実現できました。

参考リンク

エラー 4: Type 'X' is not assignable to type 'Y' - Mapped Types との組み合わせ

Mapped Types と再帰型を組み合わせた際、型の互換性エラーが発生することがあります。

bashType 'X' is not assignable to type 'Y'.

発生条件

Mapped Types で型を変換する際、再帰的な型が期待する型と一致しない場合に発生します。

typescript// ❌ エラーが発生するコード
type MakeOptional<T> = {
  [K in keyof T]?: T[K] extends object
    ? MakeOptional<T[K]>
    : T[K];
};

interface Config {
  database: {
    host: string;
    port: number;
  };
  items: string[]; // 配列
}

const config: MakeOptional<Config> = {
  database: {
    host: 'localhost',
    // port を省略
  },
  items: ['a', 'b'], // string[] は object extends object なので再帰される
};
// エラー: Type 'string' is not assignable to type 'MakeOptional<string>'.

原因

string[]object 型に該当するため、配列の要素に対しても MakeOptional が再帰的に適用されてしまいます。その結果、string 型が MakeOptional<string> 型と一致せずエラーになります。

解決方法: 配列の型判定を追加

typescript// ⭕ 解決策: 配列を明示的に処理
type MakeOptional<T, D extends Depth = 0> = D extends 5
  ? T
  : T extends Primitive
  ? T
  : T extends Array<infer U>
  ? Array<MakeOptional<U, NextDepth<D>>> // 配列は配列のまま
  : {
      [K in keyof T]?: MakeOptional<T[K], NextDepth<D>>;
    };

const config: MakeOptional<Config> = {
  database: {
    host: 'localhost',
  },
  items: ['a', 'b'], // 正常に動作
};

解決後の確認

修正後、配列を含むオブジェクトに対しても正しく型推論が機能することを確認しました。

まとめ

TypeScript の再帰型は、ツリー構造やネストしたデータを型安全に扱うための強力な機能ですが、無制限に使用すると型推論の破綻やパフォーマンス低下を招きます。実際のプロジェクト運用を通じて、以下の知見が得られました。

再帰型を使うべき場面

階層的なデータ構造 を扱う場合、再帰型は最適な選択です。ファイルシステム、コメントのネスト、組織図、メニュー構造など、「同じ形のデータが何階層も続く」パターンでは再帰型が威力を発揮します。

JSON や API レスポンスの型定義 では、深くネストした構造を型安全に扱えます。特に、外部 API との連携では、予期しない構造のデータを型レベルで検出できるため、バグの早期発見に役立ちます。

Utility Types の拡張 として、DeepReadonly や DeepPartial のような型ユーティリティを作成する場合も有効です。Mapped Types と組み合わせることで、柔軟な型操作が可能になります。

再帰型で必ず守るべき設計原則

実務で安全に再帰型を使うためには、以下の原則を守ることが重要です。

深度制限は必須 です。再帰深度を 5 階層程度に制限することで、TypeScript コンパイラのエラーを回避し、IDE のパフォーマンスも維持できます。実務では、5 階層を超えるデータ構造は稀であり、この制限で十分対応可能です。

オプショナルプロパティには型ガードを用意 しましょう。children?: TreeNode[] のようなオプショナルプロパティは、必ず型ガード関数を用意してから使用します。これにより、実行時エラーを防ぎ、コードの安全性が向上します。

Mapped Types との組み合わせでは型制約を厳密化 することが大切です。object 型の判定では、配列や関数、Date オブジェクトなども含まれてしまうため、Primitive 型や配列の判定を明示的に行います。

型名と JSDoc で意図を明確にする ことも重要です。再帰型は複雑になりがちなので、型名で目的を表現し、JSDoc コメントで使用例を示すことで、チームメンバーの理解を助けます。

型推論が効かなくなったときの対処法

再帰型で型推論が効かなくなった場合は、以下を確認してください。

  • 深度制限が適切に設定されているか
  • オプショナルプロパティに型ガードを使っているか
  • Mapped Types で object 型を正しく判定しているか
  • 型エイリアスではなくインターフェースを使うべきか

それでも解決しない場合は、型を段階的に分割することを検討しましょう。1 つの型定義で複雑な変換を行うのではなく、小さな型ユーティリティを組み合わせることで、型推論が機能しやすくなります。

再帰型は「適切に制限すれば安全」

再帰型は難しい機能ではありますが、適切な制約と設計パターンを守れば、実務で十分に活用できます。本記事で紹介した深度制限、型ガード、Mapped Types の厳密化といった手法を組み合わせることで、型安全性と保守性を両立した再帰型が実現できます。

ツリー構造やネストしたデータを扱う際は、ぜひ再帰型を活用してみてください。この記事で紹介した設計パターンとエラー対処法が、皆さんのプロジェクトで役立てば幸いです。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;