Skip to content

feat: accept JS Date for date-time request values - #299

Merged
mfal merged 2 commits into
masterfrom
claude/issue-209-date-time-as-date
Sep 22, 2026
Merged

mfal merged 2 commits into
masterfrom
claude/issue-209-date-time-as-date

Conversation

@mfal

@mfal mfal commented Sep 4, 2026

Copy link
Copy Markdown
Member

Closes #209

Lets callers pass a JS Date where the spec declares format: date-time, instead of hand-formatting ISO 8601 strings:

client.contract.terminateContract({ contractId, data: { terminationTargetDate: new Date() } })

Not breaking — request side only. Verified below.

Scope decision

Request positions are widened to (string | Date). Response types are deliberately left as string: turning a response field into a Date would break .split("T"), new Date(x), string comparison and JSON.stringify at every existing call site. The issue's complaint is about sending dates, which this fully resolves.

The obstacle, and how it is handled

format is retained — the generator hands raw OpenAPI schemas to json-schema-to-typescript, which just ignores format for strings. jstt's tsType extension lets us override the emitted type per sub-schema, so no template work was needed.

The real problem was component schemas shared between request and response position. Measured against the committed spec: 134 request-reachable schemas, 404 response-reachable, 72 overlapping — but only 4 of those transitively contain a date-time and are reachable from a request. So the generator emits widened copies of exactly those four into a new, purely additive Components.RequestSchemas namespace and redirects request-position $refs there. Components.Schemas stays byte-identical, so responses are untouched.

Parentheses in (string | Date) matter: jstt appends [] to an item's tsType unwrapped, so a bare union would emit string | Date[].

Runtime

packages/commons/src/core/serializeDates.ts — a schema-agnostic recursive serializer applied in Request.buildAxiosConfig. Schema-agnostic rather than per-field codegen because the runtime has no descriptor-level schema and the conversion is unambiguous. It recurses only into arrays and plain objects (prototype Object.prototype or null), so FormData/Blob/Uint8Array/class instances pass through by reference, never cloned. Input is never mutated.

Three genuine bugs fixed along the way — bodies happened to work via Date.prototype.toJSON, but:

position before now
path params String(date) → garbage in the URL ISO 8601
headers Date.toString() → non-ISO ISO 8601
query params JSON.stringify(date) → a quoted ISO string ISO 8601

HeaderValue and PathParameters gained | Date. This was required, not cosmetic: RequestWithQueryParameters<TQuery> = { queryParameters: TQuery & HttpHeaders }, so without it a Date query param fails the index-signature side of the intersection. Both are input-shaped types, so it is a widening.

Deliberately excluded

  • format: date is not widened. Its wire format is YYYY-MM-DD and the runtime serializer cannot know that, so a Date would be sent as a full ISO timestamp and rejected. The spec has a real instance (GET /v2/pageinsights?date=); a @ts-expect-error check proves it still rejects a Date.
  • One intentional runtime behaviour change: an invalid Date now throws RangeError: Invalid time value instead of silently serializing to null via toJSON. Fail-fast on a programming error.

Verification that this is not breaking

Regenerated in-PR (build:client-prod), 210 insertions / 54 deletions across both clients. Every | Date occurrence, by enclosing namespace:

location v2 v3-next
Components.RequestSchemas (new, additive) 3 3
Get.Parameters 4 4
Patch.Parameters 8 8
Post.Parameters 11 11
Put.Parameters 2 2
Responses 0 0
Components.Schemas 0 0

Tests: dateTimeInput.test.ts (widening transform — date vs date-time, enums, existing tsType, nesting/arrays/compositions, non-mutation, refs untouched), dateTimeInput.integration.test.ts (compiles a spec end-to-end, asserts request widening, response non-widening, shared-schema preservation, ref redirection), serializeDates.test.ts, Request.test.ts (Dates in body/path/headers/query, arrays, non-plain payload passthrough, non-mutation).

Also type-checked against the real generated client: the issue's exact example compiles; an ISO string still compiles; response.data.terminationTargetDate.split("T") still compiles; a response-typed Components.Schemas.X is still assignable to the widened Components.RequestSchemas.X.

yarn nx run-many -t test --skip-nx-cache (all 4 projects), yarn lint (4), yarn format, generator test:unit (46/9 suites), commons test (35/4 suites), api-client:test:compile — all pass.

Residual risk

Inherent to any request-side widening: code that declares a variable of a generated request body type and then reads a date field from it now sees string | Date. Constructing and passing request bodies — the overwhelmingly common case — is unaffected.

🤖 Generated with Claude Code

Request types generated from `format: date-time` string schemas are widened
from `string` to `string | Date`, and any `Date` in a request is serialized to
ISO 8601 before it is sent — in the request body (at any nesting depth), in
query parameters, path parameters and headers.

Closes #209.

This is a widening only, so existing code passing ISO strings keeps compiling
and working unchanged:

* Response types are deliberately left as `string`. Changing them would break
  consumers that split, compare or re-parse those strings.
* Component schemas used in both request and response position keep their
  original shape in `Components.Schemas`; a widened copy is emitted in the new,
  purely additive `Components.RequestSchemas` namespace and referenced from
  request position only.
* `format: date` (`YYYY-MM-DD`) is *not* widened. Its wire format differs from
  a full ISO 8601 timestamp, and the runtime serializer has no access to the
  schema, so accepting a `Date` there would produce a value the API rejects.

`HeaderValue` and `PathParameters` in `@mittwald/api-client-commons` gained
`Date` as an accepted input type, which is required for the widened parameter
types to satisfy the `RequestType` constraints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal marked this pull request as ready for review September 7, 2026 08:12
Comment thread packages/commons/src/core/OpenAPIPath.ts Outdated
Replaces the inline `instanceof Date ? toISOString()` branch in
OpenAPIPath with the shared serializeDates helper, so date serialization
lives in exactly one place. Side effect: an invalid Date in a path
parameter now throws RangeError like everywhere else, instead of
producing "Invalid Date" in the URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal merged commit cd1c20a into master Sep 22, 2026
1 check passed
mfal added a commit that referenced this pull request Sep 23, 2026
Master's date-time PR (#299) touched the same two files:

- JSONSchema.ts: import-only conflict, both sides kept. Master also split the
  compile path in two, and component parameters now run through the new
  `compileAsRequestInput`, which did not apply the type alias workaround —
  a deprecated component parameter alias fell back to an empty `/** */`
  block. The deprecation handling of `compile` is mirrored there, with a
  regression test on the existing `LegacySort` fixture.
- componentRefsToCustomTypes.ts: both sides added a declaration behind the
  same doc comment; both are kept, and a ref now carries this branch's
  sibling annotations while resolving through master's `resolveTSName`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mfal added a commit that referenced this pull request Sep 23, 2026
Master's date-time PR (#299) rewrote the same three spots in Request.ts:

- The request body is now taken from the date-serialized request object, so
  a `Date` in a multipart body is appended as a bare ISO string part rather
  than a JSON-quoted one. Covered by a new test.
- `makeAxiosHeaders` keeps this branch's optional-headers/content-type
  handling and applies master's `serializeDates` to the header values.

`serializeDates` rebuilds plain objects, so a JSON body is no longer the
caller's exact object instance. Three assertions in requestBody.test.ts that
checked referential identity now compare structurally; the non-plain payloads
(FormData, Blob, URLSearchParams) are still passed through by reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mfal added a commit that referenced this pull request Sep 23, 2026
Master's date-time PR (#299) added `RequestSchemas.ts`, modelled on
`Schemas.ts` and therefore still calling the `populateNullableTypes`
workaround this branch deletes — the merge left the generator uncompilable.
The removal is applied there as well; the defensive `cloneDeep` stays.

The committed client was regenerated from the merged state, so the generated
types carry both the v16 `nullable` handling and master's widened date-time
request parameters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

It would be convenient if 'date-time' formatted Strings would be typed as JS Dates

2 participants