第36章:日時のVO:時間は地雷⏰🧨
今回は「時間まわりの事故」をVOでガチガチに減らす章だよ〜!🧯✨ “動いてるけど、ある日突然バグる”の代表が時間…😵💫 なので、ここで型とVOで守れるようにしよっ💪💎
0. 2026-02-07時点の“最新事情”メモ🗞️✨
-
いま Temporal(新しい標準日時API) が本格的に現実になってきてるよ〜!
- **Chrome 144(2026-01-13)**で「Temporal in ECMA262」がリリースノートに載ってるよ📌 (Chrome for Developers)
- Firefox 139 は Temporal をデフォルト有効にして“最初に出したブラウザ”って明言してるよ🦊✨ (SpiderMonkey)
-
ただし MDNでは “Limited availability(まだ全ブラウザの基準になってない)” 扱い!つまり「環境によってはそのまま使えない」ことがあるよ⚠️ (MDN Web Docs)
-
仕様としては TC39 の提案で、GitHubのREADME上は Stage 3。一方で Stage 4 マイルストーンはかなり進んでる…って状態🧩 (GitHub)
だからこの章は結論:Temporalを前提に設計しつつ、学習プロジェクトは polyfill を噛ませて安定運用がいちばん安心だよ〜🧸🛡️
1. なぜ「時間」が地雷なの?💣😇(よくある爆発パターン)
💥 地雷①:Date の文字列パースがややこしい
たとえば YYYY-MM-DD みたいな “日付だけ” は、タイムゾーン情報が無いと UTCとして解釈されるケースがあるよ(歴史的な仕様の事情…)😵💫 (MDN Web Docs)
→ 結果、表示したら前日/翌日になったとか起きる💀
💥 地雷②:タイムゾーンと夏時間(DST)
- 「同じ 10:00」でも国によって意味が違う
- DSTで 存在しない時刻 / 2回出てくる時刻 が出る → 期限判定とか予約時刻とかがズレる😇
💥 地雷③:用途に合わない“ひとつの型”で全部やろうとする
- “絶対時刻(ログ/決済)” と “現地の時計(予約/営業時間)” と “日付だけ(有効期限)” これ、同じにすると破綻するよ〜🙅♀️
2. この章の結論🎯✨:日時は「用途別VO」に分ける!
Temporal は「用途別の型」を最初から用意してくれてるのが強い💎 (MDNでも Temporal は Date の置き換えとして設計されてるって説明されてるよ)(MDN Web Docs)

✅ まず覚える3分類(これだけで勝率上がる🥳)
-
絶対時刻(いつ発生したか) →
Temporal.Instant(UTCの一点) 例:支払い完了時刻、イベント発行時刻、監査ログ -
タイムゾーン付きの日時(現地の時計) →
Temporal.ZonedDateTime(Instant+TimeZone) 例:受け取り予約「2026-02-10 10:00(Asia/Tokyo)」、営業時間判定 -
日付だけ →
Temporal.PlainDate例:「有効期限は 2026-02-10 まで」、請求締め日、誕生日
ここがVO化ポイント💡 「ドメインの言葉」で VO を作る(例:
PickupAt,PaymentDeadline,ExpiryDate)と、読みやすさが爆上がりするよ📈✨
3. Temporal を“確実に使える”ようにする(polyfill)🧩🛠️
MDN的にまだ “どこでも当たり前” ではないので、学習プロジェクトは polyfill が安心!(MDN Web Docs)
📦 polyfill(例:@js-temporal/polyfill)
npmの説明で「ESMで import」例が載ってるよ📌 (npmjs.com)
// src/shared/temporal.ts
import { Temporal } from "@js-temporal/polyfill";
export { Temporal };
※もう一つ軽量 polyfill もあるよ(fullcalendar側)📦 (GitHub) でもこの章は「説明が一番多い @js-temporal/polyfill」でいくね😊
4. 実装してみよう:日時VOの基本形(Resultで失敗も型に)🧪💎
ここでは「生成時に検証して、以降は安全」スタイルでいくよ〜🔒✨ (VOの鉄則:作った瞬間に正しい)
4-1) Result型(超ミニ)
// src/domain/shared/result.ts
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: Error };
export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
export const err = (error: Error): Result<never> => ({ ok: false, error });
4-2) タイムゾーンID VO(まずここで地雷除去🧯)
Temporalは無効なタイムゾーンだと例外を投げるので、VO生成で吸収するよ🧸
// src/domain/time/timeZoneId.ts
import { Temporal } from "../../shared/temporal";
import { Result, ok, err } from "../shared/result";
export class TimeZoneId {
private constructor(private readonly value: string) {}
static create(value: string): Result<TimeZoneId> {
try {
// 無効なら RangeError
Temporal.TimeZone.from(value);
return ok(new TimeZoneId(value));
} catch {
return err(new Error(`Invalid time zone: ${value}`));
}
}
toString(): string {
return this.value;
}
}
5. 主役VO①:絶対時刻 UtcInstant(期限・ログ用)⏱️🧊
「いつ発生したか」系は、Instant一本でほぼ勝てる🥇 (タイムゾーンに引きずられないのが最高✨)
// src/domain/time/utcInstant.ts
import { Temporal } from "../../shared/temporal";
import { Result, ok, err } from "../shared/result";
export class UtcInstant {
private constructor(private readonly value: Temporal.Instant) {}
static fromISO(iso: string): Result<UtcInstant> {
try {
return ok(new UtcInstant(Temporal.Instant.from(iso)));
} catch {
return err(new Error(`Invalid instant ISO: ${iso}`));
}
}
static now(): UtcInstant {
return new UtcInstant(Temporal.Now.instant());
}
isBefore(other: UtcInstant): boolean {
return this.value.epochNanoseconds < other.value.epochNanoseconds;
}
addSeconds(seconds: number): UtcInstant {
return new UtcInstant(this.value.add({ seconds }));
}
toISO(): string {
return this.value.toString(); // 例: 2026-02-07T00:00:00Z
}
equals(other: UtcInstant): boolean {
return this.value.epochNanoseconds === other.value.epochNanoseconds;
}
}
6. 主役VO②:現地の日時 ZonedDateTimeVo(予約・営業時間)🗓️🌏
Temporalの ZonedDateTime は「Instant + TimeZone」って位置づけが明確なのが良い💎 (MDN Web Docs)
ここでは「予約時間」に使うVOを作るよ☕📦
// src/domain/time/zonedDateTimeVo.ts
import { Temporal } from "../../shared/temporal";
import { Result, ok, err } from "../shared/result";
import { TimeZoneId } from "./timeZoneId";
import { UtcInstant } from "./utcInstant";
export class ZonedDateTimeVo {
private constructor(private readonly value: Temporal.ZonedDateTime) {}
static fromISO(iso: string): Result<ZonedDateTimeVo> {
try {
// 例: 2026-02-10T10:00:00+09:00[Asia/Tokyo]
return ok(new ZonedDateTimeVo(Temporal.ZonedDateTime.from(iso)));
} catch {
return err(new Error(`Invalid zoned date-time ISO: ${iso}`));
}
}
static fromLocal(
date: string, // "2026-02-10"
time: string, // "10:00"
tz: TimeZoneId
): Result<ZonedDateTimeVo> {
try {
const plain = Temporal.PlainDateTime.from(`${date}T${time}`);
const zdt = plain.toZonedDateTime(tz.toString());
return ok(new ZonedDateTimeVo(zdt));
} catch {
return err(new Error(`Invalid local date/time: ${date} ${time}`));
}
}
toInstant(): UtcInstant {
// 期限判定など「比較」は Instant に寄せると事故が減る✨
return UtcInstant.fromISO(this.value.toInstant().toString()).value;
}
toString(): string {
return this.value.toString();
}
}
toInstant()のところで.valueを直接使ってるのは「ここでは失敗しない前提」だからだよ〜(気になるなら Result を返す形にしてOK!)😊
7. ドメイン例題:カフェの「受け取り予約」と「支払い期限」☕⏰
🎀 要件(かわいく言うと)
-
受け取り時間
PickupAtは お店のタイムゾーン基準(現地の時計) -
支払い期限
PaymentDeadlineは 絶対時刻(UTCで比較) -
ルール:
- 受け取りは「今から30分以降」
- 支払い期限は「注文確定から15分以内」
※「今」はテストが難しくなるので、この章は nowを引数で渡す方式にするよ(Clock注入は後半でガッツリやる🧪)
// src/domain/order/pickupAt.ts
import { Result, ok, err } from "../shared/result";
import { ZonedDateTimeVo } from "../time/zonedDateTimeVo";
import { UtcInstant } from "../time/utcInstant";
export class PickupAt {
private constructor(private readonly value: ZonedDateTimeVo) {}
static create(
pickup: ZonedDateTimeVo,
now: UtcInstant
): Result<PickupAt> {
const pickupInstant = pickup.toInstant();
// 今から30分以上先
const min = now.addSeconds(30 * 60);
if (pickupInstant.isBefore(min)) {
return err(new Error("Pickup time must be at least 30 minutes from now"));
}
return ok(new PickupAt(pickup));
}
toString(): string {
return this.value.toString();
}
}
// src/domain/order/paymentDeadline.ts
import { Result, ok, err } from "../shared/result";
import { UtcInstant } from "../time/utcInstant";
export class PaymentDeadline {
private constructor(private readonly value: UtcInstant) {}
static fromConfirmedAt(confirmedAt: UtcInstant): PaymentDeadline {
// 15分以内
return new PaymentDeadline(confirmedAt.addSeconds(15 * 60));
}
isExpired(now: UtcInstant): boolean {
return this.value.isBefore(now);
}
toISO(): string {
return this.value.toISO();
}
}
8. テスト:時間VOはテストしやすい!🧪💖(境界値で守る)
時間系は「1分前」「ちょうど」「1分後」みたいな境界で事故るから、そこを刺すよ🔪✨
// src/domain/order/pickupAt.test.ts
import { describe, it, expect } from "vitest";
import { TimeZoneId } from "../time/timeZoneId";
import { ZonedDateTimeVo } from "../time/zonedDateTimeVo";
import { UtcInstant } from "../time/utcInstant";
import { PickupAt } from "./pickupAt";
describe("PickupAt", () => {
it("30分未満はNG", () => {
const tz = TimeZoneId.create("Asia/Tokyo");
if (!tz.ok) throw tz.error;
const now = UtcInstant.fromISO("2026-02-07T00:00:00Z");
if (!now.ok) throw now.error;
const pickup = ZonedDateTimeVo.fromISO("2026-02-07T09:20:00+09:00[Asia/Tokyo]");
if (!pickup.ok) throw pickup.error;
const res = PickupAt.create(pickup.value, now.value);
expect(res.ok).toBe(false);
});
it("30分ちょうどはOK", () => {
const tz = TimeZoneId.create("Asia/Tokyo");
if (!tz.ok) throw tz.error;
const now = UtcInstant.fromISO("2026-02-07T00:00:00Z");
if (!now.ok) throw now.error;
// 00:30Z = 09:30 JST
const pickup = ZonedDateTimeVo.fromISO("2026-02-07T09:30:00+09:00[Asia/Tokyo]");
if (!pickup.ok) throw pickup.error;
const res = PickupAt.create(pickup.value, now.value);
expect(res.ok).toBe(true);
});
});
9. アンチパターン集😂⚠️(これやると爆発しがち)
❌ ドメインに Date を直置き
Date はパースや扱いがややこしく、仕様的にも罠があるよ…(MDN Web Docs)
→ ドメインは VOで意味を持たせるのが正解💎
❌ “日時文字列”をそのまま持つ
"2026-02-10 10:00" みたいなのは
- タイムゾーン不明
- 比較が面倒
- 将来フォーマット変更で死ぬ の三重苦🥹
❌ 比較を「ローカルの見た目」でやる
比較は Instant(絶対時刻)に寄せるのが安全👍✨
(だから ZonedDateTimeVo.toInstant() を用意したよ〜)
10. AI活用🤖💞:時間バグを“先に潰す”使い方
🎯 使えるプロンプト例(コピペOK)
- 「受け取り予約の時間バグのパターンを20個、境界値中心で出して。タイムゾーン/DST/日付跨ぎを混ぜて」
- 「
PickupAtの仕様から Given/When/Then を10個作って。正常系3、異常系7で」 - 「このVO設計、ドメインに漏れてる“暗黙ルール”を指摘して(例:営業時間、定休日、最終受け取りなど)」
AIは “コードを書く” より、地雷の洗い出し係にするとめっちゃ強いよ〜🧠⚡
11. 章末ミニ演習🎓✨(手を動かすやつ)
🧁 演習A:ExpiryDate(PlainDate)を作ろう
- ルール:今日より前はNG
- 「30日以内」など上限をつけてもOK
🍩 演習B:BusinessHours(PlainTime×2)で営業時間チェック
- open: 09:00 / close: 18:00
PickupAtが営業時間内かを判定して、営業時間外ならエラーにする
12. 今日のまとめ🌸✨
- 時間は地雷!だから 用途別VO に分ける⏰🧨
- Temporalは「その用途別の型」が最初から用意されてるのが強い💎 (TC39)
- 2026-02-07時点ではブラウザ差がまだあるので polyfillで統一が安心🧸🛡️ (MDN Web Docs)
- 比較は Instant に寄せると事故が減る✨
次の第37章は「VOの合成」だよね📦💖
この章で作った TimeZoneId / UtcInstant / ZonedDateTimeVo を部品にして、Receipt や Address みたいな複合VOを気持ちよく作れるようになるよ〜!🎉