📅

「月が0始まり」の時代が終わる

JavaScript の日付処理で一度もバグを出したことがない開発者は、おそらく日付処理を書いたことがない開発者です。

// 2026年3月を作りたいだけなのに…
const date = new Date(2026, 3, 1); // → 2026年4月1日(月は0始まり)

この「月が0始まり」という仕様に何度泣かされたか分かりません。moment.js、date-fns、Day.js——これらのライブラリが週間合計1億ダウンロードを超えている事実こそ、Date オブジェクトの設計がいかに破綻していたかの証明です。

Temporal API は、この25年来の負債を清算します。 2026年3月の TC39 会議で Stage 4 に到達し、ES2026 標準としての採用が確定しました。Chrome 144+、Firefox 139+、Edge 144+ で既にネイティブ動作します。

この記事では「Temporal API の概要紹介」で終わらせません。移行時に実際にハマるポイント他の記事が触れていないカスタムフォーマット問題まで踏み込んで解説します。

なぜ Date オブジェクトではダメなのか

Temporal API の価値を理解するには、まず Date の何が壊れているかを正確に把握する必要があります。

ミュータブルという地雷

const meeting = new Date(2026, 2, 29, 14, 0); // 3月29日 14:00
const reminder = meeting;
reminder.setHours(meeting.getHours() - 1); // リマインダーを1時間前に

console.log(meeting.getHours()); // → 13(元のmeetingも変更されている!)

Date はミュータブルです。上のコードでは reminder に代入した時点で参照が共有され、setHours() が元の meeting まで書き換えます。実務ではカレンダー機能やスケジュール管理で、この種のバグが本番リリース後に発覚するケースを何度も見てきました。

タイムゾーンの扱いが壊滅的

// ローカルタイムゾーンとUTCしか扱えない
const date = new Date("2026-03-29T14:00:00");
// → ブラウザのローカルTZで解釈される(環境依存)

// 「東京時間の14時をニューヨーク時間に変換」→ Date単体では不可能
// moment-timezone(+35KB)を追加するしかなかった

不正な日付をサイレントに補正

new Date(2026, 1, 30); // 2月30日 → 3月2日に補正(エラーなし)

これらの問題を Temporal API がどう解決するか、コードで見ていきましょう。

Temporal API の核心:型で意図を表現する

Temporal API の最大の設計思想は、「日付・時刻の概念ごとに型を分ける」 ことです。

用途
Temporal.PlainDate日付のみ誕生日、祝日
Temporal.PlainTime時刻のみ営業開始時刻
Temporal.PlainDateTime日付+時刻(TZなし)ローカルイベント
Temporal.ZonedDateTime日付+時刻+タイムゾーンフライト出発時刻
Temporal.Instant絶対時刻(エポックからのナノ秒)API タイムスタンプ
Temporal.Duration期間「3ヶ月と15日」

Date は1つの型であらゆる概念を無理やり表現していました。Temporal は「誕生日にタイムゾーンは不要」「APIタイムスタンプにカレンダー日付は不要」という実務の意味論を型システムに反映しています。

基本操作

// 日付の作成 — 月は1始まり!
const date = Temporal.PlainDate.from("2026-03-29");
const date2 = new Temporal.PlainDate(2026, 3, 29);

console.log(date.year); // 2026
console.log(date.month); // 3(直感的)
console.log(date.dayOfWeek); // 7(日曜日。1=月曜)

// 完全イミュータブル — 元のオブジェクトは絶対に変わらない
const nextWeek = date.add({ days: 7 });
console.log(date.toString()); // "2026-03-29"(変更なし)
console.log(nextWeek.toString()); // "2026-04-05"

// 不正な日付は即座にエラー
Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 });
// → RangeError: day is out of range(サイレント補正なし)

タイムゾーンの正しい扱い

// 東京時間で会議を設定
const tokyoMeeting = Temporal.ZonedDateTime.from(
  "2026-03-29T14:00:00[Asia/Tokyo]",
);

// ニューヨーク時間に変換(DSTも自動考慮)
const nyMeeting = tokyoMeeting.withTimeZone("America/New_York");
console.log(nyMeeting.toString());
// → "2026-03-29T01:00:00-04:00[America/New_York]"

