生年月日、毎日のアラーム、クレジット カードの有効期限、毎月の請求期間など、閲覧者の場所に関係なく同じであるべき時系列データを記録する場合、以前の Date オブジェクトではエラーが発生しがちでした。Date は常に特定の瞬間を表し、閲覧者のローカル タイムゾーンを使用して表示されるため、"1990-01-01" のような日付を保存すると、UTC より西のユーザーには "1989-12-31" が表示されることがあります。また、部分的な日付(月のみのカードの有効期限など)をモデル化すると、任意のプレースホルダの日付が強制され、オーバーフロー バグが発生します。
Temporal API には、タイムゾーン オフセットを持たず、カレンダーや時計から読み取ったとおりにカレンダーの日付と壁掛け時計の時刻を表す、ロケーションに依存しない「プレーン」タイプのファミリーが用意されています。
| タイプ | モデル化の対象 | 一般的なユースケース |
|---|---|---|
Temporal.PlainDate |
完全なカレンダー日付(year、month、day) |
生年月日、イベントの日付、定期購入の請求日 |
Temporal.PlainTime |
実時間(hour、minute、second、...) |
毎日のアラーム(ユーザーの現在地の午前 9 時)、営業時間 |
Temporal.PlainDateTime |
カレンダーの日付 + タイムゾーンなしの壁時計時間 | 変動するローカル スケジュール。ゾーンをアタッチする前に PlainDate と PlainTime を組み合わせます。 |
Temporal.PlainMonthDay |
年を含まない月と日 | 毎年繰り返される誕生日、記念日、固定の祝日 |
Temporal.PlainYearMonth |
日を含まない年と月 | クレジット カードの有効期限(2027-12)、毎月の請求対象期間 |
実装方法
- データに一致する最も狭い
Temporal.Plain*型を選択します。- 完全なカレンダーの日付には
Temporal.PlainDate.from()を、実時間にはTemporal.PlainTime.from()を使用します。 - タイムゾーンにバインドせずにカレンダーの日付と壁掛け時計の時刻を組み合わせる場合は、
Temporal.PlainDateTime.from()(またはplainDate.toPlainDateTime(plainTime))を使用します。 - 年を含まない年次日付には
Temporal.PlainMonthDay.from()を使用し、日付演算を行う前に.toPlainDate({ year })を使用してPlainDateに変換します。 - 任意の日の数値が計算に漏れないように、年月値には
Temporal.PlainYearMonth.from()を使用します。
- 完全なカレンダーの日付には
- Plain 型を構築する際の
calendarパラメータに注意してください。ISO 文字列を解析する場合やPlainDate.from({ year, month, day })を使用して構築する場合、ISO 8601('iso8601')がデフォルトのカレンダーになります。プロパティ バッグからPlainMonthDayまたはPlainYearMonthを作成する場合、または ISO 以外のカレンダーを使用する場合は、calendarを明示的に渡します(例:{ month: 10, day: 31, calendar: 'iso8601' })。 .add()をoverflowオプションとともに使用して、定期的なカレンダー間隔を設定する:plainDate.add({ months: 1 })を呼び出すと、デフォルトで{ overflow: 'constrain' }になり、1 月 31 日などの月末の日付が 2 月 28 日または 29 日に固定されます。無効なターゲット日付でクランプするのではなくRangeErrorをスローする場合は、{ overflow: 'reject' }を渡します。
例: 完全な日付、実時間、PlainDateTime
// 1. Parse a calendar date and a wall-clock time from input strings
const birthdate = Temporal.PlainDate.from('1990-01-01');
const reminderTime = Temporal.PlainTime.from('09:00');
// Combine into a location-agnostic PlainDateTime without string interpolation
const localAppointment = birthdate.toPlainDateTime(reminderTime);
// 2. Display without time zone conversion; outputs the same date in any time zone
console.log(birthdate.toLocaleString('en-GB'));
console.log(localAppointment.toString()); // "1990-01-01T09:00:00"
// 3. Read calendar fields directly from the Plain type. A PlainDate already *is*
// a calendar date, so there is no need for the formatToParts() workaround that
// legacy Date code uses to recover year/month/day from an instant.
const { year, month, day } = birthdate;
例: 部分的な日付(PlainMonthDay と PlainYearMonth)
// Annual recurring event (no year component)
// Include explicit calendar when constructing from an object
const annualHoliday = Temporal.PlainMonthDay.from({
month: 10,
day: 31,
calendar: 'iso8601',
});
const today = Temporal.Now.plainDateISO();
const isToday = annualHoliday.equals(today.toPlainMonthDay());
// Convert PlainMonthDay to a PlainDate with a specific year before doing arithmetic,
// because month lengths (such as February) depend on whether the year is a leap year.
const occurrenceThisYear = annualHoliday.toPlainDate({ year: today.year });
// Monthly expiration (no day component, preventing day-of-month overflow bugs)
const cardExpiry = Temporal.PlainYearMonth.from({
year: 2027,
month: 12,
calendar: 'iso8601',
});
const currentMonth = today.toPlainYearMonth();
const isExpired = Temporal.PlainYearMonth.compare(cardExpiry, currentMonth) < 0;
例: 毎月の繰り返し間隔(overflow: 'constrain' と 'reject')
const billingStart = Temporal.PlainDate.from('2024-01-31');
// Default overflow: 'constrain' clamps Jan 31 + 1 month to the last valid day of February
const nextBilling = billingStart.add({ months: 1 }, { overflow: 'constrain' });
console.log(nextBilling.toString()); // "2024-02-29" (2024 is a leap year)
// Use overflow: 'reject' to throw a RangeError when the target day does not exist
try {
billingStart.add({ months: 1 }, { overflow: 'reject' });
} catch (err) {
// RangeError: day 31 does not exist in February
}
例: 以前の Date との相互運用
以前の Date は、カレンダーの日付ではなく、タイムスタンプ(瞬間)です。"1990-01-01" を Date として保存すると、特定の時刻(通常は UTC の午前 0 時)を選択せざるを得なくなり、UTC より西のタイムゾーンで読み取ると "31/12/1989" が生成されます。ライブラリまたは API で Date が必要な場合は、ISO 文字列を手動で作成するのではなく、Temporal.Instant を使用して明示的に変換します。
// PlainDate -> Date: attach a time zone (time of day defaults to the start of
// that day) to get an Instant, then hand the epoch milliseconds to Date.
// 'UTC' is a deliberate choice and must be documented; any zone would do,
// but the stored instant depends on it.
const instant = birthdate.toZonedDateTime('UTC').toInstant();
const legacyDate = new Date(instant.epochMilliseconds);
// Date -> PlainDate: go back through Instant (legacyDate.toTemporalInstant()
// is equivalent), then project into an explicit time zone. The result depends
// on the zone, which is exactly the drift risk.
const viewedDate = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime())
.toZonedDateTimeISO('America/New_York')
.toPlainDate();
// Compare Plain types with .equals() (or Temporal.PlainDate.compare), not strings.
console.log(viewedDate.equals(birthdate)); // false: the calendar date drifted to 1989-12-31
戦略的な実装とベスト プラクティス
- 推奨: 完全なカレンダーの日付には
Temporal.PlainDate、壁掛け時計の時間にはTemporal.PlainTime、ゾーンのないローカルの日付と時刻のペアにはTemporal.PlainDateTime、毎年繰り返される日付にはTemporal.PlainMonthDay、年月の有効期限または請求期間にはTemporal.PlainYearMonthを使用します。 Temporal.PlainMonthDayで直接算術演算を行わないでください。月の長さは年によって異なるため、まず.toPlainDate({ year })を介してTemporal.PlainDateに変換してください。- 推奨: 繰り返しのカレンダー間隔には
Temporal.PlainDateで.add()を使用します。overflow: 'constrain'(デフォルト)を使用して月末の日付を固定するか、overflow: 'reject'を使用して存在しない日付にRangeErrorをスローします。 - 推奨: オブジェクトから
PlainMonthDayまたは ISO 以外のプレーンタイプを構築する場合は、calendar(calendar: 'iso8601'など)を指定します。 - 物理的な時間における特定の瞬間(インスタント)を表す必要がある場合は、Plain 型を使用しないでください。ログ、イベント タイムスタンプ、タイムゾーンをまたぐスケジュール設定には、
Temporal.InstantまたはTemporal.ZonedDateTimeを使用します。 - 手動で作成した ISO 文字列(
`${date}T00:00:00Z`など)からDateオブジェクトを構築しないでください。Temporal.InstantまたはTemporal.ZonedDateTimeのepochMillisecondsからDateを生成し、Temporal.Instant.fromEpochMilliseconds(date.getTime())またはdate.toTemporalInstant()で読み戻して、タイムゾーンの想定を明示的にします。 - 年、月、日の値を抽出または比較するために
Intl.DateTimeFormat.prototype.formatToParts()を使用しないでください。.year、.month、.dayを Plain 型から直接読み取り、.equals()と比較します。 - すべきこと: タイムゾーンは IANA 名(
America/New_Yorkなど)で識別し、固定の UTC オフセットでは識別しない。オフセットは夏時間や法律によって変化するため、"UTC-5"のようなラベルは 1 年のうちの一部で誤りとなります。 Temporalオブジェクトは不変です。.add()、.subtract()、.with()などのメソッドは、元のオブジェクトをその場で変更するのではなく、常に新しい参照を持つ新しいインスタンスを返します。そのため、リアクティブ UI 状態に直接保存しても安全です。
代替戦略
Temporal のフォールバックとブラウザ サポート
ネイティブの Temporal API をまだサポートしていないブラウザの場合は、機能検出を使用して temporal-polyfill(約 15 KB min+gz)を動的に読み込みます。
temporal-polyfill をインポートしても、Temporal オブジェクトがグローバル スコープに自動的に割り当てられるわけではありません。コードが globalThis.Temporal に依存している場合は、明示的に割り当ててください。
// Check if Temporal is supported natively
(async () => {
if (typeof Temporal === 'undefined') {
// Load the polyfill conditionally
const module = await import('https://esm.sh/temporal-polyfill');
globalThis.Temporal = module.Temporal;
// Extend Date.prototype if needed
Date.prototype.toTemporalInstant = module.toTemporalInstant;
}
initializeApp();
})();