Temporal におけるカレンダーシステムのサポート

ECMAScript Temporalは、ECMAScript における新しい日時 API です。ECMAScript は世界中のユーザーに向けて設計されているため、高品質な国際化サポートを設計目標としています。

このドキュメントでは、Temporal がなぜ / どのようにしてカレンダーシステムをサポートするのか、そして開発者が遭遇するかもしれないカレンダーに関する問題と、それを回避する方法について説明します。前半の節では、Temporal でカレンダーシステムをサポートするに至った経緯を説明します。後半の節では、過去に検討されたカレンダーシステムの設計について説明し、なぜそれらではなく現在のアプローチが選択されたのかを説明します。

背景とモチベーション

カレンダーシステムは、「特定の時刻」と「年、月、日といった人間が読みやすい数値」を対応付ける方法を提供します。

世界中で多く使用されているのはグレゴリオ暦です。ここでは太陽暦、月、日が使われ、これらの値は季節に同期します。しかし世界中には、その他のカレンダーシステムを存在しています。有名なものでは、ユダヤ暦、ヒジュラ暦、仏滅紀元、ヒンドゥー暦、中国暦、和暦、そしてエチオピア暦などです。

今日はグレゴリオ暦で"March 4, 2021"ですが、その他のカレンダーでは次のように表されます。

グレゴリオ暦以外のカレンダーシステムは、いくつかの国、特にグレゴリオ暦が支配的な西洋諸国との文化的、経済的関係が弱い国において、日常生活ならびに宗教的、文化的、学術的用途で使用されています。例えば、日本、中国、イラン、アフガニスタン、サウジアラビア、台湾を含むいくつかの国では、グレゴリオ暦以外のカレンダーが、少なくとも一部の政府公式の手続きで使用されています。これらの公式な用途以外でも、世界の人口の大多数は、宗教的または文化的な休日の日付を決定するためにグレゴリオ暦以外のカレンダーシステムを使用する国に住んでいます。

カレンダーのビジネスロジック(単なる文字列の地域化ではない)

カレンダーシステムは地域化(l10n:localization)では重要な役割を果たします。これらは、タイムスタンプや日時を人間にわかりやすい文字列へ変換することを可能にします。

しかし、カレンダーシステムはアプリケーションのビジネスロジックについても重要な役割を果たします。人間が関わるアプリケーションでは、日時の演算や操作を必要とする場合が多く、そのような処理にはカレンダーシステムが必要です。カレンダーに依存する処理の例は次のとおりです:

言い換えると、1 日(太陽日)よりも大きな単位をどのように決めるかは、その地域の文化によります。日付計算を最も普遍的に解決する方法は、月と年の概念を排除することですが、そのような日付 API は多くの場合不十分です。

他のプラットフォームに比べて、ECMAScript におけるカレンダーサポートへの優先度が高いのはなぜか

カレンダーシステムを Temporal へ統合しようとすると新たな問題へ対処しなければならなくなるのは明らかです。例えば"Unexpected Calendar Problem"(以下を参照)が考えられます。つまり、あるカレンダーを想定して書かれたコードで、別のカレンダーの日時を処理しようとすると様々な問題を引き起こす可能性があります。

しかし、Temporal にカレンダーシステムを統合することによる利点は、これらの問題を上回る可能性があり、これはそれほど自明では無いかもしれません。これらの利点を理解する 1 つの方法は、カレンダーシステムに対して異なるアプローチを行っている Java や.NET と、ECMAScript エコシステムのニーズの違いを明確にすることです。いくつかの違いを以下に示します:

現在の設計を選択することことは容易ではありませんでした。カレンダーシステムを Temporal に統合することは、ISO カレンダーのみを意図してコードを作成するよりも多くのバグを引き起こします。しかし、経済成長や人口増加、テクノロジーの普及に関する世界的な傾向を考慮すれば、現在の設計を採用することは、ECMAScript コミュニティの長期的な成功に繋がる合理的なトレードオフであると私達は考えています。

"Intl ファースト"な設計

ECMAScript には国際化(i18n)をサポートする主要な機能があります。Intl ファーストな設計の 2 つの原則は、「1.言語または地域に固有の操作はデータ駆動であること」、「2.ユーザーの環境が、API へ明示的に入力されること」です。これらの原則は、世界中の幅広いエンドユーザーを対象にしたアプリケーションの開発者が、API を適切に使用するために役立ちます。

Temporal では、これらの原則を「カレンダー固有のロジックを抽象化すること」と「どのカレンダーを使用するかをオブジェクトコンストラクタで選択できるようにすること」によって守っています。その結果、グレゴリオ暦のみを使用する地域と、他のカレンダーを使用する地域のどちらでも利用可能な API を作成できます。

Temporal のコード内でのカレンダーの使用

以下の節ではカレンダーシステムが 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.ZonedDateTimeTemporal.PlainDateTimeTemporal.PlainDateTemporal.PlainYearMonthTemporal.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 カレンダーの定数をハードコーディングする代わりにmonthsInYeardaysInMonthを用いることで、簡単にカレンダーセーフなコードに書き換えられます。開発者は、カレンダーセーフな Temporal コードを書くためのベストプラクティスにしたがうことで、すべてのビルトインカレンダーで動作するコードを書くことができます。

