[TypeScript] 分配の応用例: 関数とその引数の組み合わせのチェック
やりたいこと
以下のような 関数(fn)
と その関数の引数(arg)
の組 Call
があるとき、入力に対応した Call
を返す関数の型付けを考える。
type Call<T extends (...arg: any) => any> = {
fn: T
arg: Parameters<T>
}
具体例として、 input
に num
が含まれる場合は ( fn_n
と input.num
) を、それ以外の場合は ( fn_s
と input.str
) を返す関数( createCall
)は以下のようになる。
const fn_n = (n: number) => n + 1
const fn_s = (s: string) => s + "x"
const createCall = (input: Record<string, string>) => {
if ("num" in input) {
// args が number[] と推論されると面倒な場面があるので `[n: number]` にする
return { fn: fn_n, arg: [ parseInt(input.num) ] as [n: number] }
}
return { fn: fn_s, arg: [ input.str ] as [s: string] }
}
ここで、この createCall
の型は返す関数にのみ依存するため、 CreateCall<typeof fn_n | typeof fn_s>
のように返す関数のユニオンを型引数で渡せるようにするにはどうするか、というのがテーマになる。
期待する型
まず期待する型をマニュアルで型定義する
type Expected = (input: Record<string, string>) =>
| Call<typeof fn_n>
| Call<typeof fn_s>
// OK
const expected: Expected = createCall
また組み合わせが正しくない場合に型エラーが出ることの確認用に以下の関数も用意する。
const invalid = (input: Record<string, string>) => {
if ("num" in input) {
return { fn: (n: number) => n + 1, arg: [ parseInt(input.num) ] as [n: number] }
}
// fn の引数(string) と arg の型(number) の組み合わせが不一致
return { fn: (s: string) => s + "x", arg: [ 0 ] as [n: number] }
}
// 型エラー
const _invalid: Expected = invalid
つまり
const t1: CreateCall<typeof fn_n | typeof fn_s> = expected; // OK
const t2: CreateCall<typeof fn_n | typeof fn_s> = invalid; // Error
になる CreateCall
とは?という問題になる。正解となる型定義は長くなく、おそらく type-challenges でも中級の最初の方くらいの難易度(?) なのでお時間のある方は考えてみていただいてもよいかもしれない。 [1]
誤答1
type CreateCall1<T extends (...args: any => any)> = (
input: Record<string, string>
) => Call<T>
単純にこれでいけるだろ、と思ったが実は正しくない。
const t1_1: CreateCall1<typeof fn_n | typeof fn_s> = expected; // OK
const t1_2: CreateCall1<typeof fn_n | typeof fn_s> = invalid; // OK になってしまう
Conditional Types でない場合はユニオンが分配されないため Call
には typeof fn_n | typeof fn_s
がそのまま渡され、最終的に arg: [n: number] | [s: string]
となり、組み合わせのチェックができない。
Call<typeof fn_n | typeof fn_s>
>>> Call<(n: number) => number | (s: string) => string>
>>> {
fn: (n: number) => number | (s: string) => string,
arg: Parameters<(n: number) => number | (s: string) => string>,
}
>>> {
fn: (n: number) => number | (s: string) => string,
arg: [n: number] | [s: string],
}
誤答2
type CreateCall2<T extends (...args: any => any)> = T extends unknown
? (input: Record<string, string>) => Call<T>
: never;
じゃあ Conditional Types にすればいけるだろ、と思ったがこれも正しくない。 [2]
const t2_1: CreateCall2<typeof fn_n | typeof fn_s> = expected; // Error
const t2_2: CreateCall2<typeof fn_n | typeof fn_s> = invalid; // Error
冷静に考えると分かるが、ユニオンが分配されるものの
() => Call<typeof fn_n> | Call<typeof fn_s>
でなく
() => Call<typeof fn_n> | () => Call<typeof fn_s>
になってしまい、これではどちらか一方の関数しか返せない。
// (補足) これなら型チェックは通る
const t2_3: CreateCall2<typeof fn_n | typeof fn_s> = (input) => {
return { fn: fn_n, arg: [ 0 ] }
}
解答
戻り値の型のみ分配されるようにすればよい。
type CallMap<T extends (...args: any => any)> = T extends unknown
? Call<T>
: never;
type CreateCall3<T extends (...args: any => any)> = (
input: Record<string, string>
) => CallMap<T>
const t3_1: CreateCall3<typeof fn_n | typeof fn_s> = expected; // OK
const t3_2: CreateCall3<typeof fn_n | typeof fn_s> = invalid; // Error
わかりやすさのために CallMap を定義したが、インラインで(?)書くこともできる。
type CreateCall4<T extends (...args: any => any)> = (
input: Record<string, string>
) => (T extends unknown ? Call<T> : never);
終わりに
別解あったらコメント下さい。
Discussion