uhyo/blog

こわくないTypeScript〜Mapped TypeもConditional Typeも使いこなせ〜

2020年8月31日 公開

TypeScriptの型システムは、ユニオン型を始めとする様々な機能を持っているのが特徴的です。 その中でも、mapped typesconditional typesは高度な機能として知られています。 ところが、その機能の膨大さゆえ、全てを使いこなす必要はない、TypeScriptの複雑な機能を無闇に使うべきではないという言説はたびたび現れます。 そのときに槍玉に上がりやすいのがmapped typesとconditional typesなのです。

筆者は、これらの機能は使えるだけ使い倒すべきであるという考えを持っています。 主張の根幹には、高度な型を使えばより正確にインターフェースを記述することができること、そして正確なインターフェースは使いやすさや正確な型推論結果に貢献することがあります。 正確なインターフェースや型推論結果は、コードの理解速度や開発効率を促進します。 これらは型システムがもともと備える性質であり、高度な型の機能を使いこなした方がそのメリットがより享受されるのです。

尤も、これにはそのコードを読み書きする人がmapped typesやconditional typesを理解しているという前提があります。 しかし、現状はどうやらそうも行かないようです。 筆者としては、機能の難しさ以上に、これらを使いこなせなくてもいいという言説がこれらの普及を阻害しているように思います。 そこで、この記事ではTypeScriptの高度な型を使いこなすメリットを解説すると同時に、これらの機能が実はそんなに難しくないよということを皆さまにお伝えしたいと思います。 これまでこれらの機能を“食わず嫌い”していた方は、この記事をきっかけに考え直していただけると幸いです。

なお、この記事ではmapped typeやconditional typeを含むTypeScriptの機能を手取り足取り解説するわけではありません。 詳しい解説が読みたい方は筆者が以前に書いた次の記事をご参照ください。

ケーススタディ: より正確なインターフェースを追求する

そもそもTypeScriptにおける型の重要な側面は、インターフェースを記述するために使われるものであるということです。 特に、関数のインターフェースを記述するのには型が欠かせません。 関数のインターフェースとは、関数の引数は何であるか、そして関数の返り値が何であるかを記述したものです。 関数のインターフェースは、関数のロジックの一部を型で記述したものと見ることができます。

ここで一つ具体例を見てみましょう。 あなたなら、次の関数にどのような型を付けるでしょうか。

function convertToNumberAll(strArray) {
  return strArray.map(str => parseInt(str, 10));
}

名前からしてstrArrayは文字列の配列(string[])と思われますね。 配列のmapメソッドで各要素を数値(number)に変換しているわけですから、引数がstring[]で返り値がnumber[]と考えられます。 型註釈を書くと次のようになりますね。

function convertToNumberAll(strArray: string[]): number[] {
  return strArray.map(str => parseInt(str, 10));
}

この関数に書かれた型註釈、すなわち型で書かれたインターフェースは、関数のロジックの一部を抽出したものになっています。 具体的には、「引数として文字列の配列を受け取り、返り値として数値の配列を返す」という部分が型で記述されています。 その一方で、どのように数値の配列を作るのかという部分はこのインターフェースには現れていませんね。 これは型では書けない部分であり、ドキュメンテーションの際はコメントなどで補う必要があります。

readonlyを使ってインターフェースを改良する

実は、上で書かれた型註釈はまだ最善ではなく、より正確なインターフェースに改良することができます。 そのためには、引数のstring[]型をreadonly string[]型に変更します。

function convertToNumberAll2(strArray: readonly string[]): number[] {
  return strArray.map(str => parseInt(str, 10));
}

こうすることで、このインターフェースには「引数strArrayは関数内部で書き換えられない」という情報が加わりました。 なぜなら、引数の型がreadonly string[]ということは、関数convertToNumberAll2の内部でstrArrayを書き換える操作が許されないからです。 実際、両者を使う側では次のような違いが発生します。

const readonlyArray = ["1", "12", "123"] as const;

// これは型エラー
convertToNumberAll(readonlyArray);
// これはOK
convertToNumberAll2(readonlyArray);

