追記
最新の 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]
に推論してもらう必要があります。配列を使ってこのように推論させる方法がわからないので募集中です。
おわりに
普段のプログラミングで定義することはあまりなさそうですが、ライブラリ作る時とかは便利かもしれません。