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
オブジェクトに失敗の可能性が含まれるようになってしまいました。