ユーザー定義型ガード、asで書くかanyで書くか
2021年4月9日 公開
TypeScriptでユーザー定義型ガードを定義する場合、引数をany
型にして中を自由に書く方法と引数をunknown
型にして中でas
を駆使して書く方法があります。この記事では両者を比較考察します。結論としては、unknown
とas
を使うのが型安全性の面からおすすめです。また、うまく関数を分割することでas
を消すことも可能で、これも有効です。
ユーザー定義型ガードとは
TypeScriptのユーザー定義型ガードとは、型の絞り込み (type narrowing) に使うことができる関数です。ユーザー定義型ガードは返り値が引数名 is 型
のような形の型述語 (type predicate) になっています1。このような関数は真偽値を返り値として返し、true
を返すならば引数名
が型
であることを表します。
例えば、与えられた値がstring | number
型かどうか調べるユーザー定義型ガードは次のように定義できます。
function isStringOrNumber(value: unknown): value is string | number {
return typeof value === "string" || typeof value === "number";
}
// 使い方
function useUnknown(v: unknown) {
// v はここでは unknown 型
if (isStringOrNumber(v)) {
// v はここでは string | number 型
console.log(v.toString());
}
}
使い方というところを見ると、最初unknown
型だった変数v
がif文を通すことでstring | number
型になることが分かります。unknown
型にはnull
やundefined
の可能性もあるのでv.toString()
は不可能ですが、if文でチェックすることにより型が絞られてv.toString()
が可能になります。
isStringOrNumber
の返り値の型述語は、「この関数の中では型の絞り込みのためのチェックが行われている」ということをTypeScriptコンパイラに理解してもらうために必要です。関数を型ガードとして扱う(=関数呼び出しをif文の条件部で使うことで型の絞り込みを行う)ためには型述語が不可欠です。
ユーザー定義型ガードを利用するにあたって非常に注意しなければいけないのは、型述語が正しいことはチェックされないということです。つまり、型述語にvalue is string | number
と書いているのは完全にそれを書いた人(プログラマ)による自己申告であり、関数の実際の実装がそれと食い違っていてもお咎め無しだということです。例えば、次のような実装でもコンパイルエラーは起きません。
function isStringOrNumber(value: unknown): value is string | number {
// 型述語と食い違う実装!
return value === null;
}
このように、型述語に嘘っぱちを書いたとしてもTypeScriptコンパイラは何の疑いもなく信じてしまいます。この意味で、ユーザー定義型ガードはany
やas
と同じく危険な機能です。
とはいえ、ユーザー定義型ガードが持つ危険性とは裏腹に、ユーザー定義型ガードという機能をわざわざ使うTypeScriptプログラマは比較的型安全性に対する意識が高いプログラマであると言えます。ユーザー定義型ガードというのは、TypeScriptに組み込みの推論機能では理解できないようなロジックを、人間が補助してあげる(型述語を書いてあげる)ことでTypeScriptに理解できるようにしてあげる仕組みであると解釈できます。これにより、TypeScript単体では不可能なレベルの型安全性保証が可能となるのです。言い換えれば、ユーザー定義型ガードはTypeScriptの型安全性保障というタスクを一部人間が肩代わりしてあげられる仕組みであると言えます。
このとき重要なのは、人間が肩代わりした部分の型安全性は人間が責任を持つということです。本来は全部TypeScriptが完璧にやってくれるのが理想的ですが、現実にはそうもいきませんので、足りない部分を人間が補ってあげます。その際に人間にも責任が生じてしまう、これがユーザー定義型ガードの危険性です。
このような人間による補正はユーザー定義型ガードに特有のものではなく、as
なども同様です。as
の正しい使い道は「TypeScriptが推論できない型を人間が正しく補正してあげる」ことであり、人間がas
で指示した型をTypeScriptは疑いません。TypeScriptを人間の責任において人間が補助してあげることでより高い型安全性を得るという点で、ユーザー定義型ガードの“危険性”もas
の“危険性”も同様なのです。any
に関しては、存在自体が危険なのでまたちょっと事情が違うのですが。
ユーザー定義型ガードと外界のデータ
ユーザー定義型ガードの典型的な使い道のひとつは、外界から来たデータを利用可能にすることです。
外界から来たデータというのは、例えばHTTPリクエストで取得したデータやファイルから読み込んだデータなど、我々が書いたTypeScriptプログラムで中身を制御できないデータです。特にHTTPリクエストから得られたデータについては、フロントエンドにおいては「APIの型定義」といった文脈でしばしば議論されます。
原理的に、外界から来たデータはunknown
型にしかなり得ません。たとえ特定の形のデータがいつも送られるように実装することで特定の型に当てはまるデータが送られてくることを保証した気になったとしても、実際には色々な要因で変な形のデータが送られてくることがあり得ます。そんな“保証”は型安全性に比べると無に等しく、型システム的にはやはり外界のデータはunknown
とする以外の選択肢はありえません。
しかし、unknown
型というのはどんなデータなのか全く不明な型であり(どんなデータが送られてくるか全く不明なので当たり前ですが)、それゆえTypeScript環境ではunknown
型のデータは中身を取り出すことができず使い物になりません。そこで活躍するのがユーザー定義型ガードです。ユーザー定義型ガードを使うことで、unknown
型のデータをもっと役に立つ型に絞り込むことができます。すごく雑な例としては、次のようなコードが想定できます。
type ImportantData = {
hello: string;
pikachu: number;
};
function isImportantData(data: unknown): data is ImportantData {
// 省略
}
const data: unknown = await getDataFromApi();
if (isImportantData(data)) {
// ここでは data が ImportantData 型
} else {
throw new Error("は?")
}
ここからがこの記事の本題です。上の例にあるisImportantData
のようなユーザー定義型ガードをどのように実装するか? ということがこの記事の焦点になります。特に、ユーザー定義型ガードの引数data
の型をunknown
にするかそれともany
にするかという選択があります。
ユーザー定義型ガードの引数の型をどうするか
ユーザー定義型ガード(上の例でいうisImportantData
の引数の型の候補は2つあります。具体的にはunknown
とany
です。どんなデータでも受け取ることができるためにはunknown
を引数として受け入れられる必要があり、これより厳しい一切の型は今回は適しません。また、引数の型をany
とした場合も全ての型の引数を受け入れられます。
外から見た使い勝手という点では、引数の型がunknown
でもany
でもほとんど変わりません。したがって、ユーザー定義型ガードの中身がどうなるかを考えて、unknown
かany
かを選ぶことになります。そこで、両方の実装を見比べてみましょう。
// any版実装
function isImportantData(data: any): data is ImportantData {
if (data == null) {
return false;
}
return typeof data.hello === "string" && typeof data.pikachu === "number";
}
// unknown版実装
function isImportantData(data: unknown): data is ImportantData {
if (data == null) {
return false;
}
const d = data as Record<string, unknown>
return typeof d.hello === "string" && typeof d.pikachu === "number";
}
両者は非常に似ていますね。ランタイムの挙動は全く同じですが、unknown
版のほうは一度as
を挟んでいます。ご存知の通り、any
は危険な機能ですがas
もまた危険な機能です。元々の型がunknown
の場合そもそもその内部のhello
やpikachu
といったプロパティへのアクセスが許されませんから、何らかの危険な機能を用いてこの制限をバイパスすることは不可欠です。
実装の意図としては、どちらの版もまずdata
がnull
またはundefined
である可能性を排除しています。これらの値は「プロパティアクセスするとランタイムエラーが発生してしまう」という特徴を持つ値ですから、hello
やpikachu
プロパティにアクセスする前に事前にチェックして排除しなければいけません。
次にunknown
版では、ここでdata
の型をas
を用いてRecord<string, unknown>
に強制的に変更しています。これは、null
やundefined
を排除したことでこれらの値が「どんな名前でプロパティアクセスしてもエラーが発生しない値」であることが保証されているので、そのことを型で表現しています。存在しないプロパティにアクセスしてしまう可能性もありますが、JavaScriptではその場合はundefined
が得られ、エラーが発生してしまうわけではありません。unknown
はundefined
も含んでいますから、この場合もRecord<string, unknown>
型とは矛盾していません。こうすることでd.hello
といったプロパティアクセスが型システム上許されるようになったので、あとは必要なチェックを実装するだけです。
では結局引数はany
とunknown
のどちらが良いのでしょうか。結論としては、記述が簡潔である点ではany
が有利なものの、型安全性という面ではunknown
に軍配が上がります。その理由のひとつとして、as
の方が、危険性を使用した意図が明確になるという点が挙げられます。any
に対しては全ての操作が危険なので、コードを読む際にはany
が登場して以降は全ての行に対して神経質になる必要があります。一方で、as
の場合はas
が使われた瞬間にのみ集中すれば全体の安全性を理解できます。また、as
は変更後の型を明記しているため、上で説明しているような意図が明確になりやすいという利点もあります。
また、さらに良いやり方としては、危険な部分をさらに関数に抜き出すことが挙げられます。上の例の場合、「data as Record<string, unknown>
」というas
使用の根拠はそこから上3行にあります。ですから、ここを関数に抜き出すのが利口なやり方です。そうすると、次のようにas
をユーザー定義型ガードにリファクタリングすることができます。
function isNotNullish(data: unknown): data is Record<string, unknown> {
return data != null;
}
function isImportantData(data: unknown): data is ImportantData {
if (!isNotNullish(data)) {
return false;
}
return typeof data.hello === "string" && typeof data.pikachu === "number";
}
このように、as
はユーザー定義型ガードにリファクタリングすることができます。こうすることで、上で「意図」と呼んでいたものが「ユーザー定義型ガードの正しさ」というより明確な指標に変換されます。as
の場合は「このas
の根拠はどこか」ということを考えたりコメントに書いたりしなければいけませんが、ユーザー定義型ガードではas
の意図が関数という構造にまとめられるため、コードの読み手が理解する助けになります。
まとめ
自分でユーザー定義型ガードを書くのは危険なのでライブラリに頼るのはどうでしょうか。io-tsとかおすすめですよ。
- 他に
asserts 引数名 is 型
という形の型述語もあります。↩