JSONに日付型はない。だから決めて、書き残す
JSONの型は6つあり、そのどれも日付ではありません。だからコードベースごとに日付を発明します。よくある4つの答えのうち、安全なのは1つだけです。
このページの記述はすべて実測か出典付きです。そのどちらでもない場合は、そのことを明記しています。
管理画面のテーブルのレコードが全部1970年1月21日に作成されている、という報告が上がってきます。ペイロードは問題なさそうに見えます。
{ "created_at": 1735689600 }
これはエポック秒での2025年1月1日です。フロントエンドはnew Date(1735689600)を呼びましたが、これはミリ秒を取るので、エポックから1,735,689秒後として読み、1970年に入って3週間の地点に着地しました。例外は何も投げられていません。数値は妥当で、型も正しく、そして意味だけが2つのサービスのあいだで失われました。JSONにはそれを運ぶ手段がないからです。
RFC 8259が与えるのはオブジェクト、配列、文字列、数値、真偽値、nullです。日付はありません。あなたが送るのは、両者が私的に解釈を取り決めた文字列か数値であり、その取り決めはドキュメントの中か、誰の頭の中にもないかのどちらかに住んでいます。
出会うことになる4つの慣習
| 慣習 | 例 | 何であるか |
|---|---|---|
| RFC 3339の文字列 | "2025-01-01T00:00:00Z" |
自己記述的、ソート可能、曖昧さがない |
| エポック秒 | 1735689600 |
現在は10桁、オフセットなし、単位の表示なし |
| エポックミリ秒 | 1735689600000 |
現在は13桁、JavaScriptの既定 |
| ASP.NET AJAX | "\/Date(1735689600000)\/" |
文字列にくるまれたミリ秒。古い.NET APIにいまも残る |
後ろの2つを見分けるフィールドレベルの手がかりは桁数だけです。現在のエポック秒は10桁で、2286年まで10桁のままです。同じ瞬間をミリ秒で表せば13桁になります。スキーマのないフィードを引き継いだら、推測する前に桁を数えてください。そして、秒の値をミリ秒のAPIに渡すと必ず1970年の初頭に着地することを覚えておいてください。このバグがこれほど見覚えのある形で現れるのはそのためです。
4つ目はJSONの意味でも本物の文字列です。バックスラッシュは/に対する正当なエスケープなので、パース後に手元に残るのはリテラルなテキスト/Date(1735689600000)/であり、正規表現をかける必要があります。/Date(1735689600000-0800)/のようにオフセットを伴う変種もありますが、そのオフセットは飾りです。数値はすでにUTCです。
RFC 3339はISO 8601とまったく同じではない
人は2つの名前を互換に使い、そのあとで一方が受け入れるものをもう一方が拒みます。RFC 3339はISO 8601のプロファイルです。機械同士が食い違えないように選ばれた、より小さく厳しい文法です。
ISO 8601はRFC 3339が許さないものを許します。
- 区切りのない基本形式、
20250101T000000Z - 週日付(
2025-W01-3)と序数日付(2025-001) 2025-01や単に2025のような精度の削減- 秒の小数点にカンマを使う形、
00:00:00,5 - オフセットをまったく持たない現地時刻
RFC 3339は常に、完全な日付、完全な時刻、そしてオフセットを要求します。またISO 8601が禁じるものをひとつ許します。オフセット-00:00で、これは瞬間は分かっているが現地のオフセットは分からない、という意味です。パーサーやバリデーターを書くなら、-00:00と+00:00は同じ瞬間であり、別の主張です。
実践的な指針: RFC 3339で出力する。Tは大文字、Zは大文字、秒は整数か小数点以下ちょうど3桁のどちらか。必要ならもう少し広く受け入れてもよいが、出力は決して広げないこと。
Zはオフセットであって、オフセットの不在ではない
Zはオフセットが+00:00であることを意味します。それは瞬間についての事実です。「タイムゾーンなし」と言う手段ではありませんし、「このレコードのタイムゾーンはUTCだ」と言う手段でもありません。それらは別物であり、その違いこそがこの話を難しくしています。
"2025-01-01T00:00:00Z"と"2025-01-01T09:00:00+09:00"は同じ瞬間です。入り口ですべてをZに正規化すれば、瞬間は保ちつつ、利用者がどこにいたかを捨てたことになります。created_atにはたいてい正しく、カレンダーの予定にはたいてい誤りです。予定では利用者が気にするのは現地の壁時計であり、オフセットはその日が来るまで分からないことすらあります。そうした値には、現地時刻とIANAのゾーン名(+01:00ではなくEurope/Berlin)を別々のフィールドに保存してください。オフセットは年に2回変わり、政府は短い予告で変更します。
オフセットのないタイムスタンプを決して出力しないでください。"2025-01-01T00:00:00"は、どの機械が読むかで意味が変わる文字列であり、JavaScriptとPythonでは解決のされ方が違います。
暦日はタイムスタンプではない
誕生日、請求書の支払期日、祝日は瞬間ではありません。時刻もオフセットも持たず、それらを付けることは、利用者の半分に対して1日ずれるバグとして現れます。
new Date('1990-07-14').toLocaleDateString('ja-JP')
// UTCより西のどこでも '1990/7/13'
ECMAScript仕様は日付のみの形式をUTCの深夜としてパースし、そのあと現地のフォーマッタが逆方向へずらします。"1990-07-14"はただの文字列として送り、文字列のまま保ち、Dateを経由せずに整形してください。「数時間ぶんだけ間違う」ことがありえない値なら、時刻を持つべきではありません。
JavaScript固有の事情
直列化は何もしなくても動きます。Date.prototype.toJSONがtoISOStringを呼ぶからです。
JSON.stringify({ at: new Date(0) })
// '{"at":"1970-01-01T00:00:00.000Z"}'
JSON.stringify({ at: new Date(NaN) })
// '{"at":null}' 有限でない日付にはtoJSONがnullを返す。例外は投げない
パースはまったく動きません。JSON.parseは文字列が日付だと知る術がないので、往復すると文字列が返り、あとで何かがそれに.getTime()を呼んだ時点でバグが表面化します。よく使われる当て木がreviverです。
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
JSON.parse(text, (key, value) =>
typeof value === 'string' && RFC3339.test(value) ? new Date(value) : value
);
この手法には注意が2つ。まずヒューリスティックであること。タイムスタンプらしく見える文字列は、利用者の自由記述欄であっても日付になります。そしてDateはオフセットを畳んでしまうので、+09:00はUTCの瞬間として返り、元のオフセットは消えます。キーのパスで復元するか、いっそ復元せず、使う場所で明示的に変換するほうがよいでしょう。
Dateの後継であるTemporalには、これらの区別を正しくモデル化した型(Instant、PlainDate、ZonedDateTime)があり、PlainDateはこの記事がずっと求めてきた暦日型そのものです。執筆時点でブラウザへの搭載が始まっています。依存する前に現在の対応状況を確認し、ポリフィルのバンドルサイズが許容できるかも確認してください。
Python固有の事情
datetimeはJSONに直列化できず、人が最初に手を伸ばす回避策は微妙に間違っています。
import json
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
json.dumps({"at": now})
# TypeError: Object of type datetime is not JSON serializable
json.dumps({"at": now}, default=str)
# '{"at": "2025-01-01 00:00:00+00:00"}' 区切りが空白。RFC 3339ではない
json.dumps({"at": now}, default=lambda o: o.isoformat())
# '{"at": "2025-01-01T00:00:00+00:00"}' 正しい
default=strが返すのはstr(datetime)で、Tの代わりに空白を使います。読みやすくはあり、そしてRFC 3339ではないので、厳格な消費側は拒否します。
読み戻す側では、datetime.fromisoformatはPython 3.11以降でZの接尾辞を扱えます。3.10以前ではValueError: Invalid isoformat stringを投げるため、古いコードの多くが呼び出しの前に.replace("Z", "+00:00")を抱えているのです。またisoformat()はZではなく+00:00を出力する点にも注意してください。消費側がZを要求するなら、出力の際に置換してください。
スキーマは既定では救ってくれない
分かりやすい一手は形を宣言することです。
{
"type": "object",
"properties": {
"created_at": { "type": "string", "format": "date-time" },
"due_on": { "type": "string", "format": "date" }
},
"required": ["created_at"]
}
JSON Schema 2019-09と2020-12では、formatは既定でアノテーションであり、表明(assertion)ではありません。素の状態では、ほとんどのバリデーターがこのスキーマに対して"created_at": "きのう"を平気で通します。文字列であり、formatキーワードは意図を述べているだけだからです。表明として働かせるには明示的に有効化する必要があります(Ajvならajv-formatsの追加)。アノテーションと表明の語彙がどう分かれるかはJSON Schema入門を、実際のペイロードから最初の草案を作るにはスキーマ生成ツールをどうぞ。
何を送り、何を受け入れるか
送る側は、瞬間にはオフセットを明示したRFC 3339を。読み手にとって現地のオフセットに意味がある場合を除き、Zに正規化します。暦日にはただのYYYY-MM-DD文字列を送ります。型が自明になるようフィールド名を付けてください。瞬間ならcreated_at、日付ならdue_on。どうしてもエポックを送るなら、単位が一緒に旅するようexpires_at_msと名付けます。
受け入れる側は、小数秒の有無を問わずRFC 3339を、オフセットは+HH:MM形式でもZ形式でも受け、オフセットのないものは推測せず拒否します。何かを組み立てる前に文字列を検証してください。new Date("でたらめ")はInvalid Dateを返し、それは静かに伝播します。
実際のペイロードをバリデーターに貼り付けて構造の健全性を確かめ、そのうえで日付フィールドは自分の目で読んでください。すべての数値について桁を数え、すべてのタイムスタンプ文字列がオフセットで終わっているかを確認する。この2つの確認で、この記事が述べたことのほとんどは捕まえられます。残りはAPIレスポンスの設計が扱っており、そこではフィールド命名の判断を一度下したら二度と蒸し返しません。