uhyo/blog

ユーザー定義型ガード、asで書くかanyで書くか

2021年4月9日 公開

TypeScriptでユーザー定義型ガードを定義する場合、引数をany型にして中を自由に書く方法と引数をunknown型にして中でasを駆使して書く方法があります。この記事では両者を比較考察します。結論としては、unknownasを使うのが型安全性の面からおすすめです。また、うまく関数を分割することで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型にはnullundefinedの可能性もあるのでv.toString()は不可能ですが、if文でチェックすることにより型が絞られてv.toString()が可能になります。

isStringOrNumberの返り値の型述語は、「この関数の中では型の絞り込みのためのチェックが行われている」ということをTypeScriptコンパイラに理解してもらうために必要です。関数を型ガードとして扱う(=関数呼び出しをif文の条件部で使うことで型の絞り込みを行う)ためには型述語が不可欠です。

ユーザー定義型ガードを利用するにあたって非常に注意しなければいけないのは、型述語が正しいことはチェックされないということです。つまり、型述語にvalue is string | numberと書いているのは完全にそれを書いた人(プログラマ)による自己申告であり、関数の実際の実装がそれと食い違っていてもお咎め無しだということです。例えば、次のような実装でもコンパイルエラーは起きません。

function isStringOrNumber(value: unknown): value is string | number {
  // 型述語と食い違う実装!
  return value === null;
}

このように、型述語に嘘っぱちを書いたとしてもTypeScriptコンパイラは何の疑いもなく信じてしまいます。この意味で、ユーザー定義型ガードはanyasと同じく危険な機能です。

とはいえ、ユーザー定義型ガードが持つ危険性とは裏腹に、ユーザー定義型ガードという機能をわざわざ使う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つあります。具体的にはunknownanyです。どんなデータでも受け取ることができるためにはunknownを引数として受け入れられる必要があり、これより厳しい一切の型は今回は適しません。また、引数の型をanyとした場合も全ての型の引数を受け入れられます。

外から見た使い勝手という点では、引数の型がunknownでもanyでもほとんど変わりません。したがって、ユーザー定義型ガードの中身がどうなるかを考えて、unknownanyかを選ぶことになります。そこで、両方の実装を見比べてみましょう。

// 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の場合そもそもその内部のhellopikachuといったプロパティへのアクセスが許されませんから、何らかの危険な機能を用いてこの制限をバイパスすることは不可欠です。

実装の意図としては、どちらの版もまずdatanullまたはundefinedである可能性を排除しています。これらの値は「プロパティアクセスするとランタイムエラーが発生してしまう」という特徴を持つ値ですから、hellopikachuプロパティにアクセスする前に事前にチェックして排除しなければいけません。

次にunknown版では、ここでdataの型をasを用いてRecord<string, unknown>に強制的に変更しています。これは、nullundefinedを排除したことでこれらの値が「どんな名前でプロパティアクセスしてもエラーが発生しない値」であることが保証されているので、そのことを型で表現しています。存在しないプロパティにアクセスしてしまう可能性もありますが、JavaScriptではその場合はundefinedが得られ、エラーが発生してしまうわけではありません。unknownundefinedも含んでいますから、この場合もRecord<string, unknown>型とは矛盾していません。こうすることでd.helloといったプロパティアクセスが型システム上許されるようになったので、あとは必要なチェックを実装するだけです。

では結局引数はanyunknownのどちらが良いのでしょうか。結論としては、記述が簡潔である点では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とかおすすめですよ。


  1. 他にasserts 引数名 is 型という形の型述語もあります。