ながたのメモ帳

ISO8601 準拠のタイムスタンプを、TypeScript の型で表現する

2026年7月6日 公開読了 約13分

#TL;DR

TypeScript の型レベルで ISO8601 のバリデーションをやってみたかった。
うるう年判定やタイムゾーン解析まで含めて、型だけで不正な日付を弾けるやつです。
Gistはここ

usecase.ts
コピーコピー済みTYPESCRIPT
// OK
type A = ISO8601<"2026-07-05">; // "2026-07-05"
type B = ISO8601<"2026-07-05T12:30:00+09:00">; // "2026-07-05T12:30:00+09:00"

// NG (2月30日は存在しない)
type C = ISO8601<"2026-02-30">; // never
// NG (うるう年でない年の2月29日)
type D = ISO8601<"2023-02-29">; // never

##なぜ作ろうとしたか

#TODO 日本語がおかしい 本ブログのソースコードを書いているとき、記事の frontmatter に「公開日時」の項目があるんですよね。

YAML
コピーコピー済みYAML
date: 2026-07-05

こういうやつです。ビルドスクリプトを TypeScript で書いているので、この日付を扱う型が string なのがなんとなく気持ち悪い。
どうせなら型レベルで ISO8601 を強制できないか、と思ったのがきっかけです。

#literal type の展開上限という壁

最初は素朴にこう書けばいいのでは、と思いました。

TYPESCRIPT
コピーコピー済みTYPESCRIPT
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type YYYY = `${Digit}${Digit}${Digit}${Digit}`;
type MM = `0${1|2|3|4|5|6|7|8|9}` | `1${0|1|2}`;
type DD = `0${1|2|3|4|5|6|7|8|9}` | `${1|2}${Digit}` | `3${0|1}`;
type ISO8601Date = `${YYYY}-${MM}-${DD}`;

一見よさそうなんですが、TypeScript の template literal type は100,000 個程度でユニオンの展開上限にあたります。
YYYY だけで 10^4 = 10,000 通り、日付 が 365 通りとすると、日付だけで 10,000 ×365 = 3,650,000 通り。余裕でアウトです。

時刻まで含めたら天文学的な数字になってしまいます。

つまり、const publishAt: ISO8601Date = "2026-02-30" のようにユニオン型として直接チェックする方法は不可能ということですね。
年の範囲を 1900~2100 くらいに絞ればいけなくもないですが、私は秒まで、できればタイムゾーンまでやりたい!

ということで、Generics と Conditional Types で頑張る方針に切り替えました。

#パーツから組み立てていく

ISO8601 のフォーマットは YYYY-MM-DDTHH:mm:ss±HH:MM という構造なので、以下のパーツに分けて型を作っていきます。

  1. 日付 (ISO8601Date)
  2. 時刻 (ISO8601Time)
  3. タイムゾーン (ISO8601Timezone)
  4. それらの合成 (ISO8601DateTimeISO8601)

##日付部分は簡単

まずは基本のパーツから。

TYPESCRIPT
コピーコピー済みTYPESCRIPT
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type NonZeroDigit = Exclude<Digit, 0>;

type Year = `${Digit}${Digit}${Digit}${Digit}`;
type Month = `0${NonZeroDigit}` | `1${0 | 1 | 2}`;
type FullDay = `0${NonZeroDigit}` | `${1 | 2}${Digit}` | `3${0 | 1}`;

Year, Month, FullDay はそれぞれユニオン型として展開するのではなく、あくまで infer でパースした文字列が合致するかどうかを判定するためのパターンとして使います。

日付の型はこうなりました。

iso8601.ts
コピーコピー済みTYPESCRIPT
type ISO8601Date<S extends string = string> =
  S extends `${infer Y}-${infer M}-${infer D}`
    ? `${Y}` extends Year
      ? `${M}` extends Month
        ? `${D}` extends Day<`${Y}`, `${M}`>
          ? S
          : never
        : never
      : never
    : never;

infer を使って - で分割し、それぞれのパーツが Year, Month, Day に合致するかをチェックしているだけです。合致すれば元の文字列 S をそのまま返し、ダメなら never を返す。ここまではシンプルですね。

ちなみに、もし infer を使わずに全パターンのユニオンを展開しようとすると、先述の通り爆発します。infer で文字列を分割してからパターンマッチする、というのが今回の全体を通しての肝になっています。

##うるう年判定は気をつけないと汚くなる

ここがちょっと厄介でした。Day 型は月によって最大値が変わるし、2月に至ってはうるう年かどうかで変わります。

うるう年の判定ルールは

  • 400で割り切れる → うるう年
  • 100で割り切れる → うるう年ではない
  • 4で割り切れる → うるう年

なので、型レベルでもこの優先順位で判定する必要があるわけですね。

