「TypeScript不要論:型チェックは TypeScript や Flow じゃなくて JavaScript にやらせる。
」という記事を読んだところ、「バリデーションと型チェックは同じである」という可能性についての言及があってとても興味深いと思った。型システムとバリデーションについて思うところを少しメモしておきたい。
型、あるいは型チェックは何を保証するのか
数学的に厳密な型システムの議論は置いておくとして、実務レベルで型システムが提供する主たる効能の一つは「ある型の値は特定の性質を持っていることが保証される」という点であろうと考える。例えば、int32
型の値であれば、概ね-2,147,483,648
から2,147,483,647
の数値が格納されていることが保証されるし、ある値が[]byte
であれば8ビットに収まる数値がいくつか入っている配列であるということが保証される。さらに、性質というのは値それ自体に対する制約(int32
とint64
では格納できる値の範囲が違う)以外にも、その値に適用することが可能な操作も含まれるであろう。先の例を用いれば、[]byte
型の値には.push()
操作を行うことが可能かもしれないが、int32
にこの操作を行うことはできないかもしれない。
ところで、int32
という型は実務でもよく使う型の1つであろうが、これはいかにも計算機や数学の仕組みに基づく型である。ビジネス的な側面から考えた場合、より大切なのは現実世界での性質であることが多い。例えば、ある人間の年齢はもちろんint32
が持つ範囲に収まるであろうが、現実的にはせいぜい0
から、余裕をみても1000
の範囲に収まる値であろう。むしろ、生まれる前の人間を考える必要がないのであれば、-1
を年齢として受け付けるのはおかしいとも言える。この場合、実務ではage
型を0
から1000
の範囲の数値として定義する事ができる。逆に言えば、age
型の値であれば0
から1000
の数であることが保証される。ビジネス面から見た場合の性質を型に落としこむと、当然年齢と距離は同じ数値でも別に扱わなければいけないので、distance
型やweight
型なども必要に応じてそzれぞれ定義することになるであろう。同じ「数値」であっても型が違えば性質が異なるのであるから、age
型の値とweight
型の値を同様に扱うことはできない。もちろんこれらの数値を加算するなどもってのほかである。
このようなビジネスロジックのための型定義は、int32
のようなプリミティブな型以外にも、構造体あるいはクラスとして現れることも多い。よくある例を出せばUser
クラスなどであろう。User
を型に持つ値は、例えば名前を.Name
に、年齢を.Age
に持つであろう。こういった値の組み合わせとその名前も、型の持つ性質の一つであると言える。
これらの値の性質は、静的型付け言語であればプログラム全体を通して常に一貫している。ソースコードのファイルによって同じ型の意味が異なることは普通はない。そのため実際にプログラムを実行しなくても「静的に」検証することが可能である。言い換えれば、ある型の値を受け取った場合、その型の持つ性質は常に保証されているので再度検証する必要は無いということである。age
型の値を受け取ったのであれば値の範囲は絶対に0
から200
であるので、それを前提としてプログラムを書くことができるし、User
クラスの値を受け取ったのであれば、.Name
にアクセスできることは常に保証されているので、そのフィールドが定義されているかどうかを改めて検証する必要は存在しないのである。
キャストとバリデーション
ところで、実務に置いては値の型を変換する処理というのは頻繁に行われる。一般に外部世界との入出力には制約をかけづらいものである。例えばウェブアプリケーションであれば、外部からやってくる入力値は概ねstring
型ばかりであるし(例えばHTTPヘッダーの値など)、もっと極端に言えばすべての入力はbyte
の配列と言えるかもしれない。byte
の配列はある意味ではすべての値の最大公約型であるから、とにかくなんでも入れられる性質を持っていると言える。しかし、前述の通りビジネス的な観点から言えばバイト列というのは意味を成さないことが多いので、必要に応じてage
型に変換したり、User
クラスのインスタンスを作ってその属性値に設定したりする。こういった変換処理は、非常に雑に言ってしまえばすべて「キャスト」処理である。
キャスト処理を行う際に必要なのが値のバリデーションである。入力においては一般に変換元の型が変換先の型よりも緩い制約を持っているので、キャストを行うためには変換先の型が持つ性質を満たしているかを確認する必要がある。もしある入力値が変換先の型の制約を満たさない場合、そのリクエストはエラーになるであろう。あるユーザーが自身の年齢を5000才と入力すれば、それは当然age
型に収まらないので、バリデーションエラーになる。
重要なのは、バリデーションとキャスト処理は、責任(あるいは関心)の境界となる位置で一回だけ行えば良いという点である。例えば、ウェブアプリケーションであれば汎用的なフレームワークと、自分自身のビジネスロジックの境界で型をキャストすることになるであろう(MVC型のフレームワークであれば、Controllerのイベントハンドラで変換が行われるであろう)。HTTPリクエストを受け取った時点で目標の型に変換してさえすれば、あとは型システムが全体の一貫性を保証してくれる。バリデーション処理は言語が提供するキャスト処理が暗黙的に行うかもしれないし、あるいは変換先がクラスであればコンストラクタがバリデータとしての役割を果たすこともあるであろう。
安全でないコードと動的チェック
現実世界において、適切な型をそれぞれ定義して値の変換を行うのは手間のかかる作業であり、常に正しく行われているとは言い難い部分がある。言及先のコメントで例として挙げられている先頭一文字はアルファベット、その後4桁の数値3文字
のIDはその良い例で、本来であれば
ID
型(クラス)を定義して値をキャストすべきところを、バリデーション後もstring
型のまま扱ってしまっているようなケースが良くある。この場合、一度バリデーションを行ったとしても、その事実は型情報として記録されない。こうなると、IDを扱う場合は常に値のバリデーションを行うか、あるいは厳密な安全性を犠牲にするかの二択になってしまう。
どこまで型システムを用いることができるか、は言語の持つ型システムに依存する。たとえば、先程から例として使用しているage
型を例にすると、Adaであればtype Age is range 0 .. 1000
で簡単に定義できるが、C言語の場合こういった範囲制約付きの数値型を定義することは難しい。typedef
でint
の別名を設定することはもちろん可能であるから異なる型の数値を混同してコードを書いてしまうことは避けられるが、age
型の値が本当に0
から1000
に収まっていることは保証されない。型変換の際にバリデーションを行うことは出来るとはいえ、真に安全なコードを書く場合は毎回値の範囲を確認する必要があるかもしれない。このあたりはコーディング規約とAssertを含めた動的チェック、あるいは形式手法を用いるなど、現実的な折り合いをつけることになるであろう。
現実的な運用
極端な話を言えば、型チェックと各所でのバリデーションを同等と見なすことは可能なのかもしれない。どちらも性質あるいは制約が満たされているかどうかを確認しているだけと言えるからだ。
しかし、言語システムによって静的に安全性が保証されるというのはとても良いもので、様々なメリットを享受することができる。人によって意見は異なるであろうが、個人的には一番大きいのは、テストコードが大幅に削減できるにもかかわらず、実行時エラーに怯える必要性が低減されることであろう。動的言語では型に対する変更がどこまで影響を与えるのかを見極めることが難しいため、変更に対する継続的なストレスが発生しやすい(保守性が低いともいえる)。このストレスを除去しようとすると、変数の安全性を確認するためだけに大量のテストコードを記述する必要がある。それでもなお実行時エラーの恐怖は拭えないため、特に大規模だったり安定性を求められる開発においては辛い部分がある。
一方で、あまりにも厳密に型システムを運用しようとすると、それはそれで弊害もある。例えば、Adaの型システムは非常に強力であることが有名ではあるものの、安全性のためにその機能をフル活用しようとすると作業量がどんどんと増加していく。あるいは、「境界で変換」と言っても現実的にはそこまで単純な話ではなくて、様々な入出力を考えると厳密な型運用は難しいことも多い。DBアクセスにORマッパーを使用していたり、Protocol Bufferからコードを生成していたりすると、型の運用に制限がかかることは往々にしてあるのだ。真にミッションクリティカルなシステムであれば型を(あるいはもっと厳密な手法を)作り込むに見合うだけの安全性を求められるかもしれないが、普通のプログラムであれば、エラーが起こると辛い部分をケアする形で運用するのが現実的であろう。メールアドレスをemail
型の代わりにstring
型の変数で処理してしまっても、人が死んだり数百億円のロケットが墜落してしまうことはほとんどない。こういった値は必要なときに動的バリデーションをしてしまえばそれで十分であろう。普通のプログラムであればage
型なんて誰も定義しないし、結局の所、最終的にはバランスの問題なのである。
では、JavaScriptでAssertによる手作業の動的チェックをどんどん行うべきかというと、個人的には、労力に対して得られる成果が少ないと考えている。特にネイティブ型のチェックを人力でやるぐらいであれば、素直にTypeScriptを使ったほうが良いように思う。このレベルの型チェック(あるいはバリデーション)をコードで記述してしまうとテストが煩雑になる上に、結局は人間のミスが防げずに実行時エラーに見舞われることになる。TypeScriptの型システムは一般的な用途には必要十分に思われるので、可能であればそれに頼ってしまうほうが、妥当な労力で安全なコードを書くことが出来るであろう。