[typescript-fetch] centralise date handling, add dateLibrary, fix for…

Closed
B2
3mo ago
+3832-134

Fixes #24636

Short version:
typescript-fetch writes its date conversion out by hand in four templates plus querystring, and the copies have drifted.
format: date ends up unconverted for form parameters, and everywhere else it goes through UTC, which moves the day for any code using the local half of the Date API.

The issue has the measurements.

Design:

  • (de)serialize date as a local date, keep UTC for date-time.
    • This fixes the bug where parsing / serializing in UTC shifts the day. It shifts in
      every timezone except UTC, in one direction or the other.
    • A full-date has no offset, so both ends have to agree on a wall clock. Local is the
      one the surrounding code already uses: new Date(Y, M, D), getDate() and
      toLocaleDateString() are all local. It also lines up with the other generators,
      where format: date maps to LocalDate, NaiveDate or datetime.date and the day
      you read back is the day the server sent.
    • create helpers in runtime.ts so that every path uses the same
      serialization/deserialization
  • add the missing isDateType branch for form parameters, the only location that was
    converting nothing at all.
  • add a generation parameter dateLibrary to choose how date and date-time are
    represented.
    • Same pattern as what is done in java, kotlin and dart.
    • two options: date for a native Date converted by the runtime, and string as an
      explicit escape hatch, handing back exactly what the server sent for consumers who
      want to own date handling (SSR/RSC, or an app that already has a date library)
    • withoutRuntimeChecks still implies string. Maybe you'd like it to be explicit?
    • only generate the date helpers if we use dateLibrary: date. serializeDateTime
      always ships, since querystring can be handed a Date either way

Temporal.PlainDate / Temporal.Instant would remove the wall-clock choice entirely and
is the proper fix here, but it can't be the default while Safari hasn't shipped it. I have
a follow-up drafted to add it as a third dateLibrary value. Would you rather have it in
this PR?

Behaviour change

Today a format: date is parsed to the instant of UTC midnight. This PR parses it to
local midnight instead, which is the position @macjohnny took in #17284 ("someone uses a
local date and expects the local representation to be transferred over the wire"). That
issue and #17272 ask the same question for the typescript generator and have sat since
December 2023. This PR only touches typescript-fetch.

Changing the anchor is not free. Reading the value as a plain date keeps working, or
starts working. Anything that relied on the instant being UTC midnight changes:

// '2026-08-05' received from the API, in Europe/Paris

// before: parsed to UTC midnight
date.getDate()      // 5
date.getUTCDate()   // 5
date.toISOString()  // '2026-08-05T00:00:00.000Z'

// after: parsed to local midnight
date.getDate()      // 5
date.getUTCDate()   // 4
date.toISOString()  // '2026-08-04T22:00:00.000Z'

So it breaks consumers who read dates with getUTC*, compare them against
Date.UTC(...), or persist and log the raw instant. Server-side rendering is affected too,
since the instant now depends on the process time zone.

dateLibrary: string is the way out, and it reproduces the current behaviour exactly:
new Date(value) and value.toISOString().substring(0, 10) are what the generator does
today.

Testing

  • 5 new tests in TypeScriptFetchClientCodegenTest, covering the default,
    dateLibrary: string, the withoutRuntimeChecks fallback, serialization per parameter
    location, and the local-calendar semantics. TypeScriptFetchClientOptionsTest and its
    provider pick up the new option. The whole typescript group passes: 192 tests.
  • New fixture 3_0/typescript-fetch/date-handling.yaml with a format: date in a model
    property and in a path, query and form parameter, plus one sample build per option value
    so the difference between them is a readable diff.
  • tsc --strict over all 19 typescript-fetch sample builds: 15 clean, 4 with the same
    error counts as master (a 123 operationId, and missing immutable / redux-ts-simple).
  • Checked the generated helpers in Pacific/Honolulu, America/Los_Angeles, America/New_York,
    UTC, Europe/Paris, Asia/Tokyo and Pacific/Auckland. Round-trip, date-picker input and
    display are correct in every one. Before, display was wrong west of UTC and the
    date-picker path was wrong east of it.

PR checklist

  • Read the contribution guidelines.
  • Ran ./mvnw clean package, ./bin/generate-samples.sh ./bin/configs/typescript-fetch*.yaml and ./bin/utils/export_generator.sh typescript-fetch, and committed all changed files.
  • @mention the TypeScript technical committee: @TiFu @taxpon @sebastianhaas @kenisteward @Vrolijkx @macjohnny @topce @akehir @petejohansonxo @amakhrov @davidgamero @mkusaka @joscha @KannaKim

Summary by cubic

Centralized date handling in typescript-fetch, added a dateLibrary option, and fixed format: date serialization across path/query/form params and querystrings. date now uses the local calendar; date-time stays UTC, with ES6-safe helpers that validate edge cases.

  • New Features

    • dateLibrary option: date (default, native Date) or string (pass-through). withoutRuntimeChecks implies string.
    • Unified helpers: parseDate, parseDateTime, serializeDate, serializeDateTime used in models, oneOf guards, and all parameter locations.
    • Helper hardening: ES6-safe (no padStart), preserves years 0000–0099, rejects invalid/DST-skipped dates, throws on invalid Date. Models without date fields no longer import helpers.
  • Migration

    • format: date is now parsed/serialized as a local date, not UTC midnight.
    • If you depend on UTC-based or SSR-stable instants, set dateLibrary: string or update code that uses getUTC*/toISOString() for plain dates.