1. ホーム
  2. TypeScript

【TypeScript】再帰的な型(Recursive Types)の使い方|JSON・ツリー構造にネストした型を付ける

Share

API から返ってくる JSON、カテゴリの入れ子、サイドバーの多階層メニュー。こうした「同じ形が中に何度も現れるデータ」に型を付けようとすると、普通の型定義では書ききれません。3階層かもしれないし10階層かもしれないデータを表すには、自分自身を参照する型(Recursive Types/再帰的な型)が必要になります。この記事では、interfacetype での書き方の違いから、JSON・ツリー・メニューといった実例、DeepPartial<T> のような再帰的なジェネリック型の作り方、そして再帰特有のエラーである TS2456TS2589 の読み解き方までを順に見ていきます。

自分自身を参照する型とは

再帰的な型とは、その名のとおり型の定義の中で自分自身の名前を使っている型のことです。もっとも分かりやすいのは連結リストで、「値と、次のノード(または null)を持つ」という定義がそのまま型になります。

linked-list.ts
interface LinkedList {
  value: number;
  next: LinkedList | null;   // ← 自分自身を参照している
}

const list: LinkedList = {
  value: 1,
  next: { value: 2, next: { value: 3, next: null } },
};

ここで大事なのは、再帰していても無限ループにはならないという点です。next の型を知るには LinkedList を展開しなければなりませんが、TypeScript はプロパティの型を必要になったときにはじめて解決する(遅延評価する)ため、定義の時点で無限に展開してしまうことはありません。実際のデータのほうも next: null でいつでも終わらせられるので、有限の値をきちんと表現できます。

逆に言えば、再帰を止める出口が型に用意されていないと、その型を満たす値は作れませんnext: LinkedList と書いて | null を外すと、値を書こうとしても永遠に next を要求されます。再帰的な型を設計するときは「どこで終わるのか」を必ず型に含めてください。| null?(オプショナル)、あるいはユニオンの片側に非再帰のケースを置く、といった形が定番です。

interface と type alias で書けるかどうかが変わる

interface はどこでも自分を参照できる

interface は必ずオブジェクト型なので、メンバーの型はすべて遅延評価されます。そのため自分自身の参照を気にせず書けます。再帰的なデータ構造を素直に表したいだけなら、interface を選んでおけばまず困りません。

type alias は「遅延評価される位置」でだけ許される

type(型エイリアス)は右辺に何でも書けるぶん、制約があります。TypeScript は型エイリアスの右辺を基本的にその場で展開しようとするため、展開を遅らせられる位置にしか自分自身を書けません。具体的には、オブジェクト型のプロパティ、配列の要素、タプルの要素、関数の引数と戻り値です。これらは「必要になったときに中身を見る」場所なので、再帰が成立します。

一方、ユニオン型やインターセクション型に自分自身を直接並べることはできません。type A = A | string は「A が何かを知るには A を知る必要がある」という循環そのもので、後述する TS2456 になります。

alias-recursion.ts
type A1 = A1[];                  // OK(配列の要素)
type A2 = { next: A2 };          // OK(オブジェクトのプロパティ)
type A3 = [string, A3];          // OK(タプルの要素)
type A4 = () => A4;              // OK(関数の戻り値)
type A7 = string | A7[];         // OK(配列に包まれていれば OK)

// error TS2456: Type alias 'A5' circularly references itself.
type A5 = A5 | string;

// error TS2456: Type alias 'A6' circularly references itself.
type A6 = A6 & { a: string };
書き方可否理由
type A = { next: A }OKプロパティの型は遅延評価される
type A = A[]OK配列の要素型は遅延評価される
type A = [string, A]OKタプルの要素型は遅延評価される
type A = () => AOK関数の引数・戻り値は遅延評価される
type A = string | A[]OK再帰部分が配列に包まれている
type A = A | stringエラーユニオンの構成要素は即座に展開される
type A = A & { a: string }エラーインターセクションも即座に展開される

なお、再帰的な型エイリアスがこれだけ自由に書けるようになったのは TypeScript 3.7 からです。それ以前は配列やタプルの要素での再帰が許されず、interface で包むといった回避策が必要でした。古い記事で「型エイリアスでは再帰できない」と書かれているのはこの時代の話です。

JSON の値を型で表す

再帰的な型エイリアスの代表例が JSON です。JSON の値は「文字列・数値・真偽値・null のいずれか、または JSON の値の配列、または値が JSON の値であるオブジェクト」と定義できます。この日本語をそのまま型に書き写すと次のようになります。

json.ts
type JsonPrimitive = string | number | boolean | null;

type JsonValue =
  | JsonPrimitive
  | JsonValue[]                      // 配列に包まれているので OK
  | { [key: string]: JsonValue };    // プロパティなので OK

const data: JsonValue = {
  name: 'webool',
  tags: ['typescript', 'react'],
  meta: { published: true, views: 1200, author: null },
};

ユニオンの中に JsonValue という名前が出てきますが、どちらも配列とオブジェクトに包まれているのでエラーになりません。ここが「遅延評価される位置でのみ許される」というルールの実践例です。もし | JsonValue と裸で書いてしまえば、その瞬間に TS2456 になります。

この型の嬉しいところは、値を処理する関数を書いたときにコンパイラが分岐の漏れを教えてくれることです。typeofArray.isArray() で絞り込みながら再帰的に走査すると、各分岐で value の型がきちんと狭まります。

count-strings.ts
// JSON の中に文字列がいくつ含まれているかを数える
function countStrings(value: JsonValue): number {
  if (typeof value === 'string') return 1;

  if (Array.isArray(value)) {
    // ここでは value: JsonValue[]
    let count = 0;
    for (const item of value) count += countStrings(item);
    return count;
  }

  if (value !== null && typeof value === 'object') {
    // ここでは value: { [key: string]: JsonValue }
    let count = 0;
    for (const item of Object.values(value)) count += countStrings(item);
    return count;
  }

  // 残るのは number | boolean | null
  return 0;
}

console.log(countStrings(data));   // 3('webool' / 'typescript' / 'react')

絞り込みの順番には注意してください。typeof null'object' なので、value !== null の確認を先に置かないとオブジェクトの分岐に null が紛れ込みます。また Array.isArray()typeof value === 'object' より先に書かないと、配列がオブジェクトの分岐に吸い込まれてしまいます。

JSON.parse() の戻り値は any なので、パースした直後にこの型を付けておくと以降の処理が安全になります。const parsed: JsonValue = JSON.parse(text); と書けば、parsed.foo.bar のような無防備なアクセスはコンパイルエラーになり、上のように分岐して確かめるコードを書かざるを得なくなります。

ツリー構造に型を付けて再帰関数で歩く

ファイルツリー、コメントのスレッド、組織図。子を持つかもしれないノードの集まりは children?: TreeNode[] の一行で表せます。? を付けておけば葉ノードでは children を書かずに済み、これが再帰の出口になります。

tree.ts
interface TreeNode {
  id: string;
  label: string;
  children?: TreeNode[];
}

const tree: TreeNode = {
  id: 'root',
  label: 'ルート',
  children: [
    { id: 'a', label: 'A', children: [{ id: 'a-1', label: 'A-1' }] },
    { id: 'b', label: 'B' },   // children を省略=葉ノード
  ],
};

型が再帰的なら、それを扱う関数も自然と再帰になります。よく使うのは「探す」「平坦にする」「深さを測る」の3つで、いずれも自分の処理をしてから子に同じ関数を適用するという同じ形をしています。

tree-walk.ts
// id を指定してノードを探す
function findNode(node: TreeNode, id: string): TreeNode | undefined {
  if (node.id === id) return node;

  // children が undefined なら空配列として扱う
  for (const child of node.children ?? []) {
    const found = findNode(child, id);
    if (found) return found;
  }
  return undefined;
}

// ツリーを1次元の配列に平坦化する
function flatten(node: TreeNode): TreeNode[] {
  return [node, ...(node.children ?? []).flatMap(flatten)];
}

// もっとも深い階層の数を返す
function maxDepth(node: TreeNode): number {
  const children = node.children ?? [];
  if (children.length === 0) return 1;   // 葉ノードで止まる
  return 1 + Math.max(...children.map(maxDepth));
}

console.log(findNode(tree, 'a-1')?.label);   // 'A-1'
console.log(flatten(tree).length);           // 4
console.log(maxDepth(tree));                 // 3

再帰関数を書くときは戻り値の型を必ず自分で書くことをおすすめします。型推論は再帰呼び出しの結果を推論しようとして自分自身を参照してしまい、「'findNode' implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.」(TS7023)というエラーになることがあります。明示的に : TreeNode | undefined と書けば、コンパイラは推論せずにその型を信じて検査してくれます。

ジェネリックにして値の型を差し替える

ノードが持つデータの型を呼び出し側で決めたいなら、型引数を付けたまま自分を参照します。GenericTree<T> の中で GenericTree<T>[] と書けるのが、再帰的なジェネリック型のもっとも単純な形です。

generic-tree.ts
interface GenericTree<T> {
  value: T;
  children: GenericTree<T>[];
}

const numbers: GenericTree<number> = {
  value: 1,
  children: [{ value: 2, children: [] }],
};

// 値だけを取り出す(型引数はそのまま引き継がれる)
function values<T>(node: GenericTree<T>): T[] {
  return [node.value, ...node.children.flatMap(values)];
}

console.log(values(numbers));   // [ 1, 2 ]

ネストしたメニューやカテゴリを型付けする

Web サイトのグローバルメニューやブログのカテゴリも、階層の深さが決まっていないツリーです。ここでは「リンク先を持つ項目」と「子をまとめるだけの項目」が混在するので、hrefchildren もオプショナルにしておきます。

menu.ts
type MenuItem = {
  label: string;
  href?: string;
  children?: MenuItem[];
};

const menu: MenuItem[] = [
  { label: 'ホーム', href: '/' },
  {
    label: 'ブログ',
    children: [
      { label: 'TypeScript', href: '/blog/typescript' },
      {
        label: 'CSS',
        href: '/blog/css',
        children: [{ label: 'Grid', href: '/blog/css/grid' }],
      },
    ],
  },
];

この型に対して、入れ子の ul を組み立てる関数と、現在のページまでのパンくずを求める関数を書いてみます。どちらも children があれば自分を呼び直すだけで、階層が何段あっても同じコードで動きます。

menu-render.ts
// 入れ子の ul を組み立てる
function renderMenu(items: MenuItem[]): string {
  const li = items.map((item) => {
    const link = item.href
      ? `<a href="${item.href}">${item.label}</a>`
      : `<span>${item.label}</span>`;
    const child = item.children ? renderMenu(item.children) : '';
    return `<li>${link}${child}</li>`;
  });
  return `<ul>${li.join('')}</ul>`;
}

// href に一致する項目までの経路(パンくず)を返す
function findPath(
  items: MenuItem[],
  href: string,
  trail: MenuItem[] = [],
): MenuItem[] | undefined {
  for (const item of items) {
    const next = [...trail, item];
    if (item.href === href) return next;

    const found = item.children && findPath(item.children, href, next);
    if (found) return found;
  }
  return undefined;
}

const path = findPath(menu, '/blog/css/grid');
console.log(path?.map((item) => item.label).join(' > '));
// ブログ > CSS > Grid

実務でこの手のデータを扱うときに怖いのは、children の先が巡り巡って自分に戻ってくる循環参照です。型はそれを禁止できません(MenuItem は自分を含んでよい型なので、循環したデータも型としては正しく見えます)。CMS や DB から組み立てたツリーを再帰的に歩くコードでは、訪問済みの idSet に入れて二度目は打ち切る、といったガードを実行時に入れておくと安全です。

再帰的なジェネリック型(DeepPartial / DeepReadonly)を書く

ここからは「データ構造の型」ではなく「型を変換する型」の再帰です。標準の Partial<T> はいちばん外側のプロパティにしか ? を付けないため、入れ子になったオブジェクトの中身は必須のまま残ります。すべての階層を省略可能にする DeepPartial<T> を作るには、マップ型の中で自分自身を呼び出します。

素朴なマップ型では配列と関数が壊れる

まず思いつくのは、マップ型の値の部分をそのまま再帰させる書き方です。オブジェクトだけを相手にしているうちは動きますが、値が配列や関数になった途端に期待と違う型になります。

