TypeScriptのBrand型でタイムゾーン事故を減らせないか考えてみた

TypeScriptのBrand型でタイムゾーン事故を減らせないか考えてみた

2026.02.02

React + TypeScript の開発で、日付・タイムゾーン周りに毎回悩まされるという人は多いと思います。

  • API では "YYYY-MM-DD" の文字列
  • UI では DatePicker が DateDayjs
  • 業務ロジックでは Date / string / 日付ライブラリが混在
  • タイムゾーン起因の日付のずれ

「毎回ちゃんと考えているはずなのに、なぜか事故る」
日付はその代表例です。

この記事では、

  1. まず知っておきたいデファクトな考え方
  2. それでも起きがちな実務上の弱点
  3. TypeScriptで事故を減らすための制約を設ける案

を整理します。

「ベストプラクティスを見つけた!」という話ではなく、どうしたらいいかを自分なりに考えてみた結果を共有します。


まず知っておきたいデファクトな考え方

日付・タイムゾーン周りで、比較的広く共有されている考え方は以下のようなものかと思います。

1. 「日付だけ」と「日時(瞬間)」は別物

  • 日付だけ
    • 誕生日、締切日、請求対象日など
    • "YYYY-MM-DD" のように タイムゾーンを含めない
  • 日時(瞬間)
    • ログ、イベント発生時刻、予約開始時刻など
    • ISO 8601 + offset / Z(UTC)

これを同じ Datestring で扱うと地獄が始まります。


2. API境界では文字列、内部では日付オブジェクト

  • API:string
  • UI / 業務ロジック内部:Dayjs / Date など
  • 送信時に文字列へ戻す

この「境界で変換する」設計は、多くの現場で採用されています。


3. 表示時だけタイムゾーン変換

  • 内部は UTC or 意図した基準で保持
  • 表示する直前だけユーザーTZへ変換

ここまでが、いわゆる デファクト寄りの整理です。


それでも起きがちな実務上の問題

これらを意識していても、次のような事故は起きがちです。

  • API用 "YYYY-MM-DD" と表示用 "YYYY/MM/DD" を同じ string で扱ってしまう
  • DatePicker から出た値を、そのまま API に投げてしまう
  • 「この string は検証済み」という前提がレビュー頼り
  • Date / string / Dayjs が業務ロジックで混在する

これらはライブラリやタイムゾーンの問題というより、境界と責務の問題です。


型で制約を設けるという考え方

そこで一案として考えられるのが、

デファクトな設計を前提に、TypeScriptで「間違えにくくする」

というアプローチです。

ポイントは3つだけです。


1. API用日付文字列を Brand 型で区別する

declare const ApiDateBrand: unique symbol;

export type ApiDateString = string & {
  readonly [ApiDateBrand]: "YYYY-MM-DD";
};
  • ただの string と API 用日付文字列を分ける
  • 「APIに渡していいのはこれだけ」という意思表示

型で意図を伝えるだけでも、レビュー負荷は下がります。


2. 入口で変換し、内部表現を統一する

import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
dayjs.extend(customParseFormat);

export const parseDate = (raw: string): Dayjs => {
  const d = dayjs(raw, "YYYY-MM-DD", true);
  if (!d.isValid()) {
    throw new Error(`Invalid date: ${raw}`);
  }
  return d;
};
  • APIから来た string入口で必ずパース
  • 内部では Dayjs に統一

3. 出口でだけ API 用文字列を生成する

export const toApiDateString = (d: Dayjs): ApiDateString => {
  if (!d.isValid()) {
    throw new Error("Invalid Dayjs");
  }
  return d.format("YYYY-MM-DD") as ApiDateString;
};
  • API送信直前でのみ文字列化
  • Brand 型を付けて「検証済み」を明示

Result / Option 型との関係

ここでよく出る疑問が、「Result 型や Option 型で良くない?」というものです。

結論から言うと、役割が違います。

  • Result / Option
    • 関心:成功 / 失敗を値として扱う
    • パース失敗、入力エラーの表現に強い
  • 今回の設計(Brand型)
    • 関心:どこで変換し、どこまで信用するか
    • 境界・型の分離

どちらか一方というものではなく、補完関係にあります。


このやり方が向いているケース

  • 人数が多いチーム
  • 日付をやり取りするAPIが多い
  • 日付項目が多く、レビューコストが高い

向いていないケース

  • 小規模・短期案件
  • 日付を扱う箇所が少ない
  • ルール運用で十分管理できるチーム

まとめ

個人的に色々悩むことが多い日付周りの実装についてまとめてみました。
参考になれば幸いです。

この記事をシェアする

FacebookHatena blogX

関連記事