変数readonlyArrayas const付きの配列リテラルで作られた配列なので、readonly配列型となります(より正確には、readonlyタプル型となります)。 これは、書き換えが許されない配列の型です。 変数readonlyArrayconvertToNumberAllに渡すのはコンパイルエラーとなります。 なぜなら、convertToNumberAllの引数の型はreadonly string[]ではなくstring[]であり、readonlyではないため「渡された変数を書き換える可能性がある」というインターフェースになっているからです。 一方で、convertToNumberAll2に渡すのは問題ありません。 こちらは引数の型がreadonly string[]であり、「渡された引数を書き換えない」ということが型で明示されているからです。

この例では、引数をstring[]からreadonly string[]にすることで型レベルの情報(与えられた配列を書き換えない)が増加しました。 情報が増加したことで型検査・型推論の助けとなり、結果としてこの関数の使い道が増えました(readonly配列を受け取ることができるようになりました)。 これが、より正確なインターフェースを記述したことの効果です。

ちなみに、readonlyによるインターフェースの強化に関しては以前にも以下の記事で詳説しています。 興味のある方はぜひ読んでみてください。

Mapped typeでさらにインターフェースを改良する

実は、上の関数に対してはmapped typeを使うことでさらにインターフェースを改良することができます。 というのも、この関数が返す配列の長さは、渡された配列の長さと同じです。 なぜなら、配列のmapメソッドは各要素を変換するのみで、配列の要素を増やしたり減らしたりすることはないからです。 しかし、この情報はインターフェースに載っていませんね。 Mapped typeを使えば、次のようにしてこの情報を型で記述することができます。

function convertToNumberAll3<A extends readonly string[]>(
  strArray: A
): {
  [K in keyof A]: number;
} {
  return strArray.map(str => parseInt(str, 10)) as any;
}

一気に複雑になりましたが、丁寧に読み解きましょう。 まず、型引数A extends readonly string[]が追加されました。 関数のインターフェースとしては、「A型の値を受け取り{ [K in keyof A]: number }型の値を返す」というインターフェースになっています。 Aextends readonly string[]という制約があることから、この関数に渡せるのが文字列の配列だけであるという点は変わりません。 型引数Aは手動で指定することもできますが、引数がA型であることから、型引数の指定を省略して引数から推論してもらう使い道を想定していることが読み取れます。

返り値に指定されているのがmapped typeです。 この場合、「Aの全てのプロパティがnumberとなった型」という意味になります。 ただし、このような([K in keyof A]: ...という形の)mapped typeはhomomorphic mapped typeと呼ばれる特殊な形であり、Aが配列型やタプル型の場合はその要素の型がnumberとなった新しい配列型・タプル型となります。 ここで特に重要なのは、引数の型(すなわちA)がタプル型(要素数が判明している配列の型)ならば、返り値の型も同じ要素数の配列となることです。

では、使用例を見てみましょう。

// readonly ["1", "12", "123"] 型
const readonlyArray = ["1", "12", "123"] as const;

// numArr1 は number[] 型
const numArr1 = convertToNumberAll2(readonlyArray);
// numArr2 は readonly [number, number, number] 型
const numArr2 = convertToNumberAll3(readonlyArray);

前の型定義を持つconvertToNumberAll2とmapped typeを使った新しい定義を持つconvertToNumberAll3を比べると、同じ引数を渡していても返り値の型が異なります。 まずnumArr1は要素数の情報が無いnumber[]という型であるのに対し、numArr2readonly [number, number, number]という型であり、numberの配列であることだけでなく、要素数が3であること(numberが3つ並んだ配列であること)までもが型で明らかになっています。 このように、関数の型定義を改良したことによって、関数の使用結果の型により多くの情報が残るようになりました。 これもまた、関数のインターフェースの情報が増えたということです。

せっかくなので、この動作をもう少し解説しておきましょう。 まず、readonlyArrayas constを使っているためreadonly ["1", "12", "123"]というタプル型(要素数と内訳がわかっている配列型)であるという前提があります。 numArr2の型にはこのタプル型の要素数が受け継がれています。 具体的には、converToNumberAll3を呼び出す場合には、型引数Aは引数readonlyArrayの型から推論され、readonly ["1", "12", "123"]型となります。 このAに対して{ [K in keyof A]: number }という型を計算した結果がreadonly [number, number, number]型なのです。

