feat!: compile-time wire-string check; always-decoded params/query args - #19
Merged
Conversation
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
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2.
Path and query values reach the server as raw strings, so a bare
z.number()orz.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 makesroute()reject such schemas at compile time and splits the client's request IO: params/query/headers are always decoded values, whileencodeRequestsgoverns only the body.Compile-time wire-string check (
@zodapi/hono)WireChecktype inroute()— alongside the existing params/path key check: errors unless each params/query value schema'sz.inputadmits astring(orreadonly 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()(inputunknown, passes unchecked),queryArray(z.string()), and non-literal-key shapes (looseObject) pass; barez.number(),z.boolean(),z.number().optional(), andz.array(z.number())are rejected with an error-shaped type naming the keys and the fix. Type-level only — no runtime behavior change.z.coerce.number()/z.stringbool()— no type argument.z.coerce.number<number>()narrows the declared input tonumber, making it structurally identical toz.number()in zod's typings, so the check rejects it too (covered by its own type test). Since client args are typed fromz.output(below), the input type never leaks into client code.route()andqueryArray(), plus a "Params and query are strings on the wire" README section covering thez.coerce.boolean()truthiness trap ("false"→true); the example API demonstratesz.stringbool()and coerced numeric ids/limits end to end.Split request IO (
@zodapi/client)numberfor a coerced param,trueforz.stringbool(),Datefor a codec), typedz.outputwith input-side optionality preserved (.default()ed /.optional()keys stay omittable). Codec-bearing values are alwaysz.encoded to their wire form — per key for object schemas, so a codec can sit beside a one-way transform likequeryArray()that whole-schemaz.encodewould reject (covered by runtime tests: a date-only codec query param serializes as2024-01-02, nottoISOString()'s full datetime).encodeRequestsnow governs only the body, at its previous default (false: wire-formz.inputbodies).encodeRequests: true(client-level or per call) flips the body arg to decodedz.output, encoded before sending.Compile break notes
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.{ 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.@zodapi/honominor,@zodapi/clientminor (breaking, pre-1.0),@zodapi/corepatch. Codegen output is unaffected (generated contracts are plainRouteDefobjects).-- Claude
🤖 Generated with Claude Code
https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv