form v1.9.0
form v1.9.0 classifies errors, decodes colors, maps field names, completes the decoder's resource bounds, and hardens input handling. No breaking changes; all defaults unchanged — every new behavior is opt-in or strictly additive.
Error classification (#51)
form.Error gains a Kind — KindSyntax, KindParse, KindUnknownKey, KindIndex, KindLimit, KindUnsupported, KindCycle, KindIO — inspectable via errors.As, alongside the existing Op and Unwrap. The zero value means unclassified (e.g. a custom TextUnmarshaler's error, still reachable via Unwrap). NewError and NewErrorf constructors let packages built on form participate in the same taxonomy. Message strings are unchanged.
Writing the Kind tests exposed a latent bug, fixed here: input that sets a key path both as a scalar and a composite (a=1&a.b=2) previously succeeded or failed depending on map iteration order (~79%/21% over 400 trials for map destinations). Keys are now split in sorted order: same input, same outcome, every time.
image/color support (#52)
The fixed-channel image/color types (NRGBA, RGBA, NRGBA64, RGBA64, Gray, Gray16, Alpha, Alpha16, CMYK) decode from hex strings — including exactly what HTML <input type=color> submits (#rrggbb), with CSS shorthand accepted. Encoding is byte-faithful per type and every value round-trips exactly; consequently the premultiplied types reject values whose channels exceed alpha (almost always a straight-alpha value aimed at the wrong type — the error says so and points to NRGBA). The pre-existing composite representation (c.R=255&…) still decodes and remains the default encoding; opt into hex with Encoder.HexColors(true).
Key mapping (#53)
Decoder.KeysWith / Encoder.KeysWith apply a user-supplied transformation to struct field names — snake_case being the motivating case — at every nesting level, in form and in form/multipart (value and file fields alike). Tagged fields are exempt: tags name keys verbatim. form deliberately ships no built-in casing heuristic: word-boundary rules are project-specific, and baking one in would freeze its edge cases into the wire format. Nil mapper = zero cost, measured.
Reset, and nil streams no longer crash (#54)
Decoder.Reset(io.Reader) and Encoder.Reset(io.Writer) swap the underlying stream while retaining configuration, enabling reuse (e.g. sync.Pool), per the request in #8 — nine years later, but here. Auditing Reset surfaced a pre-existing crash: NewDecoder(nil).Decode(…) panicked the process. Decode/Encode now return a typed KindIO error on a nil stream; the idiomatic NewDecoder(nil).DecodeString(…) keeps working.
Fuzzing (#55)
A native fuzz target ships with a seed corpus encoding this project's scar tissue — the v1.7.x resource-exhaustion patterns, conflicting key paths, invalid premultiplied colors — asserting decoding never panics and every error is typed. CI runs the seeds on every build (Go 1.18+ rungs).
multipart error parity (#57)
Every form/multipart error is now a *form.Error with an appropriate Kind (a wrong content type is KindUnsupported, not a syntax error); message strings unchanged from v1.8.0. Tag resolution for file fields mirrors form's exactly, including the present-but-unnamed form:",…" case.
MaxBytes (#59)
Decoder.MaxBytes(n) completes the resource-bounding triad: MaxSize and MaxDepth bound amplification, MaxBytes bounds the raw input Decode will buffer. Unlike its siblings it defaults to unbounded — a built-in cap would break legitimately large trusted inputs — so bound untrusted streams explicitly (or wrap with http.MaxBytesReader); the README's Limits section explains.
Input contracts (#60)
Degenerate arguments to the public entry points are now classified rather than surfacing as raw reflect errors: a nil/untyped-nil destination or source fails fast with KindUnsupported, and setting the delimiter equal to the escape rune is rejected up front on both encode and decode (it previously garbled silently). Benign edge cases — nil url.Values, pointer-to-nil-pointer, unexported-only structs — are pinned as intentional. Previously-working calls are byte-identical.
Correctness fixes
Two latent bugs, both pre-existing (present and identical in v1.8.0), found by a pre-release audit and fixed:
Implicit indexing (#65). A repeated key under implicit indexing corrupted every value after the first — bar=A&bar=B into a top-level slice failed outright, and x._=A&x._=B&x._=C stranded the third value at a wrong index in a sparse slice — because the key was rewritten in place across a value's iterations. Both documented indexing forms now behave correctly, with tests finally covering them.
Limit overflow (#63). Decoder.MaxBytes(math.MaxInt64) and multipart.Decoder.MaxFileSize(math.MaxInt64) read nothing instead of everything: the read-one-past-the-bound trick overflowed into a negative io.LimitReader limit (immediate EOF), so a decode against a bound meant as "effectively unbounded" silently succeeded on empty input. Fixed; both entry points are pinned by regression tests.
Performance
Benchmarked v1.7.1 → v1.9.0: no measurable change on any default path (decode within ±1%, encode flat). Decode carries +5 allocations from the determinism fix's key sort — the price of the coin-flip bug being dead. KeysWith costs only its users: nil mapper is allocation-identical to pre-keys builds.
Also
TODO.md is retired in favor of a README Future Work section documenting the intentions deferred to v2 (#58); runnable examples, a CONTRIBUTING guide, and the usual community files round out the repo.
Thanks to @silviogutierrez, @auraborea427, @matthewmueller, and @bretkoppel for reviews this cycle.