戻り値の型が変化する関数を TypeScript で表現する方法

こんにちは。システム部の真部です。
普段は alms のフロントエンドとバックエンドの開発を担当しています。

この記事ではサービスを開発する中で遭遇した TypeScript にまつわるちょっとした問題とその解決策について紹介しようと思います。

使用する TypeScript のバージョンは 4.6.4 です。


戻り値の型が変化する関数

TypeScript の型システムには JavaScript の動的な側面を表現するために高度な機能が用意されており、 戻り値の型が引数の型に応じて変わるような関数の型も表現することができます。

簡単な例を使って考えてみようと思います。
以下の関数は数値または文字列値を受け取り、受け取った値を 2 倍にして返します。

function twice(x: any) {
    return x + x;
}

twice(2);
// => 4

twice('aa');
// 'aaaa'

このような関数の引数と戻り値の型はどのように表現するのが良いでしょうか。


ユニオン型

とりあえずユニオン型を使って素直に書いてみます。

function twice(x: number | string): number | string {
    return x + x;
    //    ~~~~~~~ Operator '+' cannot be applied to types 'string | number' and 'string | number'.(2365)
}

残念ながらこの方法は上手くいきません。
+ 演算子がユニオン型を許容しないためです。


オーバーロード

オーバーロードを使ってみるのはどうでしょうか。

function twice(x: number | string): number | string;
function twice(x: any) {
    return x + x;
}

const num = twice(2);
// const num: number | string

const str = twice('aa');
// const str: number | string

エラーが出なくなり、最初の例よりも正確に引数の型と戻り値の型を表現できるようになりました。

しかしながら、この記述だと戻り値の型について若干曖昧な点が残ってしまいます。 この関数は引数の値を 2 倍した値を返すので引数の型と戻り値の型は一致しているはずです。 number を渡せば number が返ってきますし、string を渡せば string が返ってくるはずですが、そのような引数と戻り値の関係が関数の型に反映されていません。

もう少し細かく定義してみるのはどうでしょうか。以下のような形です。

function twice(x: number): number;
function twice(x: string): string;
function twice(x: any) {
    return x + x;
}

const num = twice(2);
// const num: number

const str = twice('aa');
// const str: string

良さそうです。
ところで他の関数の中から呼び出すことを考えると number|string にも対応できた方が良さそうですがこの場合にも対応できるでしょうか。

function func(value: number | string): void {
    const result = twice(value);
    //                  ~~~~~~~
    // No overload matches this call.
    // Overload 1 of 2, '(x: number): number', gave the following error.
    //   Argument of type 'string | number' is not assignable to parameter of type 'number'.
    //     Type 'string' is not assignable to type 'number'.
    // Overload 2 of 2, '(x: string): string', gave the following error.
    //   Argument of type 'string | number' is not assignable to parameter of type 'string'.
    //     Type 'number' is not assignable to type 'string'.(2769)
}

twice の呼び出し時の引数の型は numberstring だけであり number|stringnumber,string のどちらとも互換性が無いためエラーになります。
number|string を関数の定義に追加しましょう。

function twice(x: number): number;
function twice(x: string): string;
function twice(x: number | string): number | string;
function twice(x: any) {
    return x + x;
}

function func(value: number | string): void {
    const result = twice(value);
    // const result: number | string
}

これでようやく引数の型のパターンを網羅できました。
ただ、あまり簡潔な表現とは言えないかもしれません。 引数の型が増えた場合すべてのパターンを網羅するのはかなり大変そうです。number,string の他にもう一つ型が増えた場合、引数の型のパターンは 7 通りに増えます。(2 の 3 乗 -1 通り)


ジェネリクス

コード量を抑える方法を検討してみましょう。
ジェネリクスはどうでしょうか。
この方法なら numberstringnumber|string も受け取れるようになります。

function twice<T extends number | string>(x: T): T;
function twice(x: any) {
    return x + x;
}

良さそうに見えますがこの方法にも欠点があります。
リテラルを渡すと型推論の結果が厳密になり過ぎてしまう点です。

const num = twice(2);
// const num: 2
// num の値は実際には4なので不正確どころか間違ったものになってしまっている

const str = twice('aa');
// const str: "aa"
// str の値は実際には'aaaa'なので不正確どころか間違ったものになってしまっている

一応、型パラメータを明示することでこの問題を回避することはできますが、これでは利便性や保守性が損なわれてしまいます。

const num = twice<number>(2);
// const num: number

const str = twice<string>('aa');
// const str: string

Conditional Types

オーバーロードには記述量の問題があり、ジェネリクスにはリテラルを渡したときの型推論の結果が厳密になり過ぎる問題があることが分かりました。

これらを同時に解決するには引数の型を基に戻り値の型を決定するロジックを簡潔に記述する方法が必要になるわけですが、ちょうど TypeScript にはそのような仕組みが用意されています。

それが Conditional Types であり、これを使うと型パラメータの値に基づいて型を決定するロジックを記述することができます。

また、Conditional Types は 型パラメータがユニオン型のときに Distributive Conditional Types と呼ばれる仕組みによってユニオン型を構成する型ごとに型を決定するロジックを適用します。これによって型パラメータの値がユニオン型の場合にも対応することができます。

言葉よりもコードで表現した方が分かりやすいので例を見てみましょう。

function twice<T extends number | string>(x: T): T extends number ? number : string; // <- T の値に基づいて number と string のどちらなのか決定する
function twice(x: any) {
    return x + x;
}

function func(value: number | string): void {
    const result = twice(value);
    // const result: number | string

    // Distributive Conditional Types によって
    // number|string extends number ? number:string は
    // (number extends number ? number:string) | (string extends number ? number:string) となり、
    // 上記を計算すると number|string となります
}

const num = twice(2);
// T の型は数値リテラル(2) であり、2 extends number は true なので
// T extends number ? number:string を評価した結果は number になります
// 従って num の型は number になります

const str = twice('aa');
// T の型は文字列リテラル('aa') であり、'aa' extends number は false なので
// T extends number ? number:string を評価した結果は string になります
// 従って str の型は string になります

これでコード量を抑えつつ、戻り値の型が変化する関数の型を記述することができました。


最後に

Conditional Types を使うことによって関数の型を簡潔に、正確に表現できることが分かりました。 TypeScript には JavaScript の振る舞いに追従するために非常に柔軟で表現力豊かな型システムが用意されています。

TypeScript の機能を使いこなすには型に関する計算に慣れる必要がありますが、そうすることでコードの意図が明確になり可読性が向上します。 また、エディタや lint からのフィードバックが正確になり、開発者体験がより良いものになります。

最後までご覧頂きありがとうございました。
アイデミーでは TypeScript を活用してシステムの改善を進めています。