Q. なぜJavaScriptのDateコンストラクタは例外を投げないのか?
A. NaNがあるから
DateはJavaScriptで日時を扱うためのAPIで、JavaScriptの当初から存在します。
Dateオブジェクトは主にDateコンストラクタを用いて作られます。Dateコンストラクタにはいろいろな機能があり、new Date()のように引数なしで呼び出すと現在時刻を取得できるほか、new Date("2020-04-24T00:00+09:00")のように文字列から日時に変換したり、new Date(1587654000000)のように数値(UNIX時間)を日時に変換したりすることができます。
一般に、データの変換作業には失敗が付き物です。しかし、new Dateは決して失敗しません1。例えば、new Date("foobar")のように明らかに日時を表していない文字列からDateオブジェクトを作ろうとしても、ちゃんとDateオブジェクトが得られます。これは一体どうしてなのでしょうか。実は、その裏には**NaN**が関わっています。
Dateは時刻を表すオブジェクトですが、その内部表現は数値(UNIX時間)です。これは、1970年1月1日を0として、そこから経過したミリ秒数によって日時を表す表現方法です。そして、数値ということはNaNの可能性があるということなのです。例えばnew Date("foobar")のようなものは、内部の数値がNaNであるDateオブジェクトが結果として得られます。このようなDateオブジェクトは、数値に変換すればNaNとなり、文字列に変換すれば"Invalid Date"となります。
つまるところ、どんな値を与えられてもnew Dateが失敗しないのは、不正な値が与えられたらInvalid Dateを表すDateオブジェクトにすれば良いからなのです。これは、Numberで数値に変換する処理が失敗しないのと本質的に同じです。Numberの場合も、数値に変換できないものが与えられたらNaNにすることで、常に数値への変換を成功したように見せかける(実際にはNaNという結果が返ってくるが)ことができます。
最近のAPIは例外を投げる
データ変換時に例外が発生しないのがNumberやDateの特徴でしたが、最近のAPIはきちんと例外を投げる傾向にあります。例えばBigIntにはNaNのような概念はなく、BigIntは必ず何らかの整数を表しますから、整数に変換できない値からBigIntを作ろうとすると、BigIntの値を生成するのが不可能であるためこのように例外が発生します。
また、新しい日時処理APIとして期待されているTemporalにおいても、不正な日時の場合はコンストラクタで例外が発生する仕様となっています。これらのことから、データが不正なときはちゃんと例外を投げるのが今時のトレンドと言えるでしょう。
他の例としては、ES2015で追加されたシンボルを文字列に変換しようとするとエラーになるという例が挙げられます。
const s = Symbol();
console.log(`Hello, ${s}!`);
これはsを文字列に変換しようとした際にCannot convert a Symbol value to a stringというエラーが発生します(Google Chromeの場合)。文字列の変換がエラーになるというのはこれまでには無かった挙動です2。この挙動の背景には、シンボルはプロパティ名として使用できる文字列とは別個の概念であるため、文字列に変換できるのは混乱の元であり避けるべきであるという考えが見てとれます。ただし、String(s)のようにStringを用いて文字列に変換する場合はシンボルに対する専用の処理が入るためエラーを出さずに文字列に変換できます。
JavaScript本体以外でも、例えばWHATWG URLもnew URL(url)としたときにurlをURLとしてパースできなければ例外が発生します。
大域脱出と型の意味
例外の発生(throw文や処理系による例外)は大域脱出を伴います。大域脱出とは、その場でプログラムの実行を中断して外側のスコープへと制御を移すことを指します。JavaScriptの場合、例外の発生により大域脱出した実行コンテキストは、try文に到達するまたはasync関数の境界に到達するまで外へ脱出し続けます。
ポイントは、例えば関数から脱出した場合、その関数の返り値を読む者はいないということです。
// BigIntがエラーを発生させるため、変数valに何かが代入されることはない
const val = BigInt("foobar");
逆に言えば、BigIntの呼び出しが成功した(例外を発生させなかった)ならば、その返り値は必ず“正しい”BigInt値であるということです。
そもそもBigIntの呼び出しを成功させなければBigInt値が手に入らないのですから、BigInt値として存在しているならばそれは必ず正しいBigIntであるということになります。これが、NaNという微妙な値を擁するNumberとの決定的な違いです。
すなわち、NumberとBigIntではその型に込められた意味が異なります。Number型というのは「NaNという不正な値かもしれない値」である一方、BigInt型というのは「必ず何らかの整数である値」なのです。Numberは「失敗しない」という利点を得た代わりに値自体に失敗が埋め込まれている一方、BigIntは失敗する代わりに値自体は失敗という概念を持ちません。数値を表すデータという観点からは、後者の方がより優れた設計であることは言うまでもありません3。
他の例でも、例えばnew URL(url)は例外を投げる可能性がありますが、これによってnew URLが成功した場合に得られるURLオブジェクトは「正しいURLを表すオブジェクト」であることが担保されています。これにより、我々は「URLオブジェクトが表すURLが不正なURLである」といった可能性を除外することができます。
同様の考え方は、データ型の設計だけでなく「失敗する可能性がある関数」にも適用できます。JavaScriptでは失敗を表す方法は大きく2つあります。一つはnullやundefinedを返すとか、Option的なオブジェクトを自分で用意するとかにより「失敗を表す値」を返すという方法です。もう一つは例外を投げることです。非同期処理(結果がPromiseの場合)は前者に相当しますが、async/await構文により後者のような扱い方もできます。
「無いかもしれないデータを取得する関数」を題材にTypeScriptで考えてみましょう。前者の設計の場合、この関数は例えばstring | undefined型の返り値を持つでしょう。
function getDataOrUndefined(): string | undefined {
if (hasData()) {
return getData();
} else {
return undefined;
}
}
一方、後者の設計の場合は返り値がstring型となります。
function getDataOrThrow(): string {
if (hasData()) {
return getData();
} else {
throw new Error("無いよ");
}
}
後者の設計では、失敗に関する情報が返り値から消えました。なぜなら、失敗の場合はgetDataOrThrowは返り値を返すことが無いからです。このように、大域脱出はインターフェースをシンプルにする効果を持ちます。もちろん、その代わりに大域脱出してしまうことによる辛さはありますが。実際、ReactのConcurrent Modeは、大域脱出を活用してインターフェースをシンプルにするという方向に舵を切っています。
データの意味
最初のDateやNumberに話を戻すと、これらは失敗しても例外を投げませんが、それにも関わらずnew Date()の返り値はDateでありDate | undefinedなどではありません。これはすでに説明した通り、わざわざ失敗の可能性を表す値を用意しなくてもDate型の中に失敗を表す値(Invalid Date)が埋め込まれているからなのです。これらは特殊な例であり、この設計によりDateやNumberのデータとしての信頼性が低下してしまっています。
なお、この記事ではDateなどと対比して例外による失敗の表現に言及しましたが、実はDateやNumberなどはJavaScriptにtry-catch文が導入される(ECMAScript 3)より前から存在しました。これがない時代においてはランタイムエラーは復帰不可能なものですから、コンストラクタでランタイムエラーを発生させた場合はその失敗を検知する手段がありません。
歴史的経緯という観点からは、これがDateコンストラクタが例外を発生させない直接的な理由と言えるかもしれません。ただ、ランタイムエラーという概念自体は当然最初から存在していたので、別に妥当な日時表現かどうかチェックする関数を用意しておいて、変換できないデータがnew Dateに与えられたらランタイムエラーというデザインも可能だったでしょう。これは当初のJavaScript自体の方向性にも関係していますが、Dateというデータ型に失敗という状態の表現を含むという意思決定はどこかで行われたのだと考えられます。
まとめ
データ変換やコンストラクタの失敗時に例外を投げるのは、失敗を表す値を返さないという設計上の選択によるものです。これにより、BigInt呼び出しの結果得られたBigInt値やnew URL呼び出しの結果得られたURLオブジェクトは常に“正しい”データであることが保証されます。コンストラクタが例外を投げるか投げないかということは、そのデータ型の意味にまで影響を与えるのです。
Dateはやや特殊な例であり、new Dateの結果は常にDateオブジェクトであり、かつ本来失敗するはずの処理なのに例外を投げないという選択をした結果、Dateオブジェクトに失敗の可能性が含まれるようになってしまいました。