naive-deep-partial.ts
type NaiveDeepPartial<T> = { [K in keyof T]?: NaiveDeepPartial<T[K]> };

type Settings = {
  plugins: { name: string }[];
  onSave: (value: string) => void;
};

type P = NaiveDeepPartial<Settings>;
declare const p: P;

// 配列は残るが、要素まで「あるかもしれない」ものになる
const first = p.plugins![0];
// error TS2322: Type 'NaiveDeepPartial<{ name: string; }> | undefined' is not
// assignable to type '{ name?: string | undefined; }'.
const n: { name?: string } = first;

// 関数は呼び出しシグネチャを失って呼べなくなる
// error TS2349: This expression is not callable.
//   Type 'NaiveDeepPartial<(value: string) => void>' has no call signatures.
p.onSave?.('x');

配列に対してマップ型を適用すると要素型がマップされた配列になるため、? が要素にまで付いて 要素 | undefined の配列になってしまいます。関数のほうはさらに深刻で、マップ型はプロパティだけを写し取り、呼び出しシグネチャを捨てるため、結果は呼び出せないオブジェクト型になります。DateMap のような組み込みオブジェクトも、同じ理由で内部のメソッドがバラバラに分解されてしまいます。

Conditional Types で場合分けする

解決策は、マップ型に入る前に extends ? :(Conditional Types)で型の種類を判定し、プリミティブと関数はそのまま返し、配列は要素型だけを再帰させることです。判定の順番が結果を左右するので、狭い条件から順に並べます。

deep-partial.ts
type Primitive = string | number | boolean | bigint | symbol | null | undefined;

type DeepPartial<T> =
  T extends Primitive ? T :                                   // 1. そのまま返す
  T extends (...args: any[]) => any ? T :                     // 2. 関数もそのまま
  T extends readonly (infer U)[] ? DeepPartial<U>[] :         // 3. 要素型だけ再帰
  T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : // 4. ここで ? を付ける
  T;

type Settings = {
  theme: { color: string; fontSize: number };
  plugins: { name: string; options: { enabled: boolean } }[];
  onSave: (value: string) => void;
};

// 何階層目でも自由に省略できる
const patch: DeepPartial<Settings> = {
  theme: { fontSize: 14 },        // color を省略できる
  plugins: [{ options: {} }],     // name も enabled も省略できる
};

判定の順番には理由があります。関数もオブジェクトの一種なので T extends object を先に書くと関数がマップ型に流れ込んでしまいますし、配列も同じくオブジェクトなので object より前に置く必要があります。readonly (infer U)[]readonly を付けて受けているのは、readonly string[] のような読み取り専用配列も同じ枝で拾うためです。

同じ骨組みで readonly を全階層に付ける DeepReadonly<T> も書けます。違いはマップ型に readonly 修飾子を付ける点と、配列を readonly 配列にする点だけです。

deep-readonly.ts
type DeepReadonly<T> =
  T extends Primitive ? T :
  T extends (...args: any[]) => any ? T :
  T extends readonly (infer U)[] ? readonly DeepReadonly<U>[] :
  T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
  T;

const frozen: DeepReadonly<Settings> = {
  theme: { color: '#fff', fontSize: 14 },
  plugins: [{ name: 'a', options: { enabled: true } }],
  onSave: () => {},
};

// error TS2540: Cannot assign to 'color' because it is a read-only property.
frozen.theme.color = '#000';

// error TS2339: Property 'push' does not exist on type 'readonly { readonly name:
// string; readonly options: { readonly enabled: boolean; }; }[]'.
frozen.plugins.push({ name: 'b', options: { enabled: false } });

この readonly はあくまで型の上の話で、実行時に値が凍結されるわけではありません。本当に書き換えを防ぎたいなら Object.freeze() を併用してください。型のほうは「うっかり書き換えるコードをレビュー前に見つける」ための仕組みだと考えるとよいでしょう。

Type alias ‘X’ circularly references itself. と言われたら

TS2456 は、型エイリアスが遅延評価されない位置で自分を参照しているときに出ます。メッセージだけ見ると「再帰そのものが禁止されている」ように読めますが、禁止されているのは「その場で展開しなければならない位置での再帰」だけです。

