TypeScriptで可変長引数から型をいい感じに推論する
Photo by Nick Fewings on Unsplash
突然ですが、以下のJavaScriptプログラムに型をつけるとしたらどうするでしょうか。
function all(...callbacks) {
return (x) => {
for (const callback of callbacks) {
callback(x);
}
}
}
最も自然な方法のひとつは、次のような型づけでしょう。
type Callback<T> = (value: T) => void;
function all<T>(...callbacks: Callback<T>[]): Callback<T>;
これ自体は最小限で最も正しい型をつけていて良いのですが、ユースケースによっては不便な場合があります。具体的には、以下のような「異なる型の汎用的なコールバックを組み合わせて特殊なコールバックを作る」という使い方です。
function cb1(value: { foo: number }) {}
function cb2(value: { bar: string }) {}
const fusedCallback = all(cb1, cb2);
// ^^^ ここで型エラーになる
執筆時点での最新バージョン (TypeScript 4.1) では、この意図にそった型推論にはならず、第一引数の型にあわせて推論されてしまいます。もちろん次のように型アノテーションをつければ正しく動作します。
// Callback<{ foo: number, bar: string }> 型
const fusedCallback = all<{ foo: number, bar: string }>(cb1, cb2);
しかし、コールバックの型も自動的に合成されれば用途によってはより便利です。
最終的な解決
最終的に辿りついたのが以下のような方法です。
function all<
T extends Callback<any>[]
>(...callbacks: T): Callback<T extends Callback<infer U>[] ? U : never>;
こうすると、次のように利用側には期待した型がつきます。
// Callback<{ foo: number } & { bar: string }> 型がつく
const fusedCallback = all(cb1, cb2);
実装側は内部で any
が推論されるので、厳密にチェックされている状態ではなくなりますが、それでも大まかには正しいことは確認されています。
さらに、 &
演算子を展開するためにFlattenパターンを用いて以下のようにすることもできます。
function all<
T extends Callback<any>[]
>(...callbacks: T)
: Callback<T extends Callback<infer U>[] ? { [K in keyof U]: U[K] } : never>;
結果はこうなります。
// Callback<{ foo: number, bar: string }> 型がつく
const fusedCallback = all(cb1, cb2);
採用しなかった方法
はじめに考えたのは以下のような型付けでした。
type CallbackParams<T extends Callback<any>[]> = {
[K in keyof T]: T[K] extends Callback<infer U> ? U : never
};
function all<
T extends Callback<any>[]
>(...callbacks: T): Callback<CallbackParams<T>[number]>;
CallbackParams
はタプル型の Callback<T>
から T
を取り出す処理です。タプル型に対してMapped Typesを使うとタプル型が返ってくるという仕様を使っています。そうして得られたタプル型にIndexed Access Typesを適用しました。
しかし、Indexed Access Typesをこのように使った場合は合併型、つまり { foo: number } | { bar: string }
のような型になってしまいます。今回必要なのは交叉型、つまり &
のほうなので、ここでは不適当です。
そこでひと工夫加えて、次のように IntersectionOf
を導入しました。
type CallbackParams<T extends Callback<any>[]> = {
[K in keyof T]: T[K] extends Callback<infer U> ? U : never
};
type IntersectionOf<T extends any[]> =
T extends [infer Head, ...infer Tail] ? Head & IntersectionOf<Tail> : unknown;
function all<
T extends Callback<any>[]
>(...callbacks: T): Callback<IntersectionOf<CallbackParams<T>>>;
これである程度意図した通りの挙動になりました。
ただ、これは固定長タプル以外のものを引数に入れたときに正しくない挙動をすること、再帰conditional typeのためにTypeScript 4.1以降でないと動かないことが問題でした。最初に述べたような解決策が見つかったことで無事に没になりました。
また、最初に述べた解決策の亜種として以下のような書き方もあります。
function all<
T extends Callback<any>[]
>(...callbacks: T): T extends Callback<infer U>[] ? Callback<U> : unknown;
Callback<>
がconditional typeの中に移動しているだけの違いです。これでもインターフェースとしてはほとんど問題ありませんが、実装側の型チェックが通らなくなるので、 as any
などで型チェックをスキップする必要が出てきます。
実際の用途
状況をわかりやすくするためにコールバックを例にして説明しましたが、実際の用途は styled-components のCSSインターポレーションの合成でした。
styled-componentsではCSSをJavaScriptのコード内に記述し、動的に生成した <style>
タグに埋め込みます。これにより、JavaScript上の値でパラメーター化された再利用可能なスタイルを作ることもできます。
const buttonLikeStyle = css<{ selected: boolean }>`
background-color: ${(p) => p.selected ? "white" : "blue" };
`;
Wantedlyでは現在、デザインシステム上で定義されている再利用可能なスタイルをこのような形でJavaScript側にマッピングしています。それらのスタイルが持つパラメーターはスタイルの利用目的によって様々です。そして、複数のスタイルを並べた場合は、その &
を取ったものが必要な引数になります。この問題設定を簡略化したのが最初に挙げたコールバックの例でした。
まとめ
異なる型の可変長引数からうまく型推論をするには、可変長引数自体はタプル型で受け取りつつ、その型を配列型のサブタイプとみなして infer
するのがよさそうです。