42
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

TypeScript で再帰的な型を定義する

Last updated at Posted at 2019-01-23

追記

最新の TypeScript (2021/05/12 現在)では、この記事のテクニックをかなりかんたんに記述できるようになっています。

// tuple を自然に取り扱えるようになった(以前は関数の引数を経由する必要があった)
type Head<T> = T extends [infer U, ...unknown[]] ? U : never;
type Tail<T> = T extends [unknown, ...infer U] ? U : [];

// 再帰を自然に記述できるようになった
// readonly で as const 相当を強制できるようになった
type DeepPath<State, Paths extends readonly unknown[]> = Head<Paths> extends keyof State
  ? Tail<Paths> extends []
    ? State[Head<Paths>]
    : DeepPath<NonNullable<State[Head<Paths>]>, Tail<Paths>>
  : never;

はじめに

この記事では TypeScript で再帰的な型を定義する方法を紹介します。
TypeScript 推奨の方法かどうかは特に調べてないので将来的に壊れたりする可能性はあります。

サマリ

  • TypeScript では素直に再帰的な型を定義できない
  • その回避方法を調べていたら TypeScript 本家 issue にやり方が紹介されていた
  • 試しに get(obj, 'key', 'key') に型をつけてみたらうまくいった
  • その方法を簡略化すると下記のような感じだったので紹介する
type MyType<T> = {
  0: ...;
  1: MyType<...>;
}[T extends ... ? 1 : 0];

お題

あるネストしたオブジェクトの深い部分の型を取り出します。

type State = {
  key1: {
    key2: {
      key3: { name: string }[];
    };
  };
};

type Key3Value = DeepPath<State, [
  'key1',
  'key2',
  'key3',
  number
]>; // { name: string; };

実装

// Tuple の先頭を取り出す
type Head<U> = U extends [any, ...any[]]
  ? ((...args: U) => any) extends (head: infer H, ...args: any) => any
    ? H
    : never
  : never;

// Tuple の先頭以外を Tuple として取り出す
type Tail<U> = U extends [any, any, ...any[]]
  ? ((...args: U) => any) extends (head: any, ...args: infer T) => any
    ? T
    : never
  : never;

// State と Paths を与え、再帰的に型を取り出していく
type DeepPath<State, Paths extends any[]> = Head<Paths> extends keyof State
  ? {
      0: State[Head<Paths>];
      1: DeepPath<State[Head<Paths>], Tail<Paths>>;
    }[Tail<Paths> extends never ? 0 : 1]
  : never;

説明

一番重要なのは DeepPath の実装になりますが、なんだか妙な記述です。
素直に書くとこうなります。

type DeepPath<State, Paths extends any[]> = Tail<Paths> extends never
  ? State[Head<Paths>]
  : DeepPath<State[Head<Paths>], Tail<Paths>>;

でもこれは TypeScript 的には NG です。
Type alias 'DeepPath' circularly references itself. と言われます。

そのため、下記のように一度オブジェクトの型を定義した上で、即座にそのオブジェクトのインデックスを選択的に参照することでエラーを回避しています。

type DeepPath<State, Paths extends any[]> = Head<Paths> extends keyof State
  ? {
      0: State[Head<Paths>];
      1: DeepPath<State[Head<Paths>], Tail<Paths>>;
    }[Tail<Paths> extends never ? 0 : 1]
  : never;

やっていることは変わらないのですが書き方で変わるのが面白いです。

この方法は https://github.com/Microsoft/TypeScript/issues/14833 などでも紹介されているようです。

これを利用すると下記のような実装を行っても利用側で型を明示せずに推論されます。

export function get<State extends any, Paths extends (string | number)[]>(state: State, ...paths: Paths): DeepPath<State, Paths> {
  const [head, ...tail] = paths;

  if (tail.length) {
    return get(state[head], ...tail);
  }

  return state[head];
}

const hrsh7th = get({
  key1: {
    key2: {
      key3: [{ name: 'hrsh7th' }]
    }
  }
}, 'key1', 'key2', 'key3', 0);

console.log(hrsh7th.name); // 推論されているので、エディタなどで補完が効く

get(state, ['key1', 'key2', 'key3', 0]); なシグネチャにするのは paths が (string | number)[] に推論されてしまって難しかったです。
※ paths はあくまでも ['key1', 'key2', 'key3', 0] に推論してもらう必要があります。配列を使ってこのように推論させる方法がわからないので募集中です。

おわりに

普段のプログラミングで定義することはあまりなさそうですが、ライブラリ作る時とかは便利かもしれません。

42
21
2

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
42
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?