ユニオンに直接並べているケース

いちばん多いのが、式や AST のようなものをユニオンで表そうとした場合です。次の書き方はエラーになりますが、各ケースをオブジェクト型にして、再帰をプロパティの中に移すだけで通ります。

ts2456.ts
// error TS2456: Type alias 'Expr' circularly references itself.
type Expr = Expr | number;

// OK: 再帰がプロパティの中に入っている
type Expr2 =
  | { kind: 'num'; value: number }
  | { kind: 'add'; left: Expr2; right: Expr2 };

const e: Expr2 = {
  kind: 'add',
  left: { kind: 'num', value: 1 },
  right: { kind: 'num', value: 2 },
};

// 判別可能なユニオンなので、再帰関数も安全に書ける
function evaluate(expr: Expr2): number {
  switch (expr.kind) {
    case 'num':
      return expr.value;
    case 'add':
      return evaluate(expr.left) + evaluate(expr.right);
  }
}

console.log(evaluate(e));   // 3

回避の考え方は共通していて、自分自身の参照を配列・タプル・オブジェクト・関数のいずれかで包むことです。type A = A | string なら type A = A[] | stringtype A = A & { a: string } なら interface A にして extends ではなくプロパティで持たせる、といった具合に書き換えます。オブジェクト型で済む用途なら、最初から interface にしてしまうのが最短です。

interface で継承がループしているケース

interface でもエラーになる書き方があります。プロパティの型としての自己参照は問題ありませんが、extends で自分(あるいは自分を継承している型)を継承すると、別のエラー TS2310 になります。

ts2310.ts
// error TS2310: Type 'Loop' recursively references itself as a base type.
interface Loop extends Loop {
  a: string;
}

// これは問題ない(プロパティとしての自己参照)
interface Node2 {
  a: string;
  parent: Node2 | null;
}

継承関係は型を作る前に解決する必要があるので、こちらは本当の意味での循環です。実務では A extends BB extends CC extends A のように複数ファイルにまたがって輪になっているケースが多いので、エラーが出たら継承の連鎖をたどって輪を切ってください。

Type instantiation is excessively deep and possibly infinite. が出るとき

TS2589 は、型の展開が深くなりすぎたときにコンパイラが処理を打ち切って出すエラーです。無限に展開し続けてエディタが固まるのを防ぐための安全装置で、再帰的な Conditional Types を使い始めると出会いやすくなります。

ts2589.ts
// 長さ N のタプルを作る(再帰で1要素ずつ足していく)
type BuildTuple<N extends number, R extends unknown[] = []> =
  R['length'] extends N ? R : BuildTuple<N, [...R, unknown]>;

type T500 = BuildTuple<500>;    // OK

// error TS2589: Type instantiation is excessively deep and possibly infinite.
type T2000 = BuildTuple<2000>;

上のように条件型の分岐の結果がそのまま自分自身の呼び出しになっている形(末尾再帰)は、TypeScript 4.5 以降で最適化されていて、およそ1000回まで再帰できます。一方、[...Reverse<Rest>, Head] のように再帰の結果をさらに別の型に埋め込む形は最適化が効かず、数十段でこのエラーに達します。同じ「再帰する型」でも耐えられる深さがまるで違う、というのは知っておくと切り分けに役立ちます。

深さのカウンタを持たせて打ち切る

実務でこのエラーに当たるのは、DeepPartial<T> のような変換型を再帰的なデータ構造に適用したときがほとんどです。type Node2 = { value: string; child: Node2 } のような型に無制限の変換を掛けると、コンパイラが展開を終えられなくなります。対策は、型引数に「あと何段まで潜るか」のカウンタを持たせることです。

deep-partial-limited.ts
type Primitive = string | number | boolean | bigint | symbol | null | undefined;

// Prev[3] は 2、Prev[1] は 0、Prev[0] は never(=打ち切り)
type Prev = [never, 0, 1, 2, 3, 4, 5];