また、今回の改善点は「返り値の型が良くなった」ということで、いまいちパッとしない印象を受けたかもしれません。 しかし、返り値の型が良くなったということは、型エラーを検出する機会が増えたということです。 具体的には次のような場合です。


const readonlyArray = ["1", "12", "123"] as const;

// numArr1 は number[] 型
const numArr1 = convertToNumberAll2(readonlyArray);
// numArr2 は [number, number, number] 型
const numArr2 = convertToNumberAll3(readonlyArray);

// これはOK
console.log(numArr1[5]);
// これはコンパイルエラー
console.log(numArr2[5]);

配列numArr1numArr2の長さは3なので、numArr1[5]numArr2[5]にアクセスすべきではありません。 改良された型定義により得られたnumArr2は長さが3という情報が型に残っているので、numArr2[5]というアクセスはコンパイルエラーとなります。 一方、numArr1[5]はエラーを検出してくれません。 このように、より正確なインターフェースはより正確な型チェックを齎すのです。

インターフェースにどこまで拘るべきか——ただの関数との比較

ここまで記事を読んでいかがでしたか。 readonlyはまだしも、mapped typeを使ったこの型定義(再掲)はやりすぎとお思いの方が多いのではないでしょうか。

function convertToNumberAll3<A extends readonly string[]>(
  strArray: A
): {
  [K in keyof A]: number;
} {
  return strArray.map(str => parseInt(str, 10)) as any;
}

これがやりすぎであることは、正直否定しません。 実際、配列の長さが分かっている場面全てでこのような型定義を書くのは骨が折れます。 多くの場面で返り値の長さの情報は不要な場合が多く、返り値がnumber[]でも使用上何ら問題ない場合がほとんどでしょう。 これに関して、ここでは2つの観点から意見を述べておきます。

まず第一に、上記の型定義を書くデメリットは何かを考えようということです。 メリットはすでに長々と説明した通り、より情報が多く正確なインターフェースになるということです。 このメリットとデメリットを天秤に掛け、どうするかを決めるべきです。

では、デメリットとしては何が考えられるでしょうか。 皆さまの頭に浮かんだのは「分かりにくい」ではないでしょうか。 だとすると、何が分かりにくいのでしょうか。 パッと見て、引数が何で返り値が何か分かりにくいですか。

しかし、よく考えると「何が書いてあるか分かりにくい」ことは対処可能です。 これが型定義ではなく、数十行の関数だったらと考えてみましょう。 あなたは、関数の中身を理解するために関数を全部読むのでしょうか。 簡単な関数ならそれも可能かもしれませんが、そんなに簡単な関数だけで世の中が回っているわけではありません。 では、どうしても一瞬で理解できない関数が出現したときにどうしますか。 分からないからと拒絶するというのは懸命な選択肢ではありません。

長くなりましたが、我々にはコメントという武器がありますよね。 関数の処理を簡単にまとめてコメントを書いておくのはとても普通のことです。 ならば、型定義の意味が分かりにくいならば、このように型定義の意味をコメントに書いておけばいいのです。

/**
 * 与えられた文字列の配列を数値の配列に変換する。
 * string[]またはstringのタプル型を受け取り、同じ長さのnumberの配列またはタプル型を返す。
 */
function convertToNumberAll3<A extends readonly string[]>(
  strArray: A
): {
  [K in keyof A]: number;
} {
  return strArray.map(str => parseInt(str, 10)) as any;
}

これで、一目で分かりにくいからといってそのような型定義を避ける理由は無くなりました。 難しい実装よりは簡単な実装の方が良いのは当然としても、難しい実装が必要ならば分かりにくさをコメントで補えばいいのです。

なお、「関数なら内部に細かくコメントを書けるのに型定義には細かくコメントを付けられない」とお思いの方がいるかもしれません。 しかし、それは当たってはいません。 関数が分割できるのと同様に、型定義も分割できるからです。 上の型定義ならば、次のように分割すれば関数自体の型定義は小さくなります。

/**
 * 配列(またはタプル)型を受け取り、同じ長さのnumberの配列(またはタプル)を返す。
 */
type ToNumberArray<A extends readonly any[]> = {
  [K in keyof A]: number;
};

function convertToNumberAll3<A extends readonly string[]>(
  strArray: A
): ToNumberArray<A> {
  // ...
}

この記事全体のテーマとして、「型定義も普通のプログラムも同じ」という考え方があります。 普通のプログラムを書く時に使われる考え方(コメントを書く、関数を分割するなど)は型定義を書く際にも全く同じことが当てはまるはずなのです。 しかし、実際には型定義を読み書きする際にそのことを忘れてしまう人がいます。 この記事を通じて、型定義は何も特別なものではなく、普通の関数を書くのと同じ考え方で書けばいいということをお伝えしたいと思っています。

さて、話を戻すと、この型定義の分かりにくさとして「そもそもmapped typeが読み慣れなくて分かりにくい」ということを挙げる方も多いでしょう。 筆者の正直な意見としては、読み慣れてください。 プログラムを書くのに必要な機能があればそれは習得すべきです。 Mapped typeは「オブジェクト型の各要素を別の型に変換する」という特性から、ちょうど配列のmapメソッドに近いものです。 「配列のmapメソッドはコールバック関数なんか使って目が滑る! 分かりにくいから使わないべき! for文最高!」と主張する人がいたら、多くの賛同を集めるのは難しいでしょう。 しかし、mapped typeを始めとするTypeScriptの機能に関しては、同じ主張がそこそこまかり通っているように思います。

実際分かっていない人がたくさんいるとしても、「分かっていない人が多いから使わないべき」というのも先が暗い考え方です。 新しいもの(まだ誰も分かっていないもの)が出現したときに手も足も出なくなってしまいます。 必要に応じて学習することは、プログラミングをする上で欠かせない営みです。 Mapped typeやconditional typeは難易度が高いと思われがちですが、そのハードルを下げるのもこの記事の役目です。

結局、「型定義が複雑であること」のデメリットは「関数が複雑であること」のデメリットと鏡写しです。 関数の複雑化の対処法を知っているのなら、それと同じことを型定義に対して適用してやればよいだけの話なのです。 関数の複雑化がときに避けられないように、(一定のクオリティのインターフェースを求めるならば)型定義の複雑化も避けられないときがあります。 複雑な型定義を書くかどうかの意思決定は、ちょうど複雑な関数を書くかどうかの意思決定と同じです。 同じように考えればそれでいいのです。

必要に応じて書くのか、最初から書くのか——型定義のリファクタリング

Mapped typeを用いたあのような型定義は、実際のところ必要になる場面が少ないのはご承知の通りです。 「渡した配列の要素数と返り値の配列の要素数が等しい」という情報が型レベルで必要になるのはタプル型を扱っているときに限られ、タプル型の要素数が陽に問題になることはあまりありません。

そうなると、型定義の複雑化を避けて簡単な定義で済ませるというのも現実的な選択肢です。 ただし、複雑な型定義が必要になれば、そのときは躊躇せずに型定義を複雑化させるべきです。 例えば、関数の返り値の型に推論結果に不満がある場合(もっと賢く推論してくれてもいいのにとか、返り値の型が不便だと思う場合)は、その関数のインターフェースを改良することで推論結果を改善できるかもしれません。 改善の余地が表面化した型定義は改善すべきです。 これはちょうど、必要に応じてプログラムをリファクタリングするのに似ています。

ただし、“型定義のリファクタリング”は普通のリファクタリングよりも難しいかもしれません。 というのは、いつやるべきかの見極めが難しいのです。 「関数のインターフェースの改善の余地が表面化したとき」という基準は明確ですが、「改善の余地」を発見するには実力が必要です。 例えばここのインターフェースを良くすればより多くのミスをコンパイルエラーでキャッチできるかもしれないとか、そういった機会を発見する嗅覚が必要になります。 それを得るためには、複雑な型定義によるインターフェース改善の経験を積むしかありません。 しかし、出来ることの引き出しを増やして適用できるときに適用する、というのは型定義だけでなくプログラミング全般に通用する研鑽の仕方です。 ここでもやはり、型定義だけに特別なことは何も無いのです。

