Skip to content

feat!: compile-time wire-string check; always-decoded params/query args - #19

Merged
christensena merged 6 commits into
mainfrom
feat/wire-string-type-check
Sep 1, 2026
Merged

feat!: compile-time wire-string check; always-decoded params/query args#19
christensena merged 6 commits into
mainfrom
feat/wire-string-type-check

Conversation

@christensena

@christensena christensena commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Closes #2.

Path and query values reach the server as raw strings, so a bare z.number() or z.boolean() in a params/query schema type-checks but fails validation with a 400 on every request (verified against the real stack: no coercion layer anywhere in @hono/zod-openapi). This PR makes route() reject such schemas at compile time and splits the client's request IO: params/query/headers are always decoded values, while encodeRequests governs only the body.

Compile-time wire-string check (@zodapi/hono)

  • WireCheck type in route() — alongside the existing params/path key check: errors unless each params/query value schema's z.input admits a string (or readonly string[], for repeated query keys). Both checks now report through a conditional parameter type (ValidatedRouteConfig<R>): on failure the expected type collapses to just the error object — a one-line message naming the offending keys and the fix — instead of dumping the whole zod-typed config twice. Enums, z.string(), z.stringbool(), z.coerce.number() (input unknown, passes unchecked), queryArray(z.string()), and non-literal-key shapes (looseObject) pass; bare z.number(), z.boolean(), z.number().optional(), and z.array(z.number()) are rejected with an error-shaped type naming the keys and the fix. Type-level only — no runtime behavior change.
  • Idiom: plain z.coerce.number() / z.stringbool() — no type argument. z.coerce.number<number>() narrows the declared input to number, making it structurally identical to z.number() in zod's typings, so the check rejects it too (covered by its own type test). Since client args are typed from z.output (below), the input type never leaks into client code.
  • Docs — JSDoc on route() and queryArray(), plus a "Params and query are strings on the wire" README section covering the z.coerce.boolean() truthiness trap ("false"true); the example API demonstrates z.stringbool() and coerced numeric ids/limits end to end.

Split request IO (@zodapi/client)

  • Params, query, and headers are always supplied decoded — the transport turns them into strings regardless, so callers pass natural types (number for a coerced param, true for z.stringbool(), Date for a codec), typed z.output with input-side optionality preserved (.default()ed / .optional() keys stay omittable). Codec-bearing values are always z.encoded to their wire form — per key for object schemas, so a codec can sit beside a one-way transform like queryArray() that whole-schema z.encode would reject (covered by runtime tests: a date-only codec query param serializes as 2024-01-02, not toISOString()'s full datetime).
  • encodeRequests now governs only the body, at its previous default (false: wire-form z.input bodies). encodeRequests: true (client-level or per call) flips the body arg to decoded z.output, encoded before sending.

Compile break notes

  • Contracts using bare z.number()/z.boolean() in params/query stop compiling — they were already 400ing on every request. z.coerce.number<number>() also stops compiling: drop the type argument.
  • Client callers passing wire-form strings for params/query ({ id: '1' } against a coerced-number param) get a type error — pass the natural type ({ id: 1 }). Body handling is unchanged from the released behavior.
  • Changesets: @zodapi/hono minor, @zodapi/client minor (breaking, pre-1.0), @zodapi/core patch. Codegen output is unaffected (generated contracts are plain RouteDef objects).

-- Claude

🤖 Generated with Claude Code

https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv

christensena and others added 3 commits August 30, 2026 21:52
Params and query values reach the server as raw strings, so a bare
z.number()/z.boolean() type-checks but 400s on every request. route()
now rejects such schemas at compile time: a WireCheck type errors on any
params/query value whose input type admits neither a string nor an array
of strings.

z.coerce.number<number>() is structurally identical to z.number() at the
type level, so the blessed idiom becomes z.coerce.number<number|string>()
— the input type declares the wire form, which is what the check (and the
client's argument types) can see. z.stringbool() covers booleans;
z.coerce.boolean() stays out (JS truthiness turns "false" into true).

Docs on route()/queryArray() and a README section cover the trap; the
example API demonstrates both idioms.

Closes #2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
Adopt z.coerce.number<number | `${number}`>() as the blessed idiom for
coerced params/query values: the template-literal input type admits only
numeric strings, so wire-form client args reject 'abc' while the wire
check still sees that strings are welcome.

Flip @zodapi/client's encodeRequests default to true: request args are
typed z.output (Date for codecs, number for coerced params) and
codec-bearing data is z.encode'd before sending. Keys the input side
lets the caller omit (.default()/.optional()) stay omittable in output
mode, so defaulted query params don't become required args. Pass
encodeRequests: false for wire-form z.input args.

The example gains numeric user ids served by coerced number params.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
@christensena christensena changed the title feat(hono): compile-time wire-string check for params/query schemas feat!: compile-time wire-string check; encodeRequests on by default Aug 30, 2026
…ns body only

Params, query, and header values end up as strings on the wire no matter
what, so the caller now always supplies them decoded (z.output typing,
input-side optionality preserved) and codec-bearing values are always
z.encode'd to their wire form — per key for object schemas, so a codec
(z.stringbool(), a date codec) can sit next to a one-way transform like
queryArray() that whole-schema z.encode would reject.

encodeRequests narrows to the request body and returns to its previous
default: off (wire-form z.input bodies); true flips the body arg to
decoded z.output, encoded before sending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
@christensena christensena changed the title feat!: compile-time wire-string check; encodeRequests on by default feat!: compile-time wire-string check; always-decoded params/query args Aug 30, 2026
christensena and others added 2 commits August 31, 2026 09:09
The template-literal type argument was only ever a workaround for
input-typed client args; with params/query args now typed from z.output
it leaks an implementation detail into contract code for no benefit.
Plain z.coerce.number() (input `unknown`) passes the wire check as-is —
only the narrowed z.coerce.number<number>() form, indistinguishable from
z.number(), remains rejected, now covered by its own type test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
Replace the R & ParamsCheck<R> & WireCheck<R> parameter intersection
with a conditional ValidatedRouteConfig<R>: R itself when the checks
pass, only the small error object when they fail. The inferred config
type no longer appears in the failure branch, so a bad route() call
errors with the one-line message and offending keys instead of dumping
the whole zod-typed config twice. Verified on both tsc 7 and the
typescript-6 checker; a type test pins the collapsed parameter shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
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.

Tighten string coercion for params/query schemas (bare z.number()/z.boolean() silently 400)

1 participant