TypeScriptは、JavaScriptに型がついたたいへん素晴らしい言語です。しかし、元がJavaScriptであるということで、やりたいことが一筋縄ではうまくいかないこともままあります。
この記事ではそのような事例を一つ取り上げて、解決策やその考え方を紹介します。
記事に出てくるTypeScriptの型がよく分からない場合は以下の記事を参照するとよいかもしれません。
解決したい問題
例えばタブ型のUIを作りたい場合を考えます。各タブの名前を文字列で表現する場合は、タブ名の型はそれらの文字列のunion型となるでしょう。(下のコードはTab型の利用例としてReactのコードが出てきますが、Reactが分からなくても本題の理解には問題ありません。)
/** タブ名の型 */
type Tab = 'tab1' | 'tab2' | 'tab3';
/** タブを表示するコンポーネント(React) */
function TabComponent(props: { tab: Tab }) {
switch (props.tab) {
case 'tab1':
return <div>タブ1</div>;
case 'tab2':
return <div>タブ2</div>;
case 'tab3':
return <div>タブ3</div>;
}
}
このような型を作った場合、タブ名の一覧の配列が欲しくなることがよくあります。
/** 全てのタブ名の配列 */
const tabs: Tab[] = ['tab1', 'tab2', 'tab3'];
/** メニューを表示するコンポーネント */
function Menu() {
return <ul>{
tabs.map(tab => <li><a>{tabName(tab)}</a></li>)
}</ul>
}
今回問題となるのは、ここで出てきた変数tabsです。後からタブ4が追加されてTab型の定義が以下のように変更された場合を考えてください。
type Tab = 'tab1' | 'tab2' | 'tab3' | 'tab4';
こうした場合、タブ名の一覧の配列についても変更する必要があります。理想的には、変更する必要がある箇所は型エラーになってほしいですね。型エラーが発生してくれれば修正するべき箇所を簡単に発見することができます。下の例はTabの変更後は「全て」ではなくなっているのにエラーは発生しません。
/** 全てのタブ名の配列(“全て”でないのに型エラーにならない!) */
const tabs: Tab[] = ['tab1', 'tab2', 'tab3'];
そこで、Tabの変更が型エラーとなるようなコードを書きたいというのが今回の問題です。
より一般的な言い方をすれば、「与えられた配列が、union型で指定した要素を全部含むかどうか型検査で調べたい」ということになります。
解決策
では、いきなりですが完成された解決策を見ましょう。
type ElementOf<A extends any[]> = A extends (infer Elm)[] ? Elm : unknown;
type IsNever<T> = T[] extends never[] ? true : false;
function allElements<V>(): <Arr extends V[]>(arr: Arr) =>
IsNever<Exclude<V, ElementOf<Arr>>> extends true ? V[] : unknown {
return arr => arr as any;
}
type Tab = 'tab1' | 'tab2' | 'tab3' | 'tab4';
// tabsはunknown型
const tabs = allElements<Tab>()(['tab1', 'tab2', 'tab3']);
// tabs2はTab[]型
const tabs2 = allElements<Tab>()(['tab1', 'tab2', 'tab3', 'tab4']);
上半分が今回作ったもので、下半分が例です。変数tabsに直接配列を代入するのではなく、今回作ったallElementsという関数を噛ませるようになりました。このalElements関数はunion型で与えられた型変数を1つとり、与えられた配列がunion型の各要素を全部含んでいれば返り値の型はそれの配列型となり、含んでいなければ返り値の型がunknown型になるという関数です。
よく見ると、関数呼び出しの括弧が2つ並んでいますね。これは、複数の型引数のうち一部だけ指定して、一部を引数から推論させたいときに使うテクニックです。
TypeScriptでは現在のところ、型引数を一部省略するということはできません。全部省略して推論してもらうか、全部書くかのどちらかです。ところが、このように関数を返す関数を用いることで一部だけを指定することができます。この例では、気持ちの上ではallElementsはVとArrの2つの型引数を持つわけですが、そのうちVのみを利用者側に指定してほしいため、このように関数を分割してVのみを指定できるようにしています。早いところ、一部のみ型引数を指定できる機能が実装されてほしいですね。
ここで述べたように、Arrの型引数を明示しないことでTypeScriptに推論させるというのが最初のポイントです。Arrは型指定を見ると分かるように、引数として渡された配列の型です。これを推論させることで、型引数Arrに「実際に渡された型」の情報が得られるようになっています。上のコードの一部を再掲します。
function allElements<V>(): <Arr extends V[]>(arr: Arr) =>
IsNever<Exclude<V, ElementOf<Arr>>> extends true ? V[] : unknown {
return arr => arr as any;
}
const tabs = allElements<Tab>()(['tab1', 'tab2', 'tab3']);
この呼び出しで型引数VとArrがどうなるかを考えましょう。Vは型引数で指定されている通り、Tabです。Tabは'tab1' | 'tab2' | 'tab3' | 'tab4'のことでしたね。一方で、Arrは引数arrの型です。今回arrに渡されているのは['tab1', 'tab2', 'tab3']なので、引数の型を全部unionでつなぐと'tab1' | 'tab2' | 'tab3'となります。よって、型Arrはそれの配列の型、すなわちArray<'tab1' | 'tab2' | 'tab3'>となります。
こうなると、何となく希望が見えてきましたね。「必要な要素全ての型」と「実際に与えられた型」が得られたので、それらに差があるかどうか確かめればいいわけです。
なお、ここで一つ注意しておきたいのですが、['tab1', 'tab2', 'tab3']の型がArray<'tab1' | 'tab2' | 'tab3'>のように推論されるのは型引数を推論するときだけです。これを通常の変数に入れるとリテラル型の情報は消えます。
// arrの型はstring[]となる
const arr = ['tab1', 'tab2', 'tab3'];
ですので今回の関数は、配列リテラルを直接allElementsに渡さないとArrが正しく推論できません。
このように、配列(やオブジェクト)の中身をリテラル型として得たい場合は型引数の推論対象に直接入れてあげる必要があります。これはよく使うテクニックなので覚えておくとよいでしょう。
では、話を戻します。VとArrの意味は分かったので、残るはこれらから返り値を決める部分です。allElementsの返り値(正確には返り値の返り値)は以下のようになっていますね。
IsNever<Exclude<V, ElementOf<Arr>>> extends true ? V[] : unknown
ここで使われている2つの補助型の定義も再掲します。
type ElementOf<A extends any[]> = A extends (infer Elm)[] ? Elm : unknown;
type IsNever<T> = T[] extends never[] ? true : false;
ElementOf<A>は配列型Aの要素の型を得る型関数です。(書いてから気が付きましたが、A[number]でも良かったですね。)IsNever<T>は、Tがnever型ならtrue型を、そうでなければfalse型を返す型関数です。
そして、Exclude<T, U>は、union型TとUを受け取って、Tの要素からUの要素を除いた型を返す型関数です。
ElementOf<Arr>によって、配列で与えられた要素の一覧をunion型で取得しています。今回の例では、ElementOf<Arr>は'tab1' | 'tab2' | 'tab3'となりますね。これは実際に配列に含まれている要素の型の一覧でした。
これを、V、すなわち含まれていてほしい全ての型の一覧から引き算していることになります。そうなると、残るのはなんでしょうか。そう、「含まれていてほしいけど与えられた配列には含まれていなかった型」が残ります。上の例で考えると、Vは'tab1' | 'tab2' | 'tab3' | 'tab4'だったので、そこからElementOf<Arr>を引いた結果は'tab4'となります。これは、与えられた配列には'tab4'が含まれていてほしかったのに含まれていないことを意味しています。
次に、Excludeの結果がnever型かどうかをIsNeverで判定しています。neverなら結果の型はV[]に、そうでないならunknownとなることが分かります。実は、Exclude<T, U>でUにTの要素が全部含まれていた場合、結果は空っぽになります。この空っぽを表す型がnever型です。
以上が返り値の型の解説です。結局、与えられた配列にほしい型が全部含まれているかどうかをチェックしてそれによって返り値の型を変えているということがお分かりになると思います。
本当は、全部含まれていなかった返り値の型がunknownというのは理想的ではありません。理想的には、そうなった時点で型エラーが発生して欲しいです。そのような「型エラーを発生させる型」というのはやはり需要があるらしく議論があります。
とはいえ、これを型エラーに変換するのは難しくありません。一つの方法は型アノテーションです。
// unknown型はTab[]型に代入できないのでエラーが発生
const tabs: Tab[] = ellElements<Tab>()(['tab1', 'tab2', 'tab3']);
// こっちは大丈夫
const tabs2: Tab[] = ellElements<Tab>()(['tab1', 'tab2', 'tab3', 'tab4']);
これはちゃんと型エラーが発生して偉いですが、Tabと2回書かなければいけないのが少し残念です。
もうひとつの方法は、unknown型のまま放置するという方法です。正しくやれば結果は配列型となることを意図していますから、tabsを使おうとしたときにエラーが発生するという寸法です。これは楽ですが、エラーの発生する位置がtabsを使う場所なのでいまいち分かりにくいという欠点があります。
最後の方法は、型が配列であることをチェックする関数にさらにかけるという方法です。
function assertArray<T>(arr: T[]): T[] {
return arr;
}
// unknown型の要素をT[]型の引数に渡そうとしたのでエラー
const tabs = assertArray(ellElements<Tab>()(['tab1', 'tab2', 'tab3']));
const tabs2 = assertArray(ellElements<Tab>()(['tab1', 'tab2', 'tab3', 'tab4']));
この例のように、引数が配列の型になっていることをチェックするassertArrayを用意する方法です。これはTabを2回書く必要が無くて偉いですが、記述量が多いという欠点があります。どれも一長一短ですね。
余談
ところで、上では簡単のためにunknownを返り値にしましたが、これだとエラーメッセージがちょっと分かりにくいという問題があります。
// error TS2322: Type 'unknown' is not assignable to type 'Tab[]'.
const tabs: Tab[] = ellElements<Tab>()(['tab1', 'tab2', 'tab3']);
エラーメッセージは「unknown型をTab[]に代入することはできません」と言っていて、まあそうなのですが、どの要素が足りないのか(今回の場合は'tab4')ということが分からないのでエラーメッセージとしては不親切です。
例えばこんな感じに改良する方法があります、
function allElements<V>(): <Arr extends V[]>(arr: Arr) =>
IsNever<Exclude<V, ElementOf<Arr>>> extends true ? V[] : {notFound: Exclude<V, ElementOf<Arr>>} {
return arr => arr as any;
}
返り値の型をunknownではなく{notFound: Exclude<V, ElementOf<Arr>>}としています。このExclude<V, ElementOf<Arr>>というのは含まれていない値の型だったので、これを返り値の型に含めてやることでエラーメッセージに表示させようという魂胆です。こうすると先のエラーはこのようになります。
// error TS2322: Type '{ notFound: "tab4"; }' is not assignable to type 'Tab[]'.
// Property 'length' is missing in type '{ notFound: "tab4"; }'.
const tabs: Tab[] = ellElements<Tab>()(['tab1', 'tab2', 'tab3']);
ごちゃごちゃしていて分かりやすいとは言えませんが、'tab4'が足りないという情報がエラーメッセージに入っていて偉いですね。
まとめ
この記事ではリテラル型とunion型にまつわる身近な問題を取り上げ、その解決策を紹介しました。条件型のような難しい型を実用的に用いるよい例となっていると思います。皆さんもぜひこのような問題を見つけてTypeScriptの型の力で解決しましょう。
技術的な面では、配列リテラルの要素をリテラル型(のunion)として得るために型引数を推論させるというテクニックを紹介しました。このテクニックは結構使いますので覚えておくとそのうち役に立つのではないかと思います。