Swift Digest

ISO 8601 構成要素のフォーマットと解析

ISO8601 Components Formatting and Parsing

Proposal
SF-0021
Authors
Tony Parker
Status
Accepted (Swift 6.2)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Date.ISO8601FormatStyle は ISO 8601 形式の文字列と Date を相互変換するためのフォーマットスタイルですが、実際に運用してみるといくつか使いにくい点がありました。

小数秒の取り扱い

Date.ISO8601FormatStyleincludingFractionalSeconds というフラグを持っており、解析時はこの設定が「小数秒を必須にする/禁止する」という厳密な意味で働いていました。そのため、入力文字列に小数秒が含まれているかどうかが事前にわからない場合は、両方の設定で 2 回パースするしかありませんでした。

let str = "2022-01-28T15:35:46Z"
var result = try? Date.ISO8601FormatStyle(includingFractionalSeconds: false).parse(str)
if result == nil {
    result = try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(str)
}

実際には小数秒の有無を呼び出し側が気にしないケースが多く、この厳密さはむしろ取り回しを悪くしていました。

タイムゾーンオフセットの揺れ

ISO 8601 のタイムゾーンオフセットは +08+0800+08:00+08:00:00 のようにさまざまな書き方があります。これまでもパーサは秒部分の省略や : の省略を受け付けていましたが、分まで省略した +08 のような表記は受け付けていませんでした。実運用ではどのバリエーションも見かけるため、これも「事前に表記を絞り込めない」呼び出し側にとっては不便でした。

Date ではなく構成要素を取り出したい

Date.ISO8601FormatStyle.parse は最終的に Date を返すため、文字列に書かれていたタイムゾーンや暦の構成要素は解決後に失われてしまいます。「文字列が示しているタイムゾーンを知りたい」「Calendar を自分で選んで DateComponents から Date を構築したい」「厳密な検証をしたい」といった用途には、Date 経由の API は合いませんでした。

02 どのように解決されるのか

この Proposal は、既存の Date.ISO8601FormatStyle の解析時の挙動を 2 点ゆるめたうえで、DateComponents 用の新しいフォーマットスタイル DateComponents.ISO8601FormatStyle を追加します。新しい API は @available(FoundationPreview 6.2, *) でガードされ、FoundationPreview 6.2 以降で利用できます。

解析時の小数秒は常に許容

Date.ISO8601FormatStyleincludingFractionalSeconds プロパティは、これまで通りフォーマット時の小数秒の有無を制御します。一方で解析時の挙動が変わり、includingFractionalSeconds の値にかかわらず、小数秒は 常に 許容されるようになります。これにより、小数秒の有無がわからない文字列でもフラグの切り替えやリトライなしに 1 回でパースできます。

let style = Date.ISO8601FormatStyle(includingFractionalSeconds: false)
_ = try style.parse("2022-01-28T15:35:46Z")       // OK
_ = try style.parse("2022-01-28T15:35:46.123Z")   // OK(以前はエラー)

タイムゾーンオフセットの分も省略可能に

タイムゾーンオフセットの解析でも、これまで許されていた秒部分や : の省略に加えて、分の省略も許されるようになります。次のいずれの表記も解析でき、結果は同じタイムゾーンとして扱われます。

2022-01-28T15:35:46 +08
2022-01-28T15:35:46 +0800
2022-01-28T15:35:46 +080000
2022-01-28T15:35:46 +08:00
2022-01-28T15:35:46 +08:00:00

これらは既存コードに影響する挙動変更です。本当に小数秒やオフセットの形を厳密に検査したい場合は、後述の DateComponents.ISO8601FormatStyle を使って構成要素を取り出し、自前で検証するという方針になります。新しい挙動に依存するコードを Swift 6.2 より前にバックデプロイする必要がある場合は、if #available で分岐し、古い OS では従来どおり 2 回パースするコードを併存させます。

DateComponents.ISO8601FormatStyle の追加

新しい DateComponents.ISO8601FormatStyle は、Date.ISO8601FormatStyle とほぼ同じ API 形状を持ちつつ、フォーマット対象が DateComponents、解析結果も DateComponents という点だけが異なります。内部実装は Date 版と共有されており、セパレータや小数秒のオプションも共通です。

フォーマットは DateComponents から直接行えます。