is-leap-year.ts
コピーコピー済みTYPESCRIPT
type IsLeapYear<year extends Year> = year extends
  | `${0 | 2 | 4 | 6 | 8}${0 | 4 | 8}00`
  | `${1 | 3 | 5 | 7 | 9}${2 | 6}00`
  ? true
  : year extends `${Digit}${Digit}00`
    ? false
    : year extends
          | `${Digit}${Digit}${0 | 2 | 4 | 6 | 8}${0 | 4 | 8}`
          | `${Digit}${Digit}${1 | 3 | 5 | 7 | 9}${2 | 6}`
      ? true
      : false;

TypeScript の型には割り算がないので、「4で割り切れる」を template literal のパターンマッチで表現しています。
4で割り切れる数の下2桁のパターンを桁ごとに分解すると

  • 十の位が偶数(0,2,4,6,8)のとき、一の位は 0, 4, 8
  • 十の位が奇数(1,3,5,7,9)のとき、一の位は 2, 6

になります。400で割り切れるかも同様の考え方で、上2桁に対してこのパターンを適用しつつ下2桁が 00 であることをチェックしています。

これを使って Day 型を定義します。

day.ts
コピーコピー済みTYPESCRIPT
type Day<year extends Year, month extends Month> = month extends "02"
  ? IsLeapYear<year> extends true
    ? Exclude<FullDay, "30" | "31">
    : Exclude<FullDay, "29" | "30" | "31">
  : month extends "04" | "06" | "09" | "11"
    ? Exclude<FullDay, "31">
    : FullDay;

2月はうるう年なら29日まで、そうでなければ28日まで。4,6,9,11月は30日まで。それ以外は31日まで。ここは素直です。

##時刻部分

時刻は HH:mm:ss に加えて、小数秒(.123,456)もサポートしたい。

TYPESCRIPT
コピーコピー済みTYPESCRIPT
type Hour = `${0 | 1}${Digit}` | `2${0 | 1 | 2 | 3}`;
type Minute = `${0 | 1 | 2 | 3 | 4 | 5}${Digit}`;
type Second = `${0 | 1 | 2 | 3 | 4 | 5}${Digit}`;

小数秒の桁数は任意なので、再帰的に「全部数字か」をチェックするヘルパーを用意しました。

is-only-digit.ts
コピーコピー済みTYPESCRIPT
type IsOnlyDigit<S extends string = string> = S extends `${Digit}`
  ? true
  : S extends `${Digit}${infer r}`
    ? IsOnlyDigit<r>
    : false;

これらを組み合わせて ISO8601Time にします。

iso8601time.ts
コピーコピー済みTYPESCRIPT
type ISO8601Time<S extends string> =
  S extends `${infer hour}:${infer minute}:${infer second}${"." | ","}${infer subsecond}`
    ? [
        IsHour<`${hour}`>,
        IsMinute<`${minute}`>,
        IsSecond<`${second}`>,
        IsOnlyDigit<`${subsecond}`>,
      ] extends [true, true, true, true]
      ? S
      : never
    : S extends `${infer hour}:${infer minute}:${infer second}`
      ? [IsHour<`${hour}`>, IsMinute<`${minute}`>, IsSecond<`${second}`>] extends [true, true, true]
        ? S
        : never
      : never;

${"." | ","} の部分がちょっと面白くて、ISO8601 では小数点にカンマもピリオドも使えるんですよね(これは知らなかったんですが)。

最初は extends のパターンをユニオンにしてまとめて書けないかと思ったんですが、

TYPESCRIPT
コピーコピー済みTYPESCRIPT
S extends `${infer hour}:${infer minute}:${infer second}${"." | ","}${infer subsecond}`
        | `${infer hour}:${infer minute}:${infer second}`

こうすると、"12:30:45.678" に対して両方のパターンにマッチしてしまい、second"45" | "45.678" のユニオンに、subsecond も不定になってしまいます。infer がパターンごとに独立して推論した結果をユニオンにまとめてしまうわけですね。

なので、小数秒ありのパターンを先にチェックして、マッチしなければ小数秒なしで試す、という順番にしました。

##タイムゾーン周りが結構複雑

ISO8601 のタイムゾーン表記には以下のバリエーションがあります。

  • Z (UTC)
  • +09:00 / -05:30 (コロン付き)
  • +0900 / -0530 (コロンなし4桁)
  • +09 / -05 (時のみ2桁)

意外と多い。全部対応していきます。

iso8601timezone.ts
コピーコピー済みTYPESCRIPT
type ISO8601Timezone<S extends string> = S extends "Z"
  ? S
  : S extends `${"+" | "-"}${infer Offset}`
    ? Offset extends `${infer hour}:${infer minute}`
      ? [IsHour<`${hour}`>, IsMinute<`${minute}`>] extends [true, true]
        ? S
        : never
      : Offset extends `${infer hour1}${infer hour2}${infer minute1}${infer minute2}`
        ? [IsHour<`${hour1}${hour2}`>, IsMinute<`${minute1}${minute2}`>] extends [true, true]
          ? S
          : never
        : IsHour<Offset> extends true
          ? S
          : never
    : never;