ドキュメントに記載がある以外にも、Temporal API そのものが、カレンダーセーフなコードを書きやすいように既に設計されています。例えば:

カレンダーのバリデーションと変更

Temporal を用いることでカレンダーセーフなコードを簡単に記述できますが、それでも開発者による作業が必要です。一部の開発者はこの作業を行いません。これは、ISO 以外のカレンダーの存在を知らない、または理解していないことが原因である場合があります。または、ISO カレンダーのみを使用するアプリケーションを書いている場合、わざわざカレンダーセーフのベストプラクティスに従わないかもしれません。あるいは、自身が書くコードではベストプラクティスが守られているが、それが依存するコードではそうでない場合があります。通常、ECMAScript アプリケーションにはたくさんの依存関係があり、時間と労力の関係上、それらと日時データを相互運用する必要があるかもしれません。

このような場合、開発者は入力される日時データについて、「意図したカレンダーシステムであることをバリデートする」か、「カレンダーシステムを意図したものに変更する」必要があります。バリデーションと変更のどちらが望ましいかは、ユースケースによります。

カレンダーシステムを変更する場合:

date = date.withCalendar('iso8601');

カレンダーシステムをバリデートする場合:

if (date.calendar.id !== 'iso8601') throw new Error('invalid calendar');

ここで言う「入力」とは外部データに限らないことに注意してください。例えば、依存している日付ライブラリから返されるデータなども「入力」です。ライブラリや入力ソースが特定のカレンダーシステムをサポートしていると明示しない限り、それらからのデータ(日時オブジェクトやそれに変換される文字列)はすべて「外部」であり、カレンダーシステムのバリデーションや変更のための処理が必要です。それらのデータをそのまま使用するのはカレンダーセーフではありません。

検討されていた代替案

カレンダーシステムを Temporal に統合することは、API 設計におけるもっとも挑戦的な部分でした。以下では、私達が現在の設計の前に検討していたカレンダー API の代替案を簡単に説明します。(現在の設計も含めて)検討された設計はどれも理想的なものではないことに注意してください。これらの設計は、場合によっては回避策が必要となる既知の欠陥を持っています。以下の選択肢と比較して、現状の設計が全体を通して最善の選択であると私達は信じています。

代替案 1: データモデルにカレンダーを含めない

現状の Temporal の設計に対して、カレンダーの情報を Temporal のインスタンスから分離して管理するという代替案があります。これにより、「予期しないカレンダーの問題」を回避できるという利点がありますが、API の利用者への負担が大きいという問題があります:

私達は、この代替案によって生じる負担が、予期しないカレンダーの問題を回避するためのものに比べてとても大きいと考えています。

代替案 2: ISO プロパティとカレンダープロパティを分離する

予期しないカレンダーの問題に対して脆弱になることなく、カレンダーを Temporal のデータモデルに統合することが可能です。しかし、これによって Temporal API の数が増加してしまいます。この案を適用した場合の一例を以下に示します。

私達はこれらの代替案を検討した結果、この案によってもたらされる複雑さにはあまり価値がないと判断しました。特に、フィールドやプロパティを 2 倍に増やすというアプローチには、余計なバグが増えたり、入力のバリデーションなどが煩雑になるという懸念があります。

代替案 3: サブクラス

カレンダーシステムを PlainDate の内部スロットに格納する代わりに、PlainDate を ISO カレンダーのみをサポートするように保ち、PlainHebrewDate や PlainChineseDate といったカレンダー固有のサブクラスを作成する案が検討されました。より詳しくはサブクラスに関するディスカッションを参照してください。

私達は、サブクラスのアプローチを行わないことにしました。なぜなら:

私達は最終的に、このアプローチが他のアプローチに比べて高コストであり、かつ明確な利点がないと判断しました。また、カレンダーに固有なすべての操作を Temporal.Calendar に整理することで、より見通しの良いモデルが実現することもわかりました。

代替案 4: カレンダーを持たない日付タイプを新たに作成する

このアプローチでは、カレンダーシステムを持たずに日時を表す新しいタイプを作成します。そして、カレンダーの情報が必要ない処理においては、カレンダーを持たないクラスをできるだけ使用するようにします。このアプローチについては、Option 5 in calendar-draft.mdで詳しく説明しています。

私達は、次の理由によってこのアプローチに反対しました:

  1. 作成するタイプにカレンダーの情報が無いとすると、年や月に関する処理をサポートできませんでした。
  2. 作成するタイプが ISO カレンダーのように振る舞うとすると、それは ISO カレンダーを使用した PlainDate と同じことになります。

特に(2)については、動的型付け言語である ECMAScript にとってよく当てはまることに注意してください。ただし、静的型付け言語では、ECMAScript では不可能なコンパイル時の型チェックを行えるかもしれません。特に TypeScript のようなクライアントは、カレンダーシステムのコンパイル時の型チェックにおいて、カレンダーシステムに対応する型パラメタを用いることで PlainDate の型を効率的に生成できる可能性があることに注意してください。その場合、個別のデータ型は必要ありません。