type DeepPartial<T, D extends number = 5> =
  [D] extends [never] ? T :                                        // 深さ切れ
  T extends Primitive ? T :
  T extends (...args: any[]) => any ? T :
  T extends readonly (infer U)[] ? DeepPartial<U, Prev[D]>[] :
  T extends object ? { [K in keyof T]?: DeepPartial<T[K], Prev[D]> } :
  T;

type Node2 = { value: string; child: Node2 };

// 5階層までは省略可能、その先は元の型のまま
const patch: DeepPartial<Node2> = { value: 'a', child: { child: { child: {} } } };

[D] extends [never] と角括弧で包んでいるのは、条件型が never に対して分配されるのを防ぐためです。裸で D extends never と書くと、Dnever のとき条件全体が never になってしまい、意図した打ち切りになりません。この「タプルで包んで分配を止める」書き方は、Conditional Types 全般で使う定番のテクニックです。

もう一つの現実的な対処は、そもそも型で頑張らないことです。TS2589 が出るような型は、たとえ通ったとしてもエディタの補完が目に見えて遅くなります。深い階層を型で表現するより、変換を通した結果を interface として素直に書き下したほうが、コンパイル時間も可読性も改善することは珍しくありません。

文字列を分解する再帰的な Conditional Types

再帰はデータ構造だけのものではありません。Template Literal Types と組み合わせると、'theme.color.primary' のような文字列を型のレベルで分解できます。infer で先頭と残りを取り出し、残りに同じ型を適用する、というのが基本形です。

split.ts
type Split<S extends string, D extends string> =
  S extends `${infer Head}${D}${infer Tail}`
    ? [Head, ...Split<Tail, D>]   // 区切り文字が見つかったら残りを再帰
    : [S];                        // 見つからなければ終わり

// ['theme', 'color', 'primary']
type Parts = Split<'theme.color.primary', '.'>;

const parts: Parts = ['theme', 'color', 'primary'];

これを応用すると、ドット区切りのパス文字列からその位置にある値の型を取り出す型が書けます。設定オブジェクトから値を読み出すヘルパー関数などで、キーの打ち間違いをコンパイル時に弾けるようになります。

get-path.ts
type Get<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T ? Get<T[Key], Rest> : never   // 途中を掘り進む
    : P extends keyof T ? T[P] : never;                 // 最後のキー

type Config = {
  theme: { color: { primary: string; secondary: string }; fontSize: number };
  debug: boolean;
};

type C1 = Get<Config, 'theme.color.primary'>;   // string
type C2 = Get<Config, 'theme.fontSize'>;        // number
type C3 = Get<Config, 'debug'>;                 // boolean
type C4 = Get<Config, 'theme.nope'>;            // never(存在しないパス)

存在しないパスを never に落としているので、Get<Config, 'theme.nope'> を戻り値の型に使う関数は結果を何にも代入できなくなり、そこで間違いに気づけます。ただし前節のとおり、この手の型はネストが深くなるほどコンパイラの負荷が上がります。ライブラリの公開 API のように「型の心地よさ」が価値になる場面では有効ですが、アプリケーションコードの内部で多用するのはほどほどにしておくのが無難です。

まとめ

再帰的な型は、定義の中で自分自身の名前を使う型のことで、階層の深さが決まっていないデータを表すために使います。interface ならプロパティの型として自由に自分を参照できますが、型エイリアスは配列・タプル・オブジェクトのプロパティ・関数の引数と戻り値という「遅延評価される位置」でしか自分を参照できません。ユニオンやインターセクションに直接並べると TS2456 になるので、そのときは再帰部分を配列やオブジェクトで包むか、interface に置き換えます。実用面では JSON の値を表す JsonValuechildren?: TreeNode[] を持つツリー、多階層メニューが定番で、いずれも型が再帰的なら処理する関数も再帰になります(戻り値の型は明示するのを忘れずに)。型変換の側では、マップ型と Conditional Types を組み合わせて DeepPartial<T>DeepReadonly<T> が書けますが、配列は要素型だけを再帰させ、関数はそのまま返すという場合分けが必要です。最後に、展開が深くなりすぎると TS2589 が出ます。末尾再帰の形ならおよそ1000段まで耐えられますが、そうでなければ数十段で頭打ちになるので、深さのカウンタで打ち切るか、型で頑張らずに書き下すかを検討してください。

参考ページ