Temporal を使用した場所を問わないデータと部分的な日付のキャプチャ

ウェブ機能: Temporal

生年月日、毎日のアラーム、クレジット カードの有効期限、毎月の請求期間など、閲覧者の場所に関係なく同じであるべき時系列データを記録する場合、以前の 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)、毎月の請求対象期間

実装方法

  1. データに一致する最も狭い Temporal.Plain* 型を選択します。
    • 完全なカレンダーの日付には Temporal.PlainDate.from() を、実時間には Temporal.PlainTime.from() を使用します。
    • タイムゾーンにバインドせずにカレンダーの日付と壁掛け時計の時刻を組み合わせる場合は、Temporal.PlainDateTime.from()(または plainDate.toPlainDateTime(plainTime))を使用します。
    • 年を含まない年次日付には Temporal.PlainMonthDay.from() を使用し、日付演算を行う前に .toPlainDate({ year }) を使用して PlainDate に変換します。
    • 任意の日の数値が計算に漏れないように、年月値には Temporal.PlainYearMonth.from() を使用します。
  2. Plain 型を構築する際の calendar パラメータに注意してください。ISO 文字列を解析する場合や PlainDate.from({ year, month, day }) を使用して構築する場合、ISO 8601('iso8601')がデフォルトのカレンダーになります。プロパティ バッグから PlainMonthDay または PlainYearMonth を作成する場合、または ISO 以外のカレンダーを使用する場合は、calendar を明示的に渡します(例: { month: 10, day: 31, calendar: 'iso8601' })。
  3. .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();
})();