リファクタリングといえば、「最初から綺麗に書くのか、それとも必要になってからリファクタリングするのか」という問題は常に付いて回ります。 型定義に対応させれば、「最初から全力で完璧な型定義を書くのか、それとも必要に応じて型定義を改良していくのか」ということです。 こちらの問題にもやはり決まった答えはありません。 しかし、一つ目安となる考え方はあります。 それは、「関数の意図を型定義に反映させる」ということです。 関数の型定義は、その関数のインターフェースを説明するものです。 ですから、その関数にどのような意図が込められているのかということを型定義に反映しましょう。

関数が「string[]を受け取ってnumber[]を返す。返す配列の長さとか興味が無い」という意図で書かれたものならば、返り値の長さの情報を型定義に含める必要はありません。 簡単な型定義で十分です。 一方で、その関数にとって「与えられた配列の各要素を変換するので、長さが変わらない」ということが重要ならば、それを型定義に反映させるべきです。 関数の意図が型定義に載っていることは、ドキュメンテーションとしての意味もあります。 「長さが変わらない」という型定義になっていることで、「この関数は与えられた配列の長さを変えない」という明確な意思表示となります。 将来的に関数の実装がいじられることになった場合も、型定義がガイドとなるでしょう。 長さが変わらないという型定義になっていれば、返り値の長さが変わるような変更は関数の本来の意図に合わないということに気付くことができるはずです。

ただ、後から関数に「返す配列の長さが変わらない」という要件が追加されるかもしれません。 そのときにがリファクタリングのタイミングです。 型定義をリファクタリングし、「返す配列の長さが変わらない」と言う機能を追加しましょう。

型定義はロジックである

ここまで、mapped typesなどを使った複雑な型定義の重要性について解説しました。 また、型定義に対する考え方は通常のプログラム(関数の実装とか)に対する考え方と何も変わらないのだということを強調してきました。 この考え方をさらに推し進めて行きましょう。

次は、型定義はロジックであるという考え方を身につけていただきたく思います。 この記事の最初の方で、「関数のインターフェース(関数の型定義)は、関数のロジックの一部を型で記述したもの」であると述べました。 逆の見方をすれば、関数の型定義というのは関数のロジックの一部であるとも言えます。

そもそも、関数のロジックというのは2つに分けられます。 値の世界のロジック型の世界のロジックです。 値の世界のロジックとは、その関数のランタイムの挙動です。 すなわち、その関数を実行した際にランタイムに何が起きるかということです。 これを決めるのは、関数の中身そのものです。 関数の中身全てが関数の“値の世界のロジック”であると言えます。

そして、型の世界のロジックとは、その関数が型システム上でどういう挙動をするかということです。 言い方を変えれば、その関数にどんな型の値を渡すことができるのか、関数の返り値の値はどう推論されるのかということです。 お分かりの通り、これは関数の型定義そのものです。 関数の型定義というのは、関数のロジックのうち型の世界を全て担当するものなのです。

この2つのロジックは無関係というわけではありません。 型検査によって両者は繋がっています。 関数の値の世界のロジックと型の世界のロジックが食い違っている(関数の実装が型定義通りになっていない)ことをTypeScriptコンパイラが検知したらコンパイルエラーが発生します。 これによって、両者が食い違っている、すなわち関数の実装が間違っている状況を防ぐことができるのです。

ロジックの分離——2つのロジックを別々に書く

普通の場合、値の世界のロジックと型の世界のロジックは型推論などを通じて一体化しています。 しかし、TypeScriptの高度な型定義においては二つを別々に書くことが可能となっています。 実は、その例は先のmapped typeの例にすでに現れています(再掲)。

function convertToNumberAll3<A extends readonly string[]>(
  strArray: A
): {
  [K in keyof A]: number;
} {
  return strArray.map(str => parseInt(str, 10)) as any;
}

ここまで敢えて触れませんでしたが、よく見ると関数の実装の最後にas anyと書いてあります。 見た瞬間に敗北者だ!と思った方もいるかもしれませんが、これは必要な犠牲です。 というのも、TypeScript標準の型定義でのmapのインターフェースには「長さが同じ」といった情報が含まれていませんから、そのままではコンパイルが通らないのです。 また、mapped type(やconditional type)を使っている場合は型の形が複雑すぎて、asanyを使って誤魔化さないとコンパイルを通すのは不可能です。 もちろん何も考えずにanyを濫用しているわけでもなく、anyによる影響範囲が最小になるように考えています。 この辺りの考え方は以前のトークで詳しく述べていますので、興味がある方は見てみてください。

ここで特に着目すべきは、anyによって値の世界のロジックと型の世界のロジックが分断されているということです。 anyによって、関数の返り値が何であろうとコンパイルが通ってしまうようになりました。 これは、「関数の実際の返り値」(=値の世界のロジック)と「型定義に書かれた返り値の型」(=型の世界のロジック)が互いに影響を与えない状況になったということであり、この状況では両者がまったく別々に実装されることになります。 ただしanyを使っている以上、これは値の世界のロジックが嘘をついた(=型定義に反する実装になっていた)としてもお咎めなしという危険な状況です。 ですから、「嘘をつかない」というのは当然の大前提となります。 とにかく、高度な型定義を書く際にはこのように値の世界のロジックと型の世界のロジックを別々に書くことが必要になってくるという点を抑えておきましょう。

強調したいのは、この考え方においては型定義は関数の付属物ではないということです。 むしろ、型定義は関数の中身そのもの(=値の世界のロジック)と肩を並べる存在であると言えます。

ですから、TypeScriptにおいて関数を定義するというのは2つの作業を伴うことになります。 一つは値の世界のロジックを書くこと、そしてもう一つは型の世界のロジックを書くことです。 筆者は難しい型定義を書いている時、型定義こそ関数の本体でありその中身は辻褄合わせのために書くものだと思うことすらあります。 筆者の既存記事では以下の記事が特にその傾向が色濃いですね。 型定義の説明ばかりで、実装(=値の世界のロジック)の解説は適当です。

ここで皆さんに理解していただきたいのは、型定義を書くというのも関数の実装における重要なステップであるということです。 そして、型定義もまたロジックを書く作業であるということです。 そして、正確なロジックを書くために重宝される道具がまさにmapped typesやconditional typesなのです。

ケーススタディ: 条件分岐を含む型のロジック

ロジックと言えば、最も基本的なのは条件分岐です。 型の世界のロジックでこれを担当するのがconditional typesです。 値の世界の側では、言うまでもなくif文とか条件演算子(? :)ですね。 Conditional typesをご存知の方はお分かりだと思いますが、conditional typesは条件演算子にとても類似しており、まさに型版の条件演算子とも言える存在です。 ですから、筆者の正直な意見として、conditional typesの難易度は条件分岐の難易度と同程度だと思っています(ただ、conditional typesの場合はunion distributionなど追加の知識が多少必要となりますが)。 「条件分岐は難しい! 避けるべき!」なんて主張したら誰にも相手にされませんから、conditional typesも難しいなんて言わないで欲しいですね、本当は。 ただし、“無闇に”使うべきではないというのは筆者も同意するところです。 だから「if文を書かなくても解決できる場合はif文を書かない! Conditional typesも同じ!」なんて反論はしないでくださいね。 ここでは条件分岐が必要な場合の話をしています。 必要ならば、conditional typesだろうと何だろうと避けるべきではありません。

では、conditional typesはどのような場合に必要になるのか、言い換えればconditional typesはどのように正確なインターフェースに貢献するのか、その例をひとつ紹介します。 こんな使い方をする関数getUserInputを考えてみてください。

const input = await getUserInput({ optional: true });

関数getUserInputはこのように真偽値のoptionalオプションを受け取るとします。 返り値はPromise<string>ですが、optionaltrueの場合は結果が無い場合もあり、結果がundefinedになる場合もあるとします(すなわち、返り値がPromise<string | undefined>)。 このような関数に、皆さんならどう型を付けるでしょうか。 Conditional typesが無ければ、次のようなものが限界でしょう。

type Options = { optional: boolean };
async function getUserInput(
  { optional }: Options
): Promise<string | undefined> {
  while (true) {
    const result = await getUserInputMaybeUndefined();
    if (result !== undefined || !optional) {
      return result;
    }
  }
}

ここで、getUserInputMaybeUndefinedは何らかの関数で、Promise<string | undefined>を返すと考えてください。 この関数はoptionalがfalseなら答えが得られるまで無限にループします。

まあ、今回内部実装はそこまで重要ではありません。 問題は、型がどうなっているかです。 この関数getUserInputにつけられた型は、この関数の“型の世界のロジック”を完璧に表しているとは言えません。 なぜなら、引数として{ optional: false }を渡した場合でも返り値がPromise<string | undefined>となっており、「optionalfalseならばundefinedが返る可能性はない」と言うことを表現できていないからです。 関数を使う側としては、optionalfalseなら返り値はPromise<string>となってほしいでしょう。

これを実現するには、「optionalfalseならば返り値をPromise<string>にする」という型の世界のロジックが必要です。 これは型の条件分岐であり、まさにconditional typesの使い所です。 では、conditional typesを使ってこの要件を実現してみましょう。

type Options<IsOptional extends boolean> = { optional: IsOptional };
async function getUserInput<IsOptional extends boolean>(
  { optional }: Options<IsOptional>
): Promise<IsOptional extends false ? string : string | undefined> {
  while (true) {
    const result = await getUserInputMaybeUndefined();
    if (result !== undefined || !optional) {
      return result;
    }
  }
}

使う側はこのような挙動になります。 求めていた挙動になっていますね。

// foo は string 型
const foo = await getUserInput({ optional: false })
// bar は string | undefined 型
const bar = await getUserInput({ optional: true })

では、上記の型定義を分解していきましょう。 まず、getUserInputに型引数IsOptional extends booleanが追加されました。 これは、オプションとして与えられたoptionalの値を型の世界で取得するために必要です。 型引数IsOptionalは引数から推論され、引数が{ optional: false }ならfalse型に、引数が{ optional: true }ならtrue型に推論されます。 このように、引数に与えられた値(の型)を型の世界のロジックで取り扱いたい場合は型引数にして推論してもらいます。

これでIsOptionalという型にfalsetrueが入った状態になるので、それを返り値の型に反映させるだけです。 返り値の型はこうなっていますね。 ここにconditional typeが使われています。

Promise<IsOptional extends false ? string : string | undefined>

Promiseの中身はconditional typeで、条件 ? 真のときの型 : 偽のときの型と言う形です。 ただし、条件は型1 extends 型2という形です。 この条件は「型1型2の部分型である」という条件、言い換えれば「型1型2に代入可能である」と言う条件を表します。 今回条件はIsOptional extends falseなので、これはIsOptionalfalseであるという意味になります。 よって、IsOptionalfalseならばこのconditional typeはstringになり、trueならばstring | undefinedになります。

一見やっていることは難しく見えますが、要するに「IsOptionalfalseかどうかで条件分岐して返り値の型を変える」ということをやっているだけです。 型定義を書いている、という気持ちだと何だか小難しいことをやっている気持ちになりますが、そうではなく関数のロジックのうち型の部分を実装しているのだという気持ちになりましょう。 そう思えば、条件分岐の一つくらい書けて当然と言う気持ちになりませんか。 ぜひなっていただきたい所です。

補足: 細部を詰める

とはいえ型の世界のロジックでは考えることが多少増えるのは否めません。 一応細かな挙動を確認しておきましょう。

まず、IsOptionalですが必ずtrue型やfalse型のどちらかになるとは限りません。 この型引数はextends booleanという条件を満たしていればよいので、booleananyneverといった型が放り込まれる可能性もあります。 これは型引数を明示的に指定するのが簡単です(通常の使い方ではそうする必要はありませんので、あくまで念の為ですが。また、never型の値を渡す方法はないのでasで誤魔化しています)。 その場合の挙動をチェックしてみましょう。

