🍴

[TypeScript] 分配の応用例: 関数とその引数の組み合わせのチェック

2024/12/19に公開

やりたいこと

以下のような 関数(fn)その関数の引数(arg) の組 Call があるとき、入力に対応した Call を返す関数の型付けを考える。

type Call<T extends (...arg: any) => any> = {
  fn: T
  arg: Parameters<T>
}

具体例として、 inputnum が含まれる場合は ( fn_ninput.num ) を、それ以外の場合は ( fn_sinput.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);

終わりに

別解あったらコメント下さい。

参考

脚注
  1. まぁ私自身分配をちゃんと理解できていなかったので普通にハマっていたわけですが ↩︎

  2. A extends unknown ? X : never は extends unknown が常に true になるので、分配や型変換に用いられる ↩︎

Discussion