知らんけど。

主にプログラミングについて書きます

TypeScriptで型安全な関数合成を行う

型安全な関数合成関数を作成したのでメモします。

関数合成を行う関数compose

const defaultFunction = (p: any) => p;

const compose = <
  F extends (...args: any[]) => T0,
  T0 = ReturnType<F>,
  T1 = T0,
  T2 = T1,
  T3 = T2,
  T4 = T3,
  T5 = T4,
  T6 = T5,
  T7 = T6,
  T8 = T7,
  T9 = T8
>(
  f0: F,
  f1: (p: T0) => T1 = defaultFunction,
  f2: (p: T1) => T2 = defaultFunction,
  f3: (p: T2) => T3 = defaultFunction,
  f4: (p: T3) => T4 = defaultFunction,
  f5: (p: T4) => T5 = defaultFunction,
  f6: (p: T5) => T6 = defaultFunction,
  f7: (p: T6) => T7 = defaultFunction,
  f8: (p: T7) => T8 = defaultFunction,
  f9: (p: T8) => T9 = defaultFunction
) => (...p: Parameters<F>) => f9(f8(f7(f6(f5(f4(f3(f2(f1(f0(...p))))))))));

関数10個までを合成します。

composeの使用

例えばFizzBuzzを以下のように作ることができます。 Fizz Buzz - Wikipedia

function createFizzBuzz(
  fizzBuzzString: string,
  ...nums: number[]
): (input: number | string) => number | string {
  return (input: number | string) =>
    typeof input === 'number' && nums.every((num) => input % num === 0)
      ? fizzBuzzString
      : input;
}

const toFizz = createFizzBuzz('Fizz', 3);
const toBuzz = createFizzBuzz('Buzz', 5);
const toFizzBuzz = createFizzBuzz('FizzBuzz', 3, 5);
function toString(input: number | string): string {
  return input.toString();
}
const fizzbuzz = compose(toFizzBuzz, toFizz, toBuzz, toString);

describe('FizzBuzz', () => {
  test('2 -> 2', () => expect(fizzbuzz(2)).toEqual('2'));
  test('3 -> Fizz', () => expect(fizzbuzz(3)).toEqual('Fizz'));
  test('4 -> 4', () => expect(fizzbuzz(4)).toEqual('4'));
  test('5 -> Buzz', () => expect(fizzbuzz(5)).toEqual('Buzz'));
  test('6 -> Fizz', () => expect(fizzbuzz(6)).toEqual('Fizz'));
  test('10 -> Buzz', () => expect(fizzbuzz(10)).toEqual('Buzz'));
  test('15 -> FizzBuzz', () => expect(fizzbuzz(15)).toEqual('FizzBuzz'));
  test('30 -> FizzBuzz', () => expect(fizzbuzz(30)).toEqual('FizzBuzz'));
});

生成した関数の型

composeで作成した関数は、型が特定されています。 f:id:hori_chan:20200514202112p:plain

composeに指定した第一引数の関数の引数の型が、composeで生成した関数の引数の型になります。 また、composeに指定した最後の関数の戻り値の型が、composeで生成した関数の戻り値の型になります。

生成したfizzbuzz関数の引数の型が「string | number」なのがイマイチですね。 以下のようにnumberを引数に取る関数を第一引数にすると良いです。 f:id:hori_chan:20200514202208p:plain

引数に渡す関数も型安全

例えば、以下のようなcomposeの引数を

const fizzbuzz = compose(inputNumber, toBuzz, toString);

以下のように変えると型エラーになります。

const fizzbuzz = compose(inputNumber, toBuzz, inputNumber, toString);

f:id:hori_chan:20200514202835p:plain

これは、toBuzzの戻り値の型がstring | numberであるのに対し、inputNumberの引数の型がnumberであるためです。

compose関数の解説

const defaultFunction = (p: any) => p; // ①

const compose = <
  F extends (...args: any[]) => T0, // ②
  T0 = ReturnType<F>, // ③
  T1 = T0, // ④
  T2 = T1,
  T3 = T2,
  T4 = T3,
  T5 = T4,
  T6 = T5,
  T7 = T6,
  T8 = T7,
  T9 = T8
