ECMAScript Temporalは、ECMAScript における新しい日時 API です。ECMAScript は世界中のユーザーに向けて設計されているため、高品質な国際化サポートを設計目標としています。
このドキュメントでは、Temporal がなぜ / どのようにしてカレンダーシステムをサポートするのか、そして開発者が遭遇するかもしれないカレンダーに関する問題と、それを回避する方法について説明します。前半の節では、Temporal でカレンダーシステムをサポートするに至った経緯を説明します。後半の節では、過去に検討されたカレンダーシステムの設計について説明し、なぜそれらではなく現在のアプローチが選択されたのかを説明します。
カレンダーシステムは、「特定の時刻」と「年、月、日といった人間が読みやすい数値」を対応付ける方法を提供します。
世界中で多く使用されているのはグレゴリオ暦です。ここでは太陽暦、月、日が使われ、これらの値は季節に同期します。しかし世界中には、その他のカレンダーシステムを存在しています。有名なものでは、ユダヤ暦、ヒジュラ暦、仏滅紀元、ヒンドゥー暦、中国暦、和暦、そしてエチオピア暦などです。
今日はグレゴリオ暦で"March 4, 2021"ですが、その他のカレンダーでは次のように表されます。
グレゴリオ暦以外のカレンダーシステムは、いくつかの国、特にグレゴリオ暦が支配的な西洋諸国との文化的、経済的関係が弱い国において、日常生活ならびに宗教的、文化的、学術的用途で使用されています。例えば、日本、中国、イラン、アフガニスタン、サウジアラビア、台湾を含むいくつかの国では、グレゴリオ暦以外のカレンダーが、少なくとも一部の政府公式の手続きで使用されています。これらの公式な用途以外でも、世界の人口の大多数は、宗教的または文化的な休日の日付を決定するためにグレゴリオ暦以外のカレンダーシステムを使用する国に住んでいます。
カレンダーシステムは地域化(l10n:localization)では重要な役割を果たします。これらは、タイムスタンプや日時を人間にわかりやすい文字列へ変換することを可能にします。
しかし、カレンダーシステムはアプリケーションのビジネスロジックについても重要な役割を果たします。人間が関わるアプリケーションでは、日時の演算や操作を必要とする場合が多く、そのような処理にはカレンダーシステムが必要です。カレンダーに依存する処理の例は次のとおりです:
言い換えると、1 日(太陽日)よりも大きな単位をどのように決めるかは、その地域の文化によります。日付計算を最も普遍的に解決する方法は、月と年の概念を排除することですが、そのような日付 API は多くの場合不十分です。
カレンダーシステムを Temporal へ統合しようとすると新たな問題へ対処しなければならなくなるのは明らかです。例えば"Unexpected Calendar Problem"(以下を参照)が考えられます。つまり、あるカレンダーを想定して書かれたコードで、別のカレンダーの日時を処理しようとすると様々な問題を引き起こす可能性があります。
しかし、Temporal にカレンダーシステムを統合することによる利点は、これらの問題を上回る可能性があり、これはそれほど自明では無いかもしれません。これらの利点を理解する 1 つの方法は、カレンダーシステムに対して異なるアプローチを行っている Java や.NET と、ECMAScript エコシステムのニーズの違いを明確にすることです。いくつかの違いを以下に示します:
現在の設計を選択することことは容易ではありませんでした。カレンダーシステムを Temporal に統合することは、ISO カレンダーのみを意図してコードを作成するよりも多くのバグを引き起こします。しかし、経済成長や人口増加、テクノロジーの普及に関する世界的な傾向を考慮すれば、現在の設計を採用することは、ECMAScript コミュニティの長期的な成功に繋がる合理的なトレードオフであると私達は考えています。
ECMAScript には国際化(i18n)をサポートする主要な機能があります。Intl ファーストな設計の 2 つの原則は、「1.言語または地域に固有の操作はデータ駆動であること」、「2.ユーザーの環境が、API へ明示的に入力されること」です。これらの原則は、世界中の幅広いエンドユーザーを対象にしたアプリケーションの開発者が、API を適切に使用するために役立ちます。
Temporal では、これらの原則を「カレンダー固有のロジックを抽象化すること」と「どのカレンダーを使用するかをオブジェクトコンストラクタで選択できるようにすること」によって守っています。その結果、グレゴリオ暦のみを使用する地域と、他のカレンダーを使用する地域のどちらでも利用可能な API を作成できます。
以下の節ではカレンダーシステムが Temporal のコード内にどのように現れるかを示します。より詳しい情報はドキュメントを参照してください。
Temporal では、日付のフィールドを持つすべてのオブジェクトは、カレンダーのフィールドも持ちます。例えば、以下の 2 つの実行結果は、どちらもユダヤ暦の「23 Adar I 5779」を表すTemporal.PlainDate
インスタンスとなります。
// bag of fieldsでユダヤ暦の日付オブジェクトを作成する
Temporal.PlainDate.from({
year: 5779,
monthCode: 'M05L',
day: 23,
calendar: 'hebrew'
});
// ISOカレンダーの日付オブジェクトを作成し、ユダヤ暦に変換する
Temporal.PlainDate.from('2019-02-28').withCalendar('hebrew');
内部的には、日付は常に ISO カレンダーによって表されています。つまり、もしインスタンスからカレンダーの情報が失われても、それが示す日付は変化しません。これは、Temporal の内部スロットと文字列表現のどちらにも言えます。したがって、以下のどちらの結果も同じく「23 Adar I 5779」のインスタンスとなります:
// ISO文字列をパースする(IETF RFCはまだ提案中)
Temporal.PlainDate.from('2019-02-28[u-ca=hebrew]');
// コンストラクタによって内部データモデルの値を指定する
new Temporal.PlainDate(2019, 2, 28, 'hebrew');
Temporal インスタンスに対する操作は、すべてカレンダーシステムのコンテキストで行われます。例:
date = Temporal.PlainDate.from('2019-02-28[u-ca=hebrew]');
date.with({ day: 1 }); // => 2019-02-06[u-ca=hebrew]
date.with({ day: 1 }).toLocaleString('en-US', { calendar: 'hebrew' }); // => '1 Adar I 5779'
date.year; // => 5779
date.monthCode; // => 'M05L'
date.month; // => 6
date.day; // => 23
date.inLeapYear; // => true
date.calendar.id; // => 'hebrew'
inFourMonths = date.add({ months: 4 });
inFourMonths.toLocaleString('en-US', { calendar: 'hebrew' }); // => '23 Sivan 5779'
inFourMonths.withCalendar('iso8601'); // => 2019-06-26
date.until(inFourMonths, { largestUnit: 'month' }); // => P4M
定義より、年、月、日から bag of fields(訳注:コンストラクタに与える日付の情報を含んだオブジェクトのこと)を生成するにはカレンダーシステムを指定する必要があります。
そのような際にカレンダーシステムが指定されていない場合、Temporal は ISO カレンダーが省略されているものとみなします。これは、Temporal がカレンダーの省略を想定する唯一の場所です。
Temporal.PlainDate.from({ year: 2019, month: 2, day: 28 });
次の Temporal タイプはすべて内部にカレンダーを持っています:Temporal.ZonedDateTime
、Temporal.PlainDateTime
、Temporal.PlainDate
、Temporal.PlainYearMonth
、Temporal.PlainMonthDay
Temporal の多くの日付操作においてカレンダーを意識することは、世界中の多くのアプリケーション利用者にとって重要です。しかし、これは新しい問題を引き起こします:もし、コードが特定のカレンダーを想定して作られている場合、予期していないカレンダーの日付を扱おうとするとバグが発生します。このバグは、悪意を持って利用されてしまうかもしれません。
この節では、私達が「予期しないカレンダーの問題(Unexpected Calendar Problem)」と呼んでいるものについて説明し、それをユーザーランドのコードで軽減する方法も説明します。
以下はこの問題の典型的な例です:一年の最後の日かどうかを判定する関数があります。これは、1 年が 12 ヶ月で、1 年の最後の月の長さが 31 日であることを想定しています。
function isLastDayOfYear(date) {
// Note: これは"良くない"例です!
return date.month === 12 && date.day === 31;
}
// ISOカレンダーでは正常に動作します
isLastDayOfYear(Temporal.PlainDate.from('2019-12-31')); // => true
isLastDayOfYear(Temporal.PlainDate.from('2020-01-01')); // => false
// ISO以外のカレンダーでは動作しません
hebrewNewYearsEve = Temporal.PlainDate.from({
year: 5780,
monthCode: 'M12',
day: 29,
calendar: 'hebrew'
});
isLastDayOfYear(hebrewNewYearsEve); // => false
// (trueとなるべき)
この問題を回避する方法は 2 つあります。ベストプラクティスは、どのようなカレンダーにも対応できる、カレンダーセーフなコードを書くことです。もう 1 つの方法は、カレンダーのバリデーションや変換処理を実装することです。これにより、対応しているカレンダーの日付のみを処理できるようになります。
予期しないカレンダーの問題を回避する最も良い方法は、ビルトインのすべてのカレンダーに対応する、カレンダーセーフなコードを書くことです。例えば上記のコードに関しては、ISO カレンダーの定数をハードコーディングする代わりにmonthsInYear
やdaysInMonth
を用いることで、簡単にカレンダーセーフなコードに書き換えられます。開発者は、カレンダーセーフな Temporal コードを書くためのベストプラクティスにしたがうことで、すべてのビルトインカレンダーで動作するコードを書くことができます。
ドキュメントに記載がある以外にも、Temporal API そのものが、カレンダーセーフなコードを書きやすいように既に設計されています。例えば:
year
、month
、day
、monthCode
といった、日付に関する共通したフィールドを持っています。これらのフィールドに関する操作は、すべてのビルトインカレンダーで共通です。month
プロパティは、一年を通して連続する(ギャップのない)月のインデックスを表し、これは ISO カレンダーの月の挙動と一致します。太陰太陽暦では、年ごとに月の構造が異なりますが、month
プロパティは常に 1 年を通してのインデックスです。年に依存しない月の種類を表す情報は、monthCode
文字列フィールドに分離されています。year
プロパティは各カレンダーにおける「デフォルトの時代」からの経過を示す符号付きの値です。これは ISO カレンダーの挙動と一致し、例えば「紀元前では年を逆方向に数える」というようなバグを防ぎます。各カレンダーにおけるデフォルトでない時代(訳注:和暦では平成、令和など)に対する年数などの情報はeraYear
とera
フィールドに分離されています。monthsInYear
、daysInMonth
、inLeapYear
。これらを用いることで、カレンダー固有の定数(例:1 年間は"12"ヶ月)の使用を回避できます。Temporal.now.zonedDateTimeISO()
。Temporal を用いることでカレンダーセーフなコードを簡単に記述できますが、それでも開発者による作業が必要です。一部の開発者はこの作業を行いません。これは、ISO 以外のカレンダーの存在を知らない、または理解していないことが原因である場合があります。または、ISO カレンダーのみを使用するアプリケーションを書いている場合、わざわざカレンダーセーフのベストプラクティスに従わないかもしれません。あるいは、自身が書くコードではベストプラクティスが守られているが、それが依存するコードではそうでない場合があります。通常、ECMAScript アプリケーションにはたくさんの依存関係があり、時間と労力の関係上、それらと日時データを相互運用する必要があるかもしれません。
このような場合、開発者は入力される日時データについて、「意図したカレンダーシステムであることをバリデートする」か、「カレンダーシステムを意図したものに変更する」必要があります。バリデーションと変更のどちらが望ましいかは、ユースケースによります。
カレンダーシステムを変更する場合:
date = date.withCalendar('iso8601');
カレンダーシステムをバリデートする場合:
if (date.calendar.id !== 'iso8601') throw new Error('invalid calendar');
ここで言う「入力」とは外部データに限らないことに注意してください。例えば、依存している日付ライブラリから返されるデータなども「入力」です。ライブラリや入力ソースが特定のカレンダーシステムをサポートしていると明示しない限り、それらからのデータ(日時オブジェクトやそれに変換される文字列)はすべて「外部」であり、カレンダーシステムのバリデーションや変更のための処理が必要です。それらのデータをそのまま使用するのはカレンダーセーフではありません。
カレンダーシステムを Temporal に統合することは、API 設計におけるもっとも挑戦的な部分でした。以下では、私達が現在の設計の前に検討していたカレンダー API の代替案を簡単に説明します。(現在の設計も含めて)検討された設計はどれも理想的なものではないことに注意してください。これらの設計は、場合によっては回避策が必要となる既知の欠陥を持っています。以下の選択肢と比較して、現状の設計が全体を通して最善の選択であると私達は信じています。
現状の Temporal の設計に対して、カレンダーの情報を Temporal のインスタンスから分離して管理するという代替案があります。これにより、「予期しないカレンダーの問題」を回避できるという利点がありますが、API の利用者への負担が大きいという問題があります:
カレンダーに固有のフィールド(month
、day
、year
など)やプロパティ(daysInMonth
、inLeapYear
)を利用する際に、その都度パラメタが必要になります。つまり、これらの値を参照するのではなく、date.calendarMonth('chinese')
のように Getter を介して値を取得することになります。
多くのメソッド(add
、subtract
、round
、until
、since
、with
、そしておそらくtoPlainMonthDay
、compare
、equals
、またその他いくつかのメソッド)を呼び出すたびにカレンダーを明示しなければなりません。これは、年、月、日などのカレンダーに固有なフィールドを処理するために必要です。メソッドの呼び出しは、たとえば次のようになります:date.add({months: 2}, {calendar: 'chinese'})
複数の操作をチェインする(例:.add({months: 2}).with({day: 1})
)際にもメソッドごとにカレンダーを指定する必要があります。通常、連鎖するすべての操作では同じカレンダーが使用されます。それをメソッドの呼び出しごとに指定するのは開発者の負担になり、バグの原因にもなります。
Temporal.PlainMonthDay
を扱うのが難しくなります。なぜなら、誕生日や祝日のような月/日の値は本質的にカレンダー固有のものであり、対応する ISO の記法が無いため推論することも難しくなります。特に、ユダヤ暦や中国歴のような太陰太陽暦では年ごとに月の構成が変わります。(現状の Temporal ではmonthCode
という文字列によって年に固有の月を表せます。)例えば、「ユダヤ暦の 12 Adar I」という誕生日は、現状では次のようにモデル化できます。
Temporal.PlainMonthDay.from({ monthCode: 'M05L', day: 12, calendar: 'hebrew' });
インスタンスがカレンダーを持たない場合、この誕生日をどのようにモデル化すればいいのかわかりません。
日付を返すコードは、単一の Temporal インスタンスを返せません。代わりに、複合オブジェクトか、シリアライズされた文字列を返す必要があります。例えば、ユーザーの操作の結果日付データを返す DatePicker は、Temporal.PlainDate
を返すだけでは不十分であり、代わりにタプルや{date, calendar}
といった複合オブジェクトを返す必要があります。このような複合オブジェクトは、カレンダーが必要なすべての Temporal のコードを複雑にしてしまいます。
私達は、この代替案によって生じる負担が、予期しないカレンダーの問題を回避するためのものに比べてとても大きいと考えています。
予期しないカレンダーの問題に対して脆弱になることなく、カレンダーを Temporal のデータモデルに統合することが可能です。しかし、これによって Temporal API の数が増加してしまいます。この案を適用した場合の一例を以下に示します。
.isoMonth
と.calendarMonth
の 2 つのフィールドを提供します(または、カレンダーの概念に慣れていない開発者のために.isoMonth
と.calendarMonth
を提供する?)calendar*
のメソッドのうち、どれをサポートしてどのような型を返すかは、各カレンダーに完全に依存します。すべてのカレンダーはcalendarDay
、calendarMonth
、calendarMonthCode
、calendarYear
を提供しますが、calendarEra
やcalendarEraYear
を提供するかはカレンダーしだいです。date.with({calendarYear: 5780, isoMonth: 12})
は例外を投げる必要があります。しかも、 DX の観点から、これは ESLint や TS で強制されるべきです。calendar*
へのアクセスはエラーとなります。dayOfYear
やinLeapYear
といった、フィールドではないプロパティも複製されます。ここでcalendar
接頭辞が付かないプロパティは、単に ISO カレンダーでの結果を返すものになります。Temporal.Duration
にcalendar
フィールドを追加します。days
、weeks
、months
、years
calendarDays
, calendarWeeks
, calendarMonths
, and calendarYears
.'P2D[u-ca=chinese]'
。なお、接尾辞がついていないものは ISO カレンダーを用いているとみなします。until
とsince
が曖昧なため、出力について ISO カレンダーの Duration なのか、ISO 以外のカレンダーの Duration なのかを指定できるようにする必要があります。
これには次の方法が考えられます:isoUntil
とcalendarUntil
、untilISO
とuntilCalendar
foo.until(bar).isoDuration
またはfoo.until(bar).calendarDuration
{ resultFields: 'iso' | 'calendar' }
。ただし、このオプションがリテラルとして指定されていない場合に(訳注:例えばこのオプションの値が外部から入力されるものである場合に)、TS がメソッドの実行結果の型をうまく処理できるかは疑問です。withCalendar
メソッドはありません。代わりに ISO フィールドやカレンダーフィールドがありますが、どちらか一方しかありません。私達はこれらの代替案を検討した結果、この案によってもたらされる複雑さにはあまり価値がないと判断しました。特に、フィールドやプロパティを 2 倍に増やすというアプローチには、余計なバグが増えたり、入力のバリデーションなどが煩雑になるという懸念があります。
カレンダーシステムを PlainDate の内部スロットに格納する代わりに、PlainDate を ISO カレンダーのみをサポートするように保ち、PlainHebrewDate や PlainChineseDate といったカレンダー固有のサブクラスを作成する案が検討されました。より詳しくはサブクラスに関するディスカッションを参照してください。
私達は、サブクラスのアプローチを行わないことにしました。なぜなら:
私達は最終的に、このアプローチが他のアプローチに比べて高コストであり、かつ明確な利点がないと判断しました。また、カレンダーに固有なすべての操作を Temporal.Calendar に整理することで、より見通しの良いモデルが実現することもわかりました。
このアプローチでは、カレンダーシステムを持たずに日時を表す新しいタイプを作成します。そして、カレンダーの情報が必要ない処理においては、カレンダーを持たないクラスをできるだけ使用するようにします。このアプローチについては、Option 5 in calendar-draft.mdで詳しく説明しています。
私達は、次の理由によってこのアプローチに反対しました:
特に(2)については、動的型付け言語である ECMAScript にとってよく当てはまることに注意してください。ただし、静的型付け言語では、ECMAScript では不可能なコンパイル時の型チェックを行えるかもしれません。特に TypeScript のようなクライアントは、カレンダーシステムのコンパイル時の型チェックにおいて、カレンダーシステムに対応する型パラメタを用いることで PlainDate の型を効率的に生成できる可能性があることに注意してください。その場合、個別のデータ型は必要ありません。