0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Node.js v26 で標準化された JavaScript Temporal — Date の何が問題で、Temporal が何を解決するのか

0
Posted at

TL;DR

  • Node.js v26(2026-05-14)で Temporal が標準で使えるようになり、Firefox / Chrome / Edge とあわせて主要環境で揃った
  • Temporal用途別に型を分けた日時 API。Date の構造的な問題(破壊的変更・役割の混在・タイムゾーン制限・暦法制限・パースの揺れ)を型レベルで解決する
  • Safari は2026-05時点で未対応。フロントエンドは @js-temporal/polyfilltemporal-polyfill を併用

Temporal とは

Temporal は、JavaScript の Date を置き換えるために設計された新しい日時 API です。TC39 proposal として長く議論され、Stage 4 に到達。Node.js v26(2026-05-14リリース)でフラグなしで使えるようになりました。

Date は1995年の登場以来、設計上の問題が知られていましたが互換性のために残り続けてきました。Temporal「ひとつの型ですべての日時を扱う」のをやめ、用途ごとに型を分ける という発想で書き直された API です。

各環境の対応状況

  • Firefox 139(2025-05)で先行
  • Chrome 144 / Edge 144(2026-04)でサポート
  • Node.js v26(2026-05-14)でフラグなしのデフォルト有効
  • Safari: 2026-05時点で未対応

Safari 未対応のため、フロントエンドで利用するならポリフィルを併用するのが現実的です。@js-temporal/polyfill は Temporal 仕様の策定メンバーが管理する参照実装ベース(gzip 後 45kB 程度)、temporal-polyfill は FullCalendar の作者による軽量実装(gzip 後 20kB 程度)です。仕様追従の正確さを取るか、バンドルサイズの軽さを取るかで使い分けます。

動作確認

Node.js v26 で Temporal が使えることを最小コードで確認します。

console.log(typeof Temporal);
// "object"

TemporalMath と同じく、関連する機能をまとめた名前空間オブジェクトです。new Temporal() のような直接インスタンス化はできず、すべての操作は Temporal.NowTemporal.Instant.from() のように静的メソッド・サブクラス経由で行います。

Date の何が問題だったか

MDN を参考に、Date の設計上の問題として5つを挙げます。順番に実コードで確認します。

1. すべてのセッターがミュータブル

const d = new Date("2026-01-31T00:00:00Z");
console.log("before:", d.toISOString());
d.setUTCMonth(d.getUTCMonth() + 1); // 2月を期待
console.log("after :", d.toISOString());
before: 2026-01-31T00:00:00.000Z
after : 2026-03-03T00:00:00.000Z

setUTCMonth は元のインスタンス d を書き換えます。さらに 1/31 の1ヶ月後は2月に該当日がないため 3/3 として扱われ、月末を1ヶ月進めるだけの操作で2月をまたぎます。

const で受けても安心できません。インスタンスを関数間で渡し回すと、どこかで意図しない書き換えが起きやすい設計です。

2. タイムスタンプと成分の2つの役割を1つの型に詰めている

const d = new Date("2026-05-17T10:00:00Z");
console.log("valueOf()    :", d.valueOf());
console.log("getUTCHours():", d.getUTCHours());
console.log("getUTCMonth():", d.getUTCMonth());
valueOf()    : 1779012000000
getUTCHours(): 10
getUTCMonth(): 4

Date インスタンス1つに対し、タイムスタンプとして読むメソッドvalueOf() getTime() はエポックからのミリ秒を返す)と、年月日時分秒の成分として読むメソッドgetUTCHours() getUTCMonth() 系)が両方定義されています。

同じ値が「単一の数値」とも「成分の組」とも読める設計で、コードからはどちらの意味で扱っているかが判別しづらく、誤用を招きやすい構造です。

3. タイムゾーンは UTC とローカルのみ

const d = new Date("2026-05-17T10:00:00Z");
console.log("Asia/Tokyo で表示:", d.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }));
console.log("America/NY で表示:", d.toLocaleString("en-US", { timeZone: "America/New_York" }));
Asia/Tokyo で表示: 2026/5/17 19:00:00
America/NY で表示: 5/17/2026, 6:00:00 AM