Z → コロン付き → 4桁 → 2桁、の順で試していく形です。

ここで気になるのが、コロンなし4桁のパターンで ${infer TH1}${infer TH2}${infer TM1}${infer TM2} と一文字ずつ infer している部分ですかね。
コロン付きの ${infer TH}:${infer TM} なら : がデリミタになるので TypeScript が「ここまでが時、ここからが分」と判断できるんですが、+0900 のようにデリミタのない4桁文字列を ${infer TH}${infer TM} と書いてしまうと、TypeScript は分割位置を決められません(空文字と"0900""0""900""09""00"... 全部マッチしうる)。
なので一文字ずつ推論して ${TH1}${TH2} / ${TM1}${TM2} と再結合することで、確実に「先頭2文字 + 末尾2文字」の分割を実現しています。ちょっと泥臭いですが、Template Literal Types でデリミタなし固定長パースをやるにはこれが定石です。

###時刻とタイムゾーンの分割

ここでひとつ問題がありました。12:30:00+09:00 のような文字列から、時刻部分(12:30:00)とタイムゾーン部分(+09:00)をどう分けるか。

infer+- をデリミタにしたいところですが、一筋縄ではいきません。(正規表現の先読み/後読みみたいなものがあれば楽なんですが) そこで、専用の分割型を用意しました。

spli-time-tz.ts
コピーコピー済みTYPESCRIPT
type SplitTimeTZ<S extends string> = S extends `${infer T}Z`
  ? { time: T; tz: "Z" }
  : S extends `${infer T}+${infer TZOffset}`
    ? { time: T; tz: `+${TZOffset}` }
    : S extends `${infer T}-${infer TZOffset}`
      ? { time: T; tz: `-${TZOffset}` }
      : never;

オブジェクト型 { time: ...; tz: ... } を返すことで、後段で個別にバリデーションできるようにしています。
タイムゾーンがない場合は tz: never を返す設計です。

###never と分配条件型の罠

SplitTimeTZ の結果を使って ISO8601TimeWithTZ を書くとき、ちょっとしたハマりポイントがありました。

iso8601time-with-tz.ts
コピーコピー済みTYPESCRIPT
type ISO8601TimeWithTZ<S extends string> =
  SplitTimeTZ<S> extends { time: infer T; tz: infer TZ }
    ? T extends string
      ? ISO8601Time<T> extends never
        ? never
        : [TZ] extends [never] // <- ココ
          ? S
          : TZ extends string
            ? ISO8601Timezone<TZ> extends never
              ? never
              : S
            : never
      : never
    : never;

[TZ] extends [never] の部分がポイントです。

普通に TZ extends never と書くと、TypeScript の分配条件型(Distributive Conditional Types)のルールにより、TZnever(= 空のユニオン)のとき条件式自体が評価されずに never が返ってしまいます。
[TZ] extends [never] のようにタプルで包むと分配が抑制されて、意図通り never かどうかを判定できるわけですね。

#できた!

最後に、日付のみの場合と日付+時刻の場合を統合して完成です。

iso8601.ts
コピーコピー済みTYPESCRIPT
type ISO8601DateTime<S extends string = string> =
  S extends `${infer DatePart}T${infer TimeTZPart}`
    ? ISO8601Date<DatePart> extends never
      ? never
      : ISO8601TimeWithTZ<TimeTZPart> extends never
        ? never
        : S
    : never;

export type ISO8601<S extends string = string> = S extends `${string}T${string}`
  ? ISO8601DateTime<S>
  : ISO8601Date<S>;

T が含まれていれば日時として、なければ日付のみとしてバリデーションします。

型だけだとランタイムの文字列は守れないので、ランタイム用の type guard 関数も合わせて書きました。

TYPESCRIPT
コピーコピー済みTYPESCRIPT
export function isISO8601(s: string): s is ISO8601 {
  const dateTimeMatch = s.match(/^(\d{4}-\d{2}-\d{2})T(.+)$/);
  if (dateTimeMatch) {
    return isValidDate(dateTimeMatch[1]) && isValidTimeWithTZ(dateTimeMatch[2]);
  }
  return isValidDate(s);
}

うるう年判定やタイムゾーンの各フォーマット対応など、型レベルの実装と同じロジックを正規表現と数値計算で再現する形です。型とランタイムで二重にバリデーションしているのは若干冗長ですが、まあ仕方ない。

#おわりに

いろいろ頑張って作ってみたんですが、実はこの公開日時は Markdown の frontmatter に書いているので、型なんて効きようがなかったんですよね。トホホ

YAML
コピーコピー済みYAML
---
date: 2026-07-05  # ← ただの文字列
---

ビルドスクリプトが YAML をパースして string として渡してくるので、TypeScript の型システムの出る幕はありませんでした。

型パズルとしてはかなり楽しかったので良しとします。