@verifyhash/query-string
v0.1.0
Published
Zero-dependency URL query-string parse + stringify for Node.js: leading-'?' tolerance, repeated keys to arrays, bracket arrays (a[]=), one documented level of bracket nesting (a[b]=c), '+'-as-space decoding, and malformed percent-sequences that never thro
Maintainers
Readme
@verifyhash/query-string
Zero-dependency URL query-string parse + stringify for Node.js, typed
from birth (hand-written index.d.ts shipped with the package). It covers
the query-string shapes real web apps actually exchange — repeated keys,
bracket arrays, one level of a[b]=c nesting — and it never throws on
malformed input to parse() (a bad %GG sequence falls back to the raw
token instead of crashing your request handler).
Who it's for: Node.js developers who want predictable query-string
behavior without pulling in qs (which has 5+ transitive behaviors around
deep nesting and allocation limits) and without the URLSearchParams
limitations (no arrays-by-default, no nesting, iterator-based API).
Install
npm install @verifyhash/query-stringExample
const qs = require('@verifyhash/query-string');
qs.parse('?tag=a&tag=b&filter[color]=red&page=');
// { tag: ['a', 'b'], filter: { color: 'red' }, page: '' }
qs.parse('ids[]=7&ids[]=9&q=caf%C3%A9+au+lait');
// { ids: ['7', '9'], q: 'café au lait' }
qs.stringify({ tag: ['a', 'b'], filter: { color: 'red' }, page: '' });
// 'tag[]=a&tag[]=b&filter[color]=red&page='
qs.stringify({ q: 'two words' }, { spaceAsPlus: true });
// 'q=two+words' (default is %20: 'q=two%20words')Round-trip property: qs.parse(qs.stringify(x)) deep-equals x for the
supported shape set (see limits below) — the test suite proves it on a table
of shapes including single-element arrays, which survive because arrays
default to bracket format (a[]=only parses back to ['only'], whereas
repeat format would collapse it to the string 'only').
API
parse(str, opts?)
Parses a query string (a leading ? is tolerated and stripped) into a
null-prototype object — keys like __proto__ are plain data and cannot
pollute Object.prototype, but it also means you should use Object.keys(obj)
rather than obj.hasOwnProperty(...) on the result.
- Repeated keys become arrays:
a=1&a=2→{ a: ['1', '2'] }. The first occurrence stays a scalar; the second converts the slot to an array. - Bracket array syntax:
a[]=1&a[]=2→{ a: ['1', '2'] }, and a singlea[]=1is still{ a: ['1'] }(an array, unlike a plaina=1). - One level of nesting:
a[b]=c→{ a: { b: 'c' } }. Repeated nested keys array-merge too:a[b]=1&a[b]=2→{ a: { b: ['1', '2'] } }. Bracket syntax is recognized after percent-decoding, sob%5Bc%5D=dalso nests. - Decoding:
+means space (in keys and values), then percent-decoding is applied. Malformed percent-sequences never throw — the token falls back to its raw text with+already converted (a=%GG+x→{ a: '%GG x' }). - Empty and valueless keys:
a=→{ a: '' }always. A barea(no=) is also{ a: '' }by default; pass{ nullForBare: true }to get{ a: null }instead so you can distinguish?flagfrom?flag=. - Empty pairs are skipped:
a=1&&b=2parses likea=1&b=2. =inside a value survives:a=b=c→{ a: 'b=c' }(split on the first=only).- Non-string input returns
{}.
Options (ParseOptions):
| option | default | effect |
|---------------|---------|----------------------------------------------------|
| nullForBare | false | bare keys parse to null instead of '' |
stringify(obj, opts?)
Serializes a plain object to a query string (no leading ?), using
encodeURIComponent (spaces become %20 by default). Output key order is
the object's insertion order — stable and predictable.
- Values:
stringas-is;number/boolean/bigintviaString();nullemits a bare key ({ a: null }→'a');undefinedis skipped entirely. - Arrays: default
arrayFormat: 'bracket'emitsa[]=1&a[]=2. Opt into'repeat'fora=1&a=2— but note a single-element array then round-trips to a plain string. - One level of nesting:
{ a: { b: 'c' } }→'a[b]=c'. Arrays inside a nested object emit repeated nested keys (a[b]=1&a[b]=2). - Anything deeper throws
TypeError— see limits.
Options (StringifyOptions):
| option | default | effect |
|---------------|-------------|--------------------------------------------------|
| spaceAsPlus | false | encode ' ' as + instead of %20 (keys too) |
| arrayFormat | 'bracket' | 'bracket' → a[]=1; 'repeat' → a=1 |
Limits (honest)
- Nesting is exactly one level deep, on purpose.
parse('a[b][c]=d')does NOT build{ a: { b: { c: 'd' } } }— the key stays literal:{ 'a[b][c]': 'd' }.stringify({ a: { b: { c: 'd' } } })throws aTypeErrorrather than half-implementing recursion. If you need deep structures in a URL, JSON-encode the value instead. - Bracket characters in data keys are ambiguous. Because bracket syntax
is recognized after decoding, a literal key
'a[b]'stringifies toa%5Bb%5D=...but parses back as nesting{ a: { b: ... } }. Such keys are outside the round-trip set. - Conflicting shapes fall back to literal keys.
a=1&a[b]=2parses to{ a: '1', 'a[b]': '2' }— the nested pair keeps its literal key rather than silently destroying the scalar. - Empty arrays vanish.
stringify({ a: [] })emits nothing fora, so they do not round-trip (query strings have no way to say "empty list"). - All parsed leaves are strings (or
nullwithnullForBare) — there is no number/boolean coercion on parse, sostringify({ a: 1 })round-trips to{ a: '1' }.
Tests
node test/index.test.js65 checks: a golden-vector table for parse and stringify (repeated keys,
bracket arrays, nesting, +/percent decoding, empty/valueless keys, malformed
%GG/%E4% sequences, && pairs, unicode, = inside values, a 500×
repeated key, prototype-pollution safety) plus a round-trip property table.
License
MIT