toLocaleStringtimeZone オプションで任意のタイムゾーンの「表示」は得られますが、Date インスタンス自身は「Tokyo の19時」として保持していません。getHours() は常に実行環境のローカルタイムゾーンを参照します。

「タイムゾーンに依存しない時刻」も表現できません。たとえば「毎朝8時のアラーム」のように特定のタイムゾーンに紐付けずに持っておきたい時刻も、Date で書くと作成した時点の実行環境のタイムゾーンを基準に解釈され、内部では UTC のタイムスタンプとして保存されてしまいます。後で別地域のユーザーや別のタイムゾーンで動くサーバーがその値を読み出すと、本来意図した「8時」とは違う時刻に解釈されます。

4. グレゴリオ暦以外を扱えない

const d = new Date(2026, 0, 1);
console.log("getFullYear():", d.getFullYear());
console.log(
  "和暦表示     :",
  d.toLocaleDateString("ja-JP-u-ca-japanese", {
    era: "long",
    year: "numeric",
    month: "long",
    day: "numeric",
  })
);
getFullYear(): 2026
和暦表示     : 令和8年1月1日

toLocaleDateString を使えば和暦などの「表示」はできます。しかし getFullYear() などの数値を返すメソッドは常にグレゴリオ暦を返します。和暦・ヘブライ暦・イスラム暦・中国暦などを 数値として 取り出す標準 API は Date にありません。

5. 日時文字列の解釈が一貫しない

console.log(new Date("2026-01-01").toISOString());
console.log(new Date("2026/01/01").toISOString());
2026-01-01T00:00:00.000Z
2025-12-31T15:00:00.000Z

ISO 8601 形式(ハイフン区切りの YYYY-MM-DD)は仕様で UTC の0時 として解釈すると定められています。一方、スラッシュ区切りの YYYY/MM/DD は仕様の対象外で、V8 や SpiderMonkey などの実装が独自に 実行環境のローカル0時 として解釈します。

Tokyo (UTC+9) で実行すると、後者は UTC では前日15時。文字列が1字違うだけで日付が前後にずれます。仕様で決まっている部分と実装任せの部分が混在しているため、形式やランタイムが変わると解釈が変わってしまいます。

Temporal の基本思想:用途別に型を分ける

Temporal はこれらの問題を 型のレベル で解決します。鍵になる発想は「ひとつの型ですべての日時を扱う」のをやめて、用途別に型を分けることです。

概念 クラス
UTC 基準の絶対時刻(瞬間) Temporal.Instant
タイムゾーン込みの実時刻 Temporal.ZonedDateTime
タイムゾーンなしの日時 Temporal.PlainDateTime
日付だけ Temporal.PlainDate
時刻だけ Temporal.PlainTime
年月だけ Temporal.PlainYearMonth
月日だけ Temporal.PlainMonthDay
期間・差分 Temporal.Duration
現在時刻の取得 Temporal.Now

すべての型は イミュータブル で、変更操作は新しいインスタンスを返します。任意のタイムゾーン、複数の暦法、ナノ秒精度に対応します。

主要クラス

Temporal.Instant — UTC 基準の絶対時刻

タイムゾーンも暦も持たない、UTC の瞬間を表すクラスです。エポックからのナノ秒数で定義されます。

const fromString = Temporal.Instant.from("2026-05-17T10:00:00Z");
const fromMs = Temporal.Instant.fromEpochMilliseconds(1_779_012_000_000);
const fromNs = Temporal.Instant.fromEpochNanoseconds(1_779_012_000_000_000_000n);
console.log("from string :", fromString.toString());
console.log("from ms     :", fromMs.toString());
console.log("from ns     :", fromNs.toString());
from string : 2026-05-17T10:00:00Z
from ms     : 2026-05-17T10:00:00Z
from ns     : 2026-05-17T10:00:00Z

イミュータブル

変更操作は元のインスタンスを書き換えず、新しいインスタンスを返します。

const t0 = Temporal.Instant.from("2026-05-17T10:00:00Z");
const t1 = t0.add({ hours: 3, minutes: 15 });
console.log("t0:", t0.toString(), "← 元は変わらない");
console.log("t1:", t1.toString(), "← 3時間15分後");
t0: 2026-05-17T10:00:00Z ← 元は変わらない
t1: 2026-05-17T13:15:00Z ← 3時間15分後

Date.prototype.setUTCMonth のような「自身を書き換えるメソッド」は Temporal には存在しません。

差分は until / since

const a = Temporal.Instant.from("2026-05-17T10:00:00Z");
const b = Temporal.Instant.from("2026-05-18T11:30:00Z");
console.log("a.until(b)               :", a.until(b).toString());
console.log("largestUnit:'hour' で整形:", a.until(b, { largestUnit: "hour" }).toString());
a.until(b)               : PT91800S
largestUnit:'hour' で整形: PT25H30M

デフォルトでは秒単位の Duration(PT91800S)が返ります。上位の単位まで繰り上げたい場合は largestUnit を指定します。日や月の長さはタイムゾーンや暦法で変わるため、デフォルトでそこまでは繰り上げません。

Temporal.ZonedDateTime — タイムゾーン込みの実時刻

タイムゾーン情報を 値の一部として 持つ日時です。タイムゾーンルール(夏時間の切り替えを含む)に従って計算されます。

const inst = Temporal.Instant.from("2026-05-17T10:00:00Z");
console.log("UTC             :", inst.toString());
console.log("Asia/Tokyo      :", inst.toZonedDateTimeISO("Asia/Tokyo").toPlainDateTime().toString());
console.log("Europe/London   :", inst.toZonedDateTimeISO("Europe/London").toPlainDateTime().toString());
console.log("America/New_York:", inst.toZonedDateTimeISO("America/New_York").toPlainDateTime().toString());
UTC             : 2026-05-17T10:00:00Z
Asia/Tokyo      : 2026-05-17T19:00:00
Europe/London   : 2026-05-17T11:00:00
America/New_York: 2026-05-17T06:00:00

同じ Instant(絶対的な瞬間)を、タイムゾーンを通して各地の日時に変換しています。逆に各タイムゾーンの日時から Instant への変換も同じように用意されています。

DST: 存在しない時刻を渡したら

米ニューヨークは毎年3月の第2日曜に時計が午前2時から午前3時に切り替わります。2026-03-08 02:30 は存在しない時刻です。

const dst = Temporal.ZonedDateTime.from("2026-03-08T02:30[America/New_York]");
console.log("入力 2026-03-08T02:30 [America/New_York]");
console.log("→ 実際の時刻       :", dst.toString());
console.log("→ オフセット        :", dst.offset);
入力 2026-03-08T02:30 [America/New_York]
→ 実際の時刻       : 2026-03-08T03:30:00-04:00[America/New_York]
→ オフセット        : -04:00

デフォルトで「DST 後のオフセット」を採用し、03:30 に補正します。disambiguation: 'reject' を渡せば例外にすることもできます。

DST の境目で「1日 ≠ 24時間」

DST 切替日をまたぐと、1日が23時間や25時間になります。

const before = Temporal.ZonedDateTime.from("2026-03-07T12:00[America/New_York]");
const after = before.add({ days: 1 });
console.log("3/7 12:00 + 1 day :", after.toString());
console.log("実経過時間         :", before.until(after, { largestUnit: "hour" }).toString());
3/7 12:00 + 1 day : 2026-03-08T12:00:00-04:00[America/New_York]
実経過時間         : PT23H

add({ days: 1 }) はカレンダー上で 12:00 → 12:00 の1日後ですが、実経過時間は 23時間 です。Temporaldayshours を別概念として扱います。

  • days: 1 → カレンダー上の1日先
  • hours: 24 → 実際に経過する時間で24時間後

withTimeZonetoPlainDateTime().toZonedDateTime() の使い分け

const tokyo9 = Temporal.ZonedDateTime.from("2026-05-17T09:00[Asia/Tokyo]");
const sameInstantInNY = tokyo9.withTimeZone("America/New_York");
console.log("Tokyo 09:00 = NY:", sameInstantInNY.toString());

const ny9 = tokyo9.toPlainDateTime().toZonedDateTime("America/New_York");
console.log("NY も 09:00:", ny9.toString());
Tokyo 09:00 = NY: 2026-05-16T20:00:00-04:00[America/New_York]
NY も 09:00: 2026-05-17T09:00:00-04:00[America/New_York]
  • withTimeZone(zone) — 同じ瞬間を別タイムゾーンで表示する
  • toPlainDateTime().toZonedDateTime(zone) — 日時の数字を維持したまま別タイムゾーンに置き換える

「東京の朝9時と同じ瞬間、ニューヨークは何時?」と「東京の朝9時に開催する会議を、ニューヨーク現地でも朝9時開催に置き換えたい」は別の操作です。Temporal はこの2つを別メソッドに分けて、書き手にどちらの意味かを選ばせます。

PlainDate / PlainTime / PlainDateTime — タイムゾーンなしの日時

Date は UTC タイムスタンプと年月日時分秒の成分を1つに詰めていました。Temporal はそれを分離します。

const birthday = Temporal.PlainDate.from("2000-04-12");
console.log("birthday    :", birthday.toString());
console.log("dayOfWeek   :", birthday.dayOfWeek);
console.log("daysInMonth :", birthday.daysInMonth);
console.log("inLeapYear  :", birthday.inLeapYear);
birthday    : 2000-04-12
dayOfWeek   : 3
daysInMonth : 30
inLeapYear  : true

PlainDate は時刻もタイムゾーンも持たない日付です。

不正な日付は例外で弾く

try {
  Temporal.PlainDate.from("2026-02-30");
} catch (e) {
  console.log("error:", e.message);
}
error: Temporal error: Parsed day value not in a valid range.

Date が黙って 3/2 にしていたところを、Temporal は明示的に例外を投げます。同じ考え方が時刻にも適用されていて、PlainTime.from("25:00") も例外を投げます。

時刻だけ・日時だけの型

const alarm = Temporal.PlainTime.from("08:00");
console.log("alarm        :", alarm.toString());
console.log("hour         :", alarm.hour);
console.log("+ 90 minutes:", alarm.add({ minutes: 90 }).toString());

const meeting = Temporal.PlainDateTime.from("2026-05-17T15:30");
console.log("meeting        :", meeting.toString());
console.log("+ 2 hours      :", meeting.add({ hours: 2 }).toString());
console.log("with year=2030 :", meeting.with({ year: 2030 }).toString());
alarm        : 08:00:00
hour         : 8
+ 90 minutes: 09:30:00
meeting        : 2026-05-17T15:30:00
+ 2 hours      : 2026-05-17T17:30:00
with year=2030 : 2030-05-17T15:30:00

with メソッドは「一部のフィールドだけ差し替えた新しいインスタンスを返す」操作です。

年月だけ・月日だけ

const cardExpiry = Temporal.PlainYearMonth.from("2030-12");
const christmas = Temporal.PlainMonthDay.from("--12-25");
console.log("PlainYearMonth :", cardExpiry.toString());
console.log("PlainMonthDay  :", christmas.toString());
PlainYearMonth : 2030-12
PlainMonthDay  : 12-25

「年」を持たない月日の型が独立しているため、誕生日の月日のように 2/29 を扱えるかどうかを別の型として区別したい場面でそのまま使えます。

Temporal.Duration — 期間と差分

const dur = Temporal.Duration.from({ minutes: 130, seconds: 45 });
console.log("dur                          :", dur.toString());
console.log("round({ largestUnit: 'hour'}):", dur.round({ largestUnit: "hour" }).toString());
console.log("round({ smallestUnit:'min'}):", dur.round({ smallestUnit: "minute" }).toString());
dur                          : PT130M45S
round({ largestUnit: 'hour'}): PT2H10M45S
round({ smallestUnit:'min'}): PT131M

round で粒度を上げ下げできます。

カレンダーを考慮した加算

ZonedDateTime と組み合わせると、暦法を考慮した月の加算ができます。Date.setMonth1/31 + 1ヶ月3/3 になっていた問題は、Temporal だと月末は月末に丸められます。

const start = Temporal.ZonedDateTime.from("2026-01-31T10:00[Asia/Tokyo]");
console.log("1/31 + 1 month:", start.add({ months: 1 }).toString());
1/31 + 1 month: 2026-02-28T10:00:00+09:00[Asia/Tokyo]

「1月31日の1ヶ月後」を「2月の最終日(28日または29日)」として解釈します。

Temporal.Now — 現在時刻

Date.now() 一択だった現在時刻取得が、粒度とタイムゾーンを呼び分けられる API に変わりました。

const instant = Temporal.Now.instant();
console.log("instant         :", instant.toString());
console.log("epochNanoseconds:", instant.epochNanoseconds);
console.log("epochMilliseconds:", instant.epochMilliseconds);

const zdt = Temporal.Now.zonedDateTimeISO();
console.log("zonedDateTimeISO:", zdt.toString());
console.log("timeZoneId      :", zdt.timeZoneId);

const tokyo = Temporal.Now.zonedDateTimeISO("Asia/Tokyo");
const ny = Temporal.Now.zonedDateTimeISO("America/New_York");
console.log("Asia/Tokyo      :", tokyo.toString());
console.log("America/New_York:", ny.toString());

console.log("plainDateISO    :", Temporal.Now.plainDateISO().toString());
console.log("plainTimeISO    :", Temporal.Now.plainTimeISO().toString());
console.log("timeZoneId()    :", Temporal.Now.timeZoneId());
instant         : 2026-05-17T10:45:34.678364014Z
epochNanoseconds: 1779014734678364014n
epochMilliseconds: 1779014734678
zonedDateTimeISO: 2026-05-17T19:45:34.681697998+09:00[Asia/Tokyo]
timeZoneId      : Asia/Tokyo
Asia/Tokyo      : 2026-05-17T19:45:34.682493896+09:00[Asia/Tokyo]
America/New_York: 2026-05-17T06:45:34.68249707-04:00[America/New_York]
plainDateISO    : 2026-05-17
plainTimeISO    : 19:45:34.682677002
timeZoneId()    : Asia/Tokyo

epochNanosecondsBigInt で返ります。ナノ秒の値は Number の安全整数の範囲(約 9 × 10^15)を超えるため、整数で精度を保つには BigInt が必要だからです。

Date と Temporal の対応関係

Date の問題 Temporal の対応
すべてのセッターがミュータブル すべての変更操作が新しいインスタンスを返す(イミュータブル)
タイムスタンプと成分の2つの役割を1つの型に詰めている タイムスタンプは Instant、成分は PlainDate / PlainTime / PlainDateTime / ZonedDateTime に分離
タイムゾーンは UTC とローカルのみ ZonedDateTime がタイムゾーンを値の一部として持つ
グレゴリオ暦以外を扱えない withCalendar("japanese") などで和暦・ヘブライ暦・中国暦に切り替え可能
日時文字列の解釈が一貫しない 各クラスの from() が厳格にパース。不正な値は例外で弾く

ユースケース別の使い分け

やりたいこと 適切なクラス 理由
サーバ間タイムスタンプ、ログのイベント順序 Temporal.Instant UTC ナノ秒、タイムゾーン不要
「Tokyo で毎週月曜9:00 開催」のスケジュール Temporal.ZonedDateTime DST やオフセットを正しく扱う
「毎朝8時のアラーム」 Temporal.PlainTime 日付・タイムゾーン情報を持たない
誕生日(月日のみ、年なし) Temporal.PlainMonthDay 2/29 などを型で区別できる
契約日、休日表、請求書日付 Temporal.PlainDate 時刻情報が混入しない
クレジットカード有効期限 Temporal.PlainYearMonth 月単位で十分
「2時間30分」「3営業日」のような期間 Temporal.Duration カレンダー対応の加算と丸めができる
ローカルでの会議予約フォーム Temporal.PlainDateTime ユーザー入力の時点ではゾーン未確定

まとめ

  • Node.js v26 で Temporal がフラグなしで利用可能になり、サーバサイドJSでは標準APIとして使える段階になった
  • MDN が挙げる Date の設計上の問題(ミュータブル・役割の混在・タイムゾーンの制限・暦法の制限・パース不一貫)は、Temporal の役割分離・イミュータブル設計でそれぞれ対応している
  • 実用範囲は Now / Instant / PlainDate 系 / ZonedDateTime / Duration の5系統でカバーできる
  • ブラウザ側は Safari が未対応のため、フロントエンドで全面採用するなら @js-temporal/polyfilltemporal-polyfill の併用が現実的
  • 新規プロジェクトは Temporal で書き始められる段階。既存コードの移行は DateInstant(UTC)か ZonedDateTime(タイムゾーン込み)のどちらに置き換えるかの判断を起点に進められる

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?