>(
  // ⑤
  f0: F,
  f1: (p: T0) => T1 = defaultFunction,
  f2: (p: T1) => T2 = defaultFunction,
  f3: (p: T2) => T3 = defaultFunction,
  f4: (p: T3) => T4 = defaultFunction,
  f5: (p: T4) => T5 = defaultFunction,
  f6: (p: T5) => T6 = defaultFunction,
  f7: (p: T6) => T7 = defaultFunction,
  f8: (p: T7) => T8 = defaultFunction,
  f9: (p: T8) => T9 = defaultFunction
) => (...p: Parameters<F>) => f9(f8(f7(f6(f5(f4(f3(f2(f1(f0(...p)))))))))); // ⑥

多くの型引数を使用しています。これらは、compose関数に引数を指定した際に、次に指定する関数の型やcompose関数で生成する関数の型を動的に導き出すためにあります。

①defaultFunction

const defaultFunction = (p: any) => p;

defaultFunctionは、受けた引数をそのまま返却する関数です。

composeに指定する関数は1〜10個(f0〜f9まで)指定でき、指定する関数が10個に満たない場合にdefaultFunctionが使用されます。

②ひとつめの型引数 F

F extends (...args: any[]) => T0

何らかの引数を受け取り何らかの返却値:T0を返す関数を継承した型 F を定義しています。 F はcompose関数の第一実引数の型となっており、実際にcompose関数に第一引数を渡したタイミングで型が確定します。

③T0

T0 = ReturnType<F>

関数Fの戻り値の型をT0と定義しています。 実際にcompose関数に第一引数を渡したタイミングで型が確定します。

④T1〜T9

  T1 = T0,
  T2 = T1,
  T3 = T2,
  T4 = T3,
  T5 = T4,
  T6 = T5,
  T7 = T6,
  T8 = T7,
  T9 = T8

compose関数の引数となる関数の戻り値、兼引数の型をそれぞれ定義しています。 T1〜T9は、実際にcompose関数に第二引数以降を指定することで型が確定します。

デフォルトで、T1はT0と同一の型、T2はT1と同一の型、・・・というように定義しています。 デフォルトの型は、compose関数を使用する際に指定する関数f1〜f9のうちいくつかが省略された場合に適用されます。

⑤実際の引数

  f0: F,
  f1: (p: T0) => T1 = defaultFunction,
  f2: (p: T1) => T2 = defaultFunction,
  f3: (p: T2) => T3 = defaultFunction,
  f4: (p: T3) => T4 = defaultFunction,
  f5: (p: T4) => T5 = defaultFunction,
  f6: (p: T5) => T6 = defaultFunction,
  f7: (p: T6) => T7 = defaultFunction,
  f8: (p: T7) => T8 = defaultFunction,
  f9: (p: T8) => T9 = defaultFunction

f0

f0はFの型の関数を指定する必要があります。 と言っても、Fは如何なる関数も許容する型ですので、関数なら何でもOKです。

f0を指定すると、関数Fの型が確定します。 例えば、f0に上述のinputNumberを指定した場合、inputNumberは(input:number)=>number型であるため、この型がFということになります。

f1〜f9

f1はf1: (p: T0) => T1のように定義しています。 これは、「T0の型の引数pを受け取り、T1の型の引数を返す関数」を意味しています。 T0は第一引数の関数f0のReturnTypeです。つまり、第一引数の関数f0の戻り値がstringなら、第二引数の関数f1の引数はstringで確定します。

例えば、f0に上述のinputNumberを指定した場合、inputNumberの戻り値の型であるnumberがf1の引数である必要があります。この時点ではf1の戻り値は何でも良いです。

f2以降も同様の考え方です。

このように、composeの型引数と実引数は、前の引数の型によって後の引数の型を決定していく構造となっています。

引数を省略した場合

f0以外の引数は省略可能となっており、デフォルトでdefaultFunctionが適用されます。

defaultFunctionは、受け取った引数をそのまま返却する関数です。 defaultFunctionを使用した場合、引数と戻り値の型が変わりません。 そのため、f1〜f9が途中から省略された場合には、最後に指定した関数の戻り値が、compose関数で生成した関数の戻り値の型T9として確定します。

⑥compose実行部分

// 引数部分省略・・・
) => (...p: Parameters<F>) => f9(f8(f7(f6(f5(f4(f3(f2(f1(f0(...p))))))))));

compose関数を実行すると、(...p: Parameters<F>) => f9(f8(f7(f6(f5(f4(f3(f2(f1(f0(...p))))))))))の関数を生成し返却します。

生成する関数の引数はF関数の引数と同じとなります。 生成する関数は、関数f0にpを渡し、その結果を関数f1に渡し、その結果を・・・という具合にf0〜f9を全て実行する内容となります。


以上。