Skip to content

Infer date-times only for strict RFC 3339 strings#2916

Merged
schani merged 3 commits into
masterfrom
strict-rfc3339-datetime-inference
Jul 12, 2026
Merged

Infer date-times only for strict RFC 3339 strings#2916
schani merged 3 commits into
masterfrom
strict-rfc3339-datetime-inference

Conversation

@schani

@schani schani commented Jul 12, 2026

Copy link
Copy Markdown
Member

Behavior change (deliberate, affects all languages)

Strings that are not strict RFC 3339 date-times are no longer inferred as date-time — they become plain strings. The default recognizer (vendored from ajv) accepted a space as the date/time separator and made the timezone offset optional, both RFC 3339 violations. JSON samples containing values like "2013-06-15 21:10:28" were typed as date-times, and every language with a strict date parser generated code that could not read the very samples the types were inferred from.

The evidence this was wrong: five languages carried skip lists around it (Go skipped 8 misc fixtures outright; Swift, Python, Rust, C++ skipped overlapping diff-via-schema runs), and pending Kotlin work (#2845) would have needed 17 more skips. "date-time" in the JSON ecosystem (JSON Schema, OpenAPI) means RFC 3339.

What changed

packages/quicktype-core/src/DateTime.ts:

  • DATE_TIME_SEPARATOR: /t|\s/i/t/i — separator must be T/t, never a space
  • TIME: offset group (z|[+-]\d\d:\d\d)? → mandatory — applies to both isDateTime and standalone isTime

Design decision on isTime: RFC 3339 full-time requires the offset, and JSON Schema's time format is full-time, so bare "21:10:28" no longer infers as time either. Lowercase t/z remain accepted (RFC 3339 allows them). Fractional seconds remain accepted.

Verified type flips (generated locally): "2013-03-21 03:59:02"-style fields are now string in Go/C#/Python output; RFC 3339 values ("1929-07-10T00:00:00Z", "2010-01-12T00:00:00.000Z") stay typed.

Skips removed (each verified individually)

  • golang skipJSON (8): f6a65 e0ac7 c3303 7681c 437e7 127a1 26b49 0cffa — all round-trip byte-identically through the real Go fixture locally (go run), and all pass diff-via-schema.
  • swift skipDiffViaSchema (8): the same space-class files, marked // date-time issues. Root cause was recognizer divergence: the direct path used SwiftDateTimeRecognizer (strict), the schema path used the lenient default, so the two paths inferred different types. Verified DIFF-on-master → PASS-with-this-change for these files.
  • typescript / javascript / flow skipJSON (1 each): 7681c.json ("year 0 is out of range") — every date-like string in that file is space-separated, so no field is Date-typed anymore and the runtime parse can't fail. TypeScript and JavaScript fixtures round-trip locally; flow could not be run locally (no flow binary) — CI validates it. TypeScript's diff-via-schema for the file also passes.
  • typescript-zod skipJSON (1): 7681c.json — previously z.coerce.date() re-serialized the coerced values as ISO UTC, breaking comparison; now they stay strings. Verified by round-tripping the zod fixture locally. 32d5c.json stays skipped (RFC 3339-valid values; .000Z normalization issue remains).
  • cplusplus (6) / elm (5) skipDiffViaSchema, csharp both variants (437e7): the audit showed these were already stale — they pass even without this change (with a single shared recognizer, both diff-via-schema paths always inferred identically; only Swift's override caused real divergence). Removed as cleanup; attribution noted honestly here.

Skips deliberately kept

  • python / rust skipDiffViaSchema space-class entries: still diff — the divergence is schema-path type naming (Downsized vs The480_WStill), not date-times.
  • All fractional-seconds / normalization files (0a358 32d5c 54d32 5eae5 77392 80aff 9ac3b a0496 b4865 d23d5 337ed): RFC 3339-valid, still inferred as date-times; their issues are serialization normalization (e.g. .000ZZ), a separate problem.
  • typescript-zod / typescript-effect-schema skipDiffViaSchema lists (naming-related; effect-schema still diffs) and the Java legacy-datetime variant skips (serialization strictness).

Per-language recognizer verdict

SwiftDateTimeRecognizer stays. The strict default now agrees with it on separators/offsets, but Swift's recognizer also rejects fractional seconds (Swift's ISO8601DateFormatter can't parse them by default) and tolerates some sloppy field widths. Not redundant. No other language overrides the recognizer.

Verification

  • Full monorepo build; unit tests 70/70 (11 new recognizer tests pinning: rejects space separator, rejects missing offset, accepts lowercase t/z, accepts fractions, field-range validation).
  • Every removed skip verified individually as described above (local Go/TS/JS/zod fixture round-trips; diff-via-schema replicated with the harness's exact commands and options for swift/csharp/cplusplus/elm/go/typescript).
  • New fixture coverage is exercised by CI: the previously-skipped files now run for the affected languages.

Follow-up (not in this PR)

Per-language "round-trip-provable" recognizers (e.g. rejecting .000Z for java.time-based targets) would eliminate the remaining normalization skips; and a leniency escape hatch could be added if users depended on MySQL-style stamps being typed (deliberately out of scope here).

🤖 Generated with Claude Code

schani and others added 3 commits July 12, 2026 18:42
The default DateTimeRecognizer, vendored from ajv, accepted two forms
that RFC 3339 does not: a space as the date/time separator, and times
without a timezone offset. As a result, JSON samples containing strings
like "2013-06-15 21:10:28" were inferred as date-times, and languages
with strict date parsers (Go, Swift, java.time, ...) generated code
that could not parse the very samples the types were inferred from.
Several languages carried test-skip lists to paper over this.

The separator must now be "T" (or "t"), and times must carry an offset
("Z"/"z" or "±hh:mm"), for both isDateTime and standalone isTime
(RFC 3339 full-time requires the offset). Non-conforming strings are
inferred as plain strings, which every target handles faithfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pins the strict RFC 3339 behavior: rejects space separators and
missing timezone offsets, accepts lowercase t/z and fractional
seconds, and validates date/time field ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Now that non-RFC 3339 strings ("2013-06-15 21:10:28") are no longer
inferred as date-times, the fixtures that contain them work for
languages with strict date parsers:

- golang: all 8 skipJSON entries round-trip (verified locally with go)
- swift: the 8 "date-time issues" skipDiffViaSchema entries pass -- the
  divergence between SwiftDateTimeRecognizer (direct path) and the
  lenient default (schema path) is gone for these files
- typescript/javascript/flow/typescript-zod: 7681c.json contains only
  space-separated date-times, so no field is Date-typed anymore and the
  "year 0 is out of range" runtime failure cannot occur (verified by
  round-tripping the typescript, javascript, and zod fixtures locally;
  flow left to CI)

Also removes entries that an audit showed were already stale
(passing even before this change): cplusplus and elm space-class
skipDiffViaSchema entries and csharp's 437e7.json (both variants).

Kept deliberately: python/rust skipDiffViaSchema entries (they still
diff, due to schema-path type naming, not date-times), all skips for
RFC 3339-valid files with fractional-second normalization issues
(0a358, 32d5c, 54d32, 5eae5, 77392, 80aff, 9ac3b, a0496, b4865, d23d5,
337ed), and the zod/effect-schema naming-related lists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@schani schani merged commit 779ec9e into master Jul 12, 2026
23 checks passed
@schani schani deleted the strict-rfc3339-datetime-inference branch July 12, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant