I spent the weekend rewriting qs, the query-string parser bundled inside Express, used across a huge chunk of the Node ecosystem to Go language. This is what actually happened: what I picked, where my first version was wrong, how I checked that the rewrite behaves the same as the original.
Why qs, why Go
I wanted a library that mattered and a library I could actually finish. qs fit both: 8.9k stars on github, a real dependency in a lot of production code, and about 1,000 lines of core logic. To port properly in a weekend instead of half-porting something bigger.
Go was the target for a specific reason, not just because "Go is fast." qs today only runs inside a Node process ; npm install, node_modules, a JS runtime spinning up to split a query string. A Go port compiles to a single static binary with no runtime and, if I did it right, no external dependencies at all. That last part turned out to matter more than I expected, because it ruled out the easy way to handle one particular edge case (more on that below).
The hackathon's rule made the whole thing harder in a useful way: the port has to pass the original test suite, completely unmodified, not a rewritten version of it that matches my own understanding of the spec.
Getting the original tests to run against Go at all
JavaScript can't call a Go function directly, so before porting any logic I had to build a bridge between the two. The Go code compiles into a CLI that reads a JSON request off stdin and writes a JSON response to stdout:
// request → bin/qs (stdin)
{"method": "parse", "args": ["a[b]=c", {}]}
// response ← bin/qs (stdout)
{"result": {"a": {"b": "c"}}}
To keep the test files themselves untouched, I intercepted Node's module loader so that when the original ljharb/qs tests call require('../'), they get silently redirected to a small adapter that shells out to the Go binary instead of the real package. The test files never change, tests/original/ matches the upstream commit (3a890d4) byte for byte, verifiable with git diff.
This mattered because it's the part that's easiest to fudge under a deadline.
What was actually broken
My first working version passed 166 of 241 parse tests. By the end it was 221 of 241 which brings it upto 390 of 410 across the full suite, 95% and almost every point between those two numbers came from a specific, traceable bug.
Scalar-into-array merges were structurally wrong.
input: a=b followed by a[]=c
expected: ["b", "c"] // qs wraps the scalar, then concatenates
got: {"0": "b", "1": "c"} // my merge built an index-keyed object instead
That looks similar in a JSON dump but is a different data structure. The fix was small once I found it. Detect "target is scalar, source is array" as its own case:
// merge.go
if isScalar(target) {
return append([]any{target}, source...)
}
But finding it meant realizing the merge code had no concept of what a value used to be before merging, only what it currently looked like.
a[0]=b and a[]=c weren't recognized as the same array.
input: a[0]=b followed by a[]=c
expected: {a: ['b', 'c']}
got: {a: {0: ['c', 'b']}}
I was parsing bracket-index notation and empty-bracket notation into two separate arrays, then trying to reconcile them during a merge step. By that point the information needed to merge them correctly was already gone. Nine tests failed on this shape before I traced it back to where the real fix belonged: the original resolves this ambiguity during key parsing, before any object gets constructed, not afterward.
depth: false was silently ignored.
Not a parsing bug but a bridge bug. qs treats depth: false as "no limit," but my CLI's option decoder only checked for a numeric depth field in the incoming JSON:
// before — bool false falls through, depth stays at its default (5)
switch v := opts["depth"].(type) {
case float64:
parsed.Depth = int(v)
}
// after
switch v := opts["depth"].(type) {
case float64:
parsed.Depth = int(v)
case bool:
if !v {
parsed.Depth = 0
}
}
One added case fixed it. The lesson: when you port a library across a JSON-serialized boundary, the serialization layer accumulates its own bugs, separate from whatever you're porting.
Some failures I left as documented limitations rather than forcing a fix. Eight tests expect a JavaScript TypeError for something like passing null where a boolean belongs but JSON.stringify collapses "explicitly null," "undefined," and "never set" into the same thing once it crosses into JSON, so by the time it reaches Go it's just nil. Recovering that distinction needs type-checking on the JavaScript side of the bridge, before the JSON is built, a different piece of work than porting qs itself. One test passes a circular reference into parse(), which can't be fixed inside this architecture at all: json.Marshal cannot serialize a cycle, in any language.
Checking equivalence beyond what the tests happened to cover
A test suite only checks the inputs someone thought to write down. To go past that, I ran Go's native fuzzer against a roundtrip property that doesn't depend on any test file:
// fuzz/harness_test.go
func FuzzRoundtrip(f *testing.F) {
f.Fuzz(func(t *testing.T, raw string) {
a := qs.Parse(raw, qs.DefaultOptions())
b := qs.Parse(qs.Stringify(a, qs.DefaultOptions()), qs.DefaultOptions())
if !reflect.DeepEqual(a, b) {
t.Fatalf("roundtrip mismatch for %q:\n a=%#v\n b=%#v", raw, a, b)
}
})
}
Across millions of randomly generated inputs; deep nesting, malformed percent-encoding, empty and boundary values. It caught a real divergence within the first run:
input: "%80" // a lone, invalid continuation byte
parse: string containing raw byte 0x80 // Go strings can hold arbitrary bytes
stringify: "%EF%BF%BD" // range over the string replaces
// the invalid byte with U+FFFD first
JavaScript never hits this because decodeURIComponent throws on malformed input before a bad byte can exist as a JS string at all. I documented it rather than trying to patch around it: for binary-safe roundtrips, base64-encode first, the same advice the WHATWG URL spec gives for the same class of problem.
cold start and memory numbers are real and measured:
realist@realist-MacBook-Pro portmortem-qs % go test -bench=. -benchmem -benchtime=1s ./bench/
goos: darwin
goarch: arm64
pkg: github.com/Len3hq/qs-go/bench
cpu: Apple M1
BenchmarkParse-8 47172 25417 ns/op 38255 B/op 453 allocs/op
BenchmarkParseSimple-8 1393298 845.4 ns/op 1664 B/op 19 allocs/op
BenchmarkParseNested-8 580536 1964 ns/op 2849 B/op 36 allocs/op
BenchmarkParseArray-8 394047 2965 ns/op 4705 B/op 66 allocs/op
BenchmarkStringify-8 112891 10538 ns/op 4729 B/op 223 allocs/op
BenchmarkStringifySimple-8 5312035 226.2 ns/op 72 B/op 6 allocs/op
BenchmarkStringifyNested-8 1427907 941.0 ns/op 248 B/op 16 allocs/op
BenchmarkStringifyArray-8 770910 1471 ns/op 568 B/op 31 allocs/op
PASS
ok github.com/Len3hq/qs-go/bench 12.840s
Where JS and Go just model things differently
A few divergences weren't bugs at all, they're places where the two languages disagree about what's even representable and the honest move is documenting the gap.
qs guards against keys named __proto__ because writing to it in JavaScript can reach up and mutate Object.prototype for the whole program:
// why the guard exists in JS
const obj = {};
obj.__proto__.polluted = true;
({}).polluted; // true — every object in the program is now affected
Go maps have no prototype chain, so the same key is just a string like any other:
m := map[string]any{"__proto__": "x"}
// m["__proto__"] is just a string key. Nothing else in the program is reachable from it.
I kept the AllowPrototypes option for API compatibility, but it's a documented no-op, because the thing it protects against doesn't exist in Go.
Sparse arrays are the same kind of gap:
JS: [, 'b', , 'd'] // holes — positions never assigned anything
Go: make([]any, 4) // [nil, nil, nil, nil] — no concept of "never assigned"
// vs. "explicitly set to null"
I used nil for holes and a separate sentinel type for explicit nulls, which recovers the distinction in most cases, but the original test suite's equality check still treats a JS hole and a Go nil as different things in a handful of deeply nested cases.
The ISO-8859-1 charset support was a smaller version of the same tradeoff, but a deliberate one. qs leans on a quirk of the browser's old unescape() function for this charset. Go's standard library has no equivalent, and the easy fix go get golang.org/x/text was one command away. I didn't take it, because a single external dependency for one option would have undercut the whole pitch of a zero-dependency static binary. I hand-wrote the byte-to-rune mapping table instead:
// Latin-1's first 256 codepoints map directly onto Unicode's first 256.
func latin1ToRune(b byte) rune {
return rune(b)
}
Slower to build, and the right call for what the port was supposed to be.
What's still unfinished
My CLI currently uses Go's panic() to reject invalid option types:
// current — kills the binary, JS side gets silence instead of a TypeError
if _, ok := opts["decodeDotInKeys"].(bool); !ok {
panic("decodeDotInKeys must be a boolean")
}
That single shortcut accounts for eight of my remaining twenty test failures and isn't a hard fix, it's the one I deprioritized in favor of the merge-logic bugs that felt more urgent at the time. It needs to become a returned error that the adapter turns back into a real TypeError, not a crash.
What's shipped now passes 390 of 410 tests from the completely unmodified original suite (95% pass) and every remaining failure traces to something specific: a handful to a real limitation of talking to Go over JSON, the rest to documented differences between how JavaScript and Go represent objects and arrays. Not logic I got wrong and didn't notice, logic I can point to and explain.
Repo: github.com/Len3hq/qs-go — built for Port Mortem 2026, Track F (JavaScript → Go), hosted by HackathonRaptors.
Top comments (0)