// a は string | undefined 型
const a = await getUserInput<boolean>({optional: false})
// b は string | undefined 型
const b = await getUserInput<any>({optional: true})
// c は never 型
const c = await getUserInput<never>({ optional: false as never })

変数aの場合、つまりIsOptionalbooleanである場合は、渡されたoptionaltrueなのかfalseなのか型のロジックからは分からない状況となります。 よって、trueの可能性もあると判断して結果がstring | undefinedとなるのが適切です。 上の例はそうなっていますね。 渡されたのがanyの場合も同様です。

ただし、なぜそうなるのかは両者の場合で異なっています。 IsOptionalbooleanの場合は、IsOptional extends boolean ? ...のところでunion distributionが発動します。 これは、IsOptionalがユニオン型の場合はユニオン型の各構成要素に対して別々にconditioanl typeが計算されるというものです。 実はbooleanfalse | trueというユニオン型であるため、これは以下の型として計算されます。

(false extends false ? string : string | undefined) |
(true  extends false ? string : string | undefined)

よって、これはstring | (string | undefined)となり、結果はstring | undefinedです。

一方、IsOptionalanyの場合はもっと単純で、conditional typeの結果は両辺のユニオン型となります1。 そのため、結果はstring | (string | undefined)として計算され、やはりstring | undefinedとなります。

最後のIsOptionalneverの場合にconditional typeの結果がneverとなってしまうのは、やはりunion distributionによって説明できます。 neverは0個のユニオン型なので、union distributionによってconditional typeの結果も無条件に0個のユニオン型、すなわちneverとなってしまうのです。 とはいえ、今回結果がneverとなるのは大した問題ではありません。 通常optionalnever型の値が渡されることはありえませんので、その場合にありえない結果(返り値がnever)となるのは問題ありません。

とにかく、conditional typesで書いたロジックの細部を詰める場合はunion distributionのことを考える必要があり、これは正直少々厄介です。 最初は、挙動が気になる型を型引数に入れてみて問題ないかをチェックするくらいでよいでしょう。

まとめ

この記事では、TypeScriptのmapped typesやconditional typesと向かい合うにあたって重要な考え方を紹介しました。 それは、型定義とは型の世界のロジックであると言う考え方です。 Conditional typesは型の世界の条件分岐だし、mapped typesはそれほどシンプルな比較対象が無いものの、配列のmapメソッドが近いでしょう。 そして、型定義はおまけではなく、値の世界のロジック(ランタイムの実装)と双璧を成すものなのです。 関数を書くならば、関数の中身(値の世界のロジック)を書いてやっと半分であり、型の世界のロジック(型定義)を完成させてやっと完全に関数を実装できたと言えます。

一定以上の正確さを持った型定義を書くならば、mapped typesやconditional typesの存在は欠かせません。 この記事では、mapped typesの使用例とconditional typesの使用例をひとつずつ紹介しました。 どちらも、これらを使うことでより正確な型定義を提供できる例となっています。

正確な型定義は、関数の使いやすさに影響します。 Conditional typesの例(getUserInput)では、conditional typesが無いと本来ありえない可能性が型上に残ってしまうという困った状態になっていましたね。 この記事で説明した通り、なるべく正確な型定義、関数の意図を正確に反映した挙動を実現するためには、mapped typesやconditional typesといった道具が欠かせないのです。

残念ながら、この記事ではmapped typesやconditional typesを手取り足取り教えることはできません。 これらを身につけるには、普通のプログラミングと同様に練習が重要です。 筆者も、一応以下の記事で多少の練習問題を提供していますが、まだ十分とはいえません。 さらに練習の機会を提供していきたいなというのが筆者の思いです。

皆さんも、普段のTypeScriptプログラミングの中で意識的にmapped typesやconditional typesの活用機会を発見し、経験を積んでいくのが望ましいでしょう。 そのためには普段から「不便な型定義・完璧でない型定義」に目ざとくなり、型のロジックによって改良できないかどうかを考えましょう。 Mapped typesもconditional typesも決して忌避するべきものではなく、全てのTypeScriptプログラマの強力な武器なのです。


  1. 右辺もanyの場合(any extends anyになった場合)は例外で、左辺の型に解決されます。