let components = DateComponents(
    year: 1999, month: 12, day: 31,
    hour: 23, minute: 59, second: 59
)
let formatted = components.formatted(.iso8601Components)
print(formatted) // 1999-12-31T23:59:59Z

出力に必要なフィールドが DateComponents に揃っていない場合は、フォーマッタが既定値(時刻なら 0 など)で埋めます。

let components = DateComponents(year: 1999, month: 12, day: 31)
let formatted = components.formatted(.iso8601Components)
// 1999-12-31T00:00:00Z

解析は parse(_:)DateComponents を返します。タイムゾーンも DateComponents.timeZone として一緒に取り出せます。

let components = try DateComponents.ISO8601FormatStyle().parse("2022-01-28T15:35:46Z")
// DateComponents(timeZone: .gmt, year: 2022, month: 1, day: 28,
//                hour: 15, minute: 35, second: 46)

得られた DateComponents から Date が必要な場合は、Calendar の API を呼び出し側で選んで使います。

let date = components.date // 解析結果が無効な日付なら nil

出力フィールドのカスタマイズ

Date.ISO8601FormatStyle と同様に、メソッドチェーンで含めるフィールドやセパレータを切り替えられます。

extension DateComponents.ISO8601FormatStyle {
    public func year() -> Self
    public func weekOfYear() -> Self
    public func month() -> Self
    public func day() -> Self
    public func time(includingFractionalSeconds: Bool) -> Self
    public func timeZone(separator: Date.ISO8601FormatStyle.TimeZoneSeparator) -> Self
    public func dateSeparator(_ separator: Date.ISO8601FormatStyle.DateSeparator) -> Self
    public func dateTimeSeparator(_ separator: Date.ISO8601FormatStyle.DateTimeSeparator) -> Self
    public func timeSeparator(_ separator: Date.ISO8601FormatStyle.TimeSeparator) -> Self
    public func timeZoneSeparator(_ separator: Date.ISO8601FormatStyle.TimeZoneSeparator) -> Self
}

FormatStyle / ParseStrategy への適合と iso8601Components ショートハンド

DateComponents.ISO8601FormatStyleFormatStyleParseableFormatStyleParseStrategy に適合します。FormatStyle などのスタティックプロパティとして iso8601Components が用意されているため、Date 版の .iso8601 と同じ感覚で書けます。

let s = components.formatted(.iso8601Components)
let c = try DateComponents.ISO8601FormatStyle().parse(s)

正規表現リテラルとの統合

CustomConsumingRegexComponent にも適合しているため、正規表現リテラルの中で ISO 8601 の日時を直接マッチさせ、結果を DateComponents として取り出せます。iso8601ComponentsWithTimeZone(...) を使えば、入力文字列に書かれているタイムゾーンをそのまま使ってマッチ結果を組み立てます。iso8601Components(timeZone:...) の系統では、マッチ時に使うタイムゾーンを呼び出し側で固定できます。

extension RegexComponent where Self == DateComponents.ISO8601FormatStyle {
    public static var iso8601Components: DateComponents.ISO8601FormatStyle

    public static func iso8601ComponentsWithTimeZone(
        includingFractionalSeconds: Bool = false,
        dateSeparator: Date.ISO8601FormatStyle.DateSeparator = .dash,
        dateTimeSeparator: Date.ISO8601FormatStyle.DateTimeSeparator = .standard,
        timeSeparator: Date.ISO8601FormatStyle.TimeSeparator = .colon,
        timeZoneSeparator: Date.ISO8601FormatStyle.TimeZoneSeparator = .omitted
    ) -> Self

    public static func iso8601Components(
        timeZone: TimeZone,
        includingFractionalSeconds: Bool = false,
        dateSeparator: Date.ISO8601FormatStyle.DateSeparator = .dash,
        dateTimeSeparator: Date.ISO8601FormatStyle.DateTimeSeparator = .standard,
        timeSeparator: Date.ISO8601FormatStyle.TimeSeparator = .colon
    ) -> Self

    public static func iso8601Components(
        timeZone: TimeZone,
        dateSeparator: Date.ISO8601FormatStyle.DateSeparator = .dash
    ) -> Self
}

Date.ISO8601FormatStyleDateComponents.ISO8601FormatStyle を使い分けることで、「Date まで一気に解決したいケース」と「文字列に書かれていた構成要素やタイムゾーンを失わずに扱いたいケース」を、共通のオプション体系のまま選べるようになります。