Temporalとは、JavaScriptに組み込まれた新しい日付計算機能です(正確にはまだStage 3ですが、仕様自体はほとんど完成している状態です)。従来あったDate
のさまざまなつらい部分が解消されたAPIとなっており、実用化が待たれます。
8月32日とは、9月1日の到来を拒む一部の人たちが主張する仮想上の日付です。日本では9月1日は夏休みが終わり学校に登校しなければならない日ですが、8月32日ではまだ夏休みが続いているとされています。
残念ながら、日付を扱う多くのアプリケーションでは8月32日がサポートされていません。しかし実は、TemporalではCalendarという仕組みを用いることで8月32日をサポートすることができまです。この記事では、8月32日をサポートするCalendar、すなわち永遠の夏休みカレンダーとして筆者が実装したeternal-summer-vacationの実装や使用例を紹介します。独自のCalendarを作りたい際の参考にしてください。
なお、まだTemporalは本物の実装が使えるわけではないので、このパッケージは公式Polyfillである@js-temporal/polyfill
を使用しています。
サンプル
こちらは、Reactで今年のカレンダーを表示するサンプルです。ボタンを押すと通常のカレンダーと永遠の夏休みカレンダーを切り替えることができます。コードを読んでみると、両者は「Temporal.PlainDate
オブジェクトを作る際にどのCalendarオブジェクトを使うか」という点にのみ影響を与えており、その他の部分は全く同じコードで2種類のカレンダーを描画することができることが分かります(useCalendar.ts
を参照)。このように、Temporalではカレンダーという概念が抽象化されており、うまくコードを書くことで異なるカレンダーに対応したコードを書くことができます1。
永遠の夏休みカレンダーモードにすると、次のように8月32日以降があるカレンダーを出力することができます。
Calendarの役割
Temporalでは、日付の内部表現はISO 8601で定められた通りグレゴリオ暦です。Calendarは、その日付をどう表示するかを司るというのが基本的な役割です。さらに、付随して「月はいくつあるのか」「この月には何日あるのか」「一週間は何日なのか」といったことをカスタマイズできるようになっています。
今回実装したカレンダーの中心となるのは、例えばISO 8601カレンダーで2021-09-01
と表現された日付を「2021年8月32日」に変換する、あるいはその逆を行うロジックです。
独自のCalendar
を作るには、Temporal.Calendar
を継承するのが簡単です。以下ではコードの一部を抜き出しながら説明します。全体は以下のURLで見ることができます。
永遠の夏休み日付の計算
ある日付が永遠の夏休みカレンダー上で何月何日になるのか定義するには、Calendarのmonth
メソッドやday
メソッドをオーバーライドします。
override month(date: DateLike) {
return eternalDate(getPlainDateWithISOCalendar(date))[0];
}
override day(date: DateLike) {
return eternalDate(getPlainDateWithISOCalendar(date))[1];
}
実装に見えるeternalDate
が、ISO 8601日付を永遠の夏休み日付に変換する関数です。その前段にあるgetPlainDateWithISOCalendar
は、与えられた日付のISO 8601日付を得るための関数です。というのも、ここで与えられるdate
引数はTemporal.PlainDate
オブジェクト(Temporalで日付を表すオブジェクト)だったり"2021-09-01"
のような数値だったりあるいは{ year: 2021, month: 9, day: 1 }
のようなオブジェクトだったりします。これらの扱いが面倒なので関数にまとめています。
function getPlainDateWithISOCalendar(date: DateLike) {
if (isTemporalDateLike(date)) {
const fields = date.getISOFields();
return new Temporal.PlainDate(
fields.isoYear,
fields.isoMonth,
fields.isoDay
);
} else if (typeof date === "string") {
return Temporal.PlainDate.from(date);
} else {
return Temporal.PlainDate.from({ ...date, calendar: "iso8601" });
}
}
この関数では最初のif文の中が注目ポイントです。与えられたオブジェクトがTemporal.PlainDate
のようなTemporalオブジェクトだった場合はgetISOFields
が使用できます。これを用いてISO 8601カレンダーでの日付を得ることができます。直接date.month
やdate.day
などとすることはできません。なぜなら、date
にはすでに別のカレンダーが紐づいていてISO 8601日付が得られないかもしれないからです。
また、このような場面で迂闊にdate
の日付計算を行うと、すでにdate
に永遠の夏休みカレンダーが紐づいているかもしれない関係で簡単に無限ループが発生してしまうという罠があります。それを避けるために与えられたdate
への刺激を最小限にし、getISOFields
の呼び出しだけにしています。getISOFields
は内部表現を読み出すだけなので、date
への刺激が小さいと思われます。
実際の計算を行うeternalDate
はこのような実装になっています。ISO 8601で8月以降の日付は全部8月に押し込めるような実装です。2021年12月31日までを全部8月に押し込め、それを超えると2022年1月1日になります。よって、永遠の夏休みカレンダーは1月〜8月しかないカレンダーとなります。
与えられた日付が永遠の夏休み日付での8月何日に当たるかを調べるには与えられた日付がISO 8601日付で8月1日の何日後か調べる必要がありますが、Temporalではそれを行なってくれるsince
メソッドが用意されているので簡単です。注意点として、このsince
は永遠の夏休みカレンダーではなくISO 8601カレンダー上で実行する必要があります。
function eternalDate(
isoDate: Temporal.PlainDate
): [month: number, day: number, isoDate: Temporal.PlainDate] {
if (isoDate.month < 8) {
return [isoDate.month, isoDate.day, isoDate];
}
const august1st = isoDate.with({ month: 8, day: 1 });
const daysFromAugust1st =
isoDate.since(august1st, { largestUnit: "day" }).days + 1;
return [8, daysFromAugust1st, isoDate];
}
永遠の夏休み日付からISO 8601日付への変換
逆に、永遠の夏休み日付で「8月50日」などと言われたとき、それがISO 8601日付で何月何日にあたるのか計算できる必要があります。Temporalではあくまで日付の内部表現はISO 8601なので、この計算はとても必要です。
これを実装するにはCelendarのdateFromFields
をオーバーライドします。fields
とは、{ year: 2021, month: 8, day: 50 }
のような形で日付を指し示すオブジェクトです。
override dateFromFields(
fields: Parameters<typeof isoDateFromFields>[0],
options: Temporal.AssignmentOptions
): Temporal.PlainDate {
const { year, month, day } = isoDateFromFields(fields, options);
return new Temporal.PlainDate(
year,
month,
day,
eternalSummerVacationCalendar
);
}
計算の本体はisoDateFromFields
ですね。内容は先ほどのちょうど逆の計算になっていて、8月の場合は一旦ISO 8601日付の8月1日から出発して何日後という形で計算します。この辺りもTemporal
の機能を用いていますので、Temporal
を用いる日付計算の参考になるかもしれません。
/**
* Converts an eternal date to ISO date.
*/
function isoDateFromFields(
fields: {
year: number | undefined;
month: number | undefined;
day: number | undefined;
},
options: Temporal.AssignmentOptions
): {
year: number;
month: number;
day: number;
} {
let { year = 0, month = 1, day } = fields;
if (month === 8) {
day = fields.day ?? 1;
if ((day < 1 || AugustDays < day) && options.overflow === "reject") {
throw new RangeError("Invalid Date");
}
day = constrain(day, 1, AugustDays);
const august1st = new Temporal.PlainDate(year, 8, 1);
const targetDate = august1st.add({ days: day - 1 });
return {
year: targetDate.year,
month: targetDate.month,
day: targetDate.day,
};
}
if (month > 8) {
throw new RangeError("Invalid Date");
}
return {
year,
month,
day: day ?? 1,
};
}
以上が永遠の夏休みカレンダーの実装の8割くらいを占めています。本当に、ISO 8601による内部表現との変換が主要なタスクであることが分かります。
他にやっていることとして、日付の足し算の処理や月の中に何日あるかというクエリへの対応があります。
永遠の夏休み日付の利用
永遠の夏休み日付からTemporalオブジェクトを作るには次のようにします。
const august32 = Temporal.PlainDate.from({
year: 2021,
month: 8,
day: 32,
calendar: eternalSummerVacationCalendar
});
console.log(august32.month, august32.day); // 8 32
console.log(august32.toJSON()); // "2021-09-01[u-ca=eternal-summer-vacation]"
注意しなければいけない点として、このように作ったTemporal.PlainDate
オブジェクトはmonth
やday
といったプロパティで永遠の夏休み日付を取得することができますが、toJSON
などで文字列化しても2021-08-32
のような表記は得られません。
これは、繰り返しになりますが、日付の内部表現はあくまでISO 8601であり、シリアライゼーションなど相互運用性が必要な場面ではISO 8601表現が使われるからです。Calendarは「日付データが時間のどの時点を指すのか」といったことに関与するものではなく、あくまで表示のためのものであると理解しましょう。
まとめ
この記事では、8月32日という題材を通してTemporalのCalendarの実装と実用例について学びました。Calendarの実装においては、ISO 8601で表される内部表現と、独自カレンダー上の日付の相互変換が主なタスクであることが分かりました。
これで、通常と異なる時空間に迷い込んでしまった場合でも問題なく日付を扱うことができますね。
サンプル(再掲):
GitHubリポジトリ(宣伝):
Q & A
Q. この実装だと永遠ではないのでは?
A. 本当に永遠の夏休みは読者の練習問題とします。年がISO 8601と一致しなくなったりするのでさらに難易度が高くおすすめです。
-
今回の実装は結構サボっていて、例えば1週間が7日ではないカレンダーだと壊れてしまいます。 ↩