// 夏時間で存在しない時刻を明示的に拒否
Temporal.ZonedDateTime.from(
  {
    year: 2026,
    month: 3,
    day: 8,
    hour: 2,
    minute: 30,
    timeZone: "America/New_York",
  },
  { disambiguation: "reject" },
);
// → RangeError(2:30 AMはDST移行で存在しない)

disambiguation オプションで「存在しない時刻」や「重複する時刻」の扱いを明示的に制御できます。これは Date では不可能だった機能で、国際対応のスケジューリングアプリでは必須です。

期間計算

const start = Temporal.PlainDate.from("2026-01-01");
const end = Temporal.PlainDate.from("2026-09-15");

const diff = start.until(end, { largestUnit: "month" });
console.log(`${diff.months}ヶ月${diff.days}日`); // "8ヶ月14日"

// 日数だけで取得
const days = start.until(end, { largestUnit: "day" });
console.log(`${days.days}日`); // "257日"

moment.js / date-fns からの移行パターン

よくある操作の対応表

// ============ 現在時刻の取得 ============
// moment:   moment()
// date-fns: new Date()
// Temporal:
const now = Temporal.Now.zonedDateTimeISO();

// ============ 日付の加算 ============
// moment:   moment().add(7, 'days')     ← ミュータブル注意!
// date-fns: addDays(new Date(), 7)
// Temporal:
const future = Temporal.Now.plainDateISO().add({ days: 7 });

// ============ 差分計算 ============
// moment:   moment(end).diff(moment(start), 'days')
// date-fns: differenceInDays(end, start)
// Temporal:
const diffDays = start.until(end, { largestUnit: "day" }).days;

// ============ タイムゾーン変換 ============
// moment:   moment().tz('Asia/Tokyo')    ← moment-timezone必要
// date-fns: formatInTimeZone(...)        ← date-fns-tz必要
// Temporal:
const tokyo = Temporal.Now.zonedDateTimeISO("Asia/Tokyo");

// ============ ISO文字列からパース ============
// moment:   moment('2026-03-29')
// date-fns: parseISO('2026-03-29')
// Temporal:
const parsed = Temporal.PlainDate.from("2026-03-29");

段階的な移行戦略

一気にすべてを置き換える必要はありません。私が推奨する移行ステップは以下です。

Step 1: 新規コードは Temporal で書く

既存コードは触らず、新しく書くコードから Temporal API を使い始めます。Date オブジェクトとの相互変換は Temporal.Instant を経由します。

// Date → Temporal
const legacyDate = new Date();
const instant = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime());
const zdt = instant.toZonedDateTimeISO("Asia/Tokyo");

// Temporal → Date
const backToDate = new Date(zdt.epochMilliseconds);

Step 2: バグの温床になっている箇所から置き換える

タイムゾーン変換、DST境界の処理、日付計算でバグが頻発している箇所を優先的に Temporal に移行します。

Step 3: moment.js / date-fns の依存を削除

新規コードが安定したら、ライブラリへの依存を段階的に除去します。

他の記事が書かない落とし穴:カスタムフォーマット問題

ここからが、私がこの記事で最も伝えたいポイントです。

Temporal API には format('YYYY-MM-DD') に相当するAPIが存在しません。

// moment.js — 直感的なトークンベースフォーマット
moment().format("YYYY年MM月DD日 HH:mm");
// → "2026年03月29日 14:30"

// date-fns
format(new Date(), "yyyy年MM月dd日 HH:mm");

// Temporal — Intl.DateTimeFormat に委譲
const dt = Temporal.Now.plainDateTimeISO();
dt.toLocaleString("ja-JP", {
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
  hour: "2-digit",
  minute: "2-digit",
});
// → "2026/03/29 14:30"(ロケール依存のフォーマット)

これは TC39 の意図的な設計判断です。ロケール処理は Intl に任せるという方針で、カスタムフォーマット文字列は Temporal v2 で検討中(proposal-temporal-v2 Issue #5)という状況です。

実務でどう影響するか

  1. API レスポンスで 2026-03-29T14:00:00Z のような固定フォーマットが必要な場合Temporal.PlainDateTime.prototype.toString() が ISO 8601 を返すので問題なし

  2. ログ出力やCSVで 20260329_1400 のような独自フォーマットが必要な場合 → 自前でフォーマッタを書くか、軽量ユーティリティを用意する必要がある

// 独自フォーマットが必要な場合のヘルパー例
function formatTemporal(dt, pattern) {
  return pattern
    .replace("YYYY", String(dt.year))
    .replace("MM", String(dt.month).padStart(2, "0"))
    .replace("DD", String(dt.day).padStart(2, "0"))
    .replace("HH", String(dt.hour).padStart(2, "0"))
    .replace("mm", String(dt.minute).padStart(2, "0"));
}

const dt = Temporal.PlainDateTime.from("2026-03-29T14:30:00");
formatTemporal(dt, "YYYYMMDD_HHmm"); // → "20260329_1430"
  1. Intl.DateTimeFormat のパフォーマンス → 繰り返し呼び出す場合、Intl.DateTimeFormat オブジェクトを使い回すこと
// ❌ ループ内で毎回生成(ロケールDB検索が走る)
items.forEach((item) => {
  console.log(item.date.toLocaleString("ja-JP", { dateStyle: "medium" }));
});

// ✅ フォーマッタを事前に生成してキャッシュ
const formatter = new Intl.DateTimeFormat("ja-JP", { dateStyle: "medium" });
items.forEach((item) => {
  console.log(formatter.format(item.date));
});

私の結論は、Temporal 採用後もフォーマット用の薄いユーティリティ関数は1つ用意しておくべきということです。moment.js のような巨大ライブラリは不要ですが、10行程度のヘルパーは実務で必ず必要になります。

ブラウザサポートと Polyfill 戦略

2026年3月時点のサポート状況です。

環境バージョン状況
Chrome144+ネイティブサポート
Firefox139+ネイティブサポート
Edge144+ネイティブサポート
SafariTechnology Previewフラグ有効化で利用可。正式サポートは2026年後半見込み
Node.jsv24実験的フラグ付きで利用可

グローバルカバレッジは約64%(Can I use 調べ)。Safari の正式対応待ちが最大のボトルネックです。

Polyfill の選択

# 本番推奨(~20KB gzip)
npm install temporal-polyfill

# フルリファレンス実装(~52KB gzip)
npm install @js-temporal/polyfill
// temporal-polyfill の使い方
import { Temporal } from "temporal-polyfill";

const date = Temporal.PlainDate.from("2026-03-29");

temporal-polyfill はネイティブ実装が存在する場合に自動でフォールバックするため、ネイティブとの共存も問題ありません。Safari 対応が必須のプロダクションでは、現時点では polyfill 併用が現実的な選択です。

いつ移行すべきか

私の考えは明確です。新規プロジェクトなら今日から使うべきです。

  • Chrome/Firefox/Edge で既にネイティブ動作する
  • Polyfill(20KB)は moment.js(72KB)+moment-timezone(36KB)より遥かに軽い
  • イミュータブル設計によりバグが構造的に減る
  • TypeScript との相性も良好(型が細かく分かれているため)

既存プロジェクトでは、前述の段階的移行戦略に従って、まず新規コードから Temporal を導入し、バグの温床になっている日付処理から順に置き換えるのが堅実です。

ただし、カスタムフォーマットを多用しているプロジェクトでは、Temporal v2 のフォーマットAPI追加を待ってからの移行も合理的な判断です。ここは「場合による」ではなく、プロジェクトの format() 呼び出し箇所の数で機械的に判断できます。

まとめ

JavaScript の日付処理は25年間壊れたままでした。Temporal API は9年の開発期間を経て、ようやくその負債を清算するネイティブソリューションとして標準化されました。

  • 月は1始まり(もう0始まりに苦しまない)
  • 完全イミュータブル(副作用バグの根絶)
  • ネイティブタイムゾーンサポート(moment-timezone 不要)
  • 不正な日付は即エラー(サイレント補正なし)
  • 型で意図を表現(PlainDate, ZonedDateTime 等の使い分け)

一方で、カスタムフォーマット文字列の不在と Safari の正式対応遅れは、移行計画に織り込む必要があります。

25年越しの Date からの卒業。その準備は、もう整っています。

WRITTEN BY nidoneko

Full-stack engineer with 8+ years of experience in TypeScript, React, Node.js, and cloud-native development across healthcare, finance, HR, and IoT domains.

View Profile →