How @hectoday/http parses queries
You now know how URL and URLSearchParams work. So here’s a question you might not expect: why doesn’t @hectoday/http just use URLSearchParams to parse incoming query strings?
It has its own parseQuery() function instead. Let’s understand why, and then read through the actual source code line by line.
Why not just use URLSearchParams?
URLSearchParams gives you an object with methods like .get() and .getAll(). That works, but @hectoday/http wants something simpler: a plain JavaScript object that you can destructure, spread, and pass around without method calls:
// What URLSearchParams gives you:
URLSearchParams { 'color' => 'red', 'size' => '10' }
// You have to call .get("color") to read values
// What @hectoday/http's parseQuery() gives you:
{ color: "red", size: "10" }
// You can just do obj.color — plain and simple There’s another difference that matters. When a key appears multiple times, URLSearchParams makes you use two different methods to handle it:
// URLSearchParams with duplicates:
params.get("tag"); // "js" ← only the first value
params.getAll("tag"); // ["js", "ts"] ← you need to know to call getAll() You have to know ahead of time that a key might have duplicates, then remember to use getAll() instead of get(). That’s error-prone.
parseQuery() handles this automatically:
parseQuery("tag=js&tag=ts");
// { tag: ["js", "ts"] } ← automatically an array! One value? You get a string. Multiple values? You get an array. No special method needed.
The full source code
Here’s the entire parseQuery() function from @hectoday/http. Don’t worry if it looks like a lot at first. We’ll go through every piece:
function safeDecodeQueryPart(value) {
const normalized = value.replace(/\+/g, " ");
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function parseQuery(search) {
const result = Object.create(null);
if (!search || search === "?") return result;
const qs = search.startsWith("?") ? search.slice(1) : search;
for (const pair of qs.split("&")) {
if (!pair) continue;
const eqIdx = pair.indexOf("=");
const key = safeDecodeQueryPart(eqIdx === -1 ? pair : pair.slice(0, eqIdx));
const value = eqIdx === -1 ? "" : safeDecodeQueryPart(pair.slice(eqIdx + 1));
const existing = result[key];
if (existing === undefined) {
result[key] = value;
} else if (Array.isArray(existing)) {
existing.push(value);
} else {
result[key] = [existing, value];
}
}
return result;
} Let’s break this down.
The helper: safeDecodeQueryPart
function safeDecodeQueryPart(value) {
const normalized = value.replace(/\+/g, " ");
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
} This small function does two things. First, it replaces every + with a space. Remember from the encoding lesson: in query strings, + means space. Second, it tries to decode percent-encoded characters (like %26 back to &) using decodeURIComponent.
What if the encoded string is malformed? Something like %ZZ isn’t valid hex, so decodeURIComponent would throw an error. The try/catch handles that gracefully by returning the string as-is instead of crashing.
The main function: parseQuery
const result = Object.create(null); This creates an empty object with no prototype. A normal object created with {} inherits properties like toString and constructor from Object.prototype. Object.create(null) creates a truly empty object with nothing inherited.
if (!search || search === "?") return result; If the search string is empty, undefined, or just a bare ? with nothing after it, return the empty object immediately. No work to do.
const qs = search.startsWith("?") ? search.slice(1) : search; Strip the leading ? if it exists. url.search gives you "?color=red", but we only want "color=red" for the parsing logic.
for (const pair of qs.split("&")) {
if (!pair) continue; Split the string by & to get individual key-value pairs. The continue skips empty segments, which can happen with URLs like "a=1&&b=2" (double ampersand).
const eqIdx = pair.indexOf("=");
const key = safeDecodeQueryPart(eqIdx === -1 ? pair : pair.slice(0, eqIdx));
const value = eqIdx === -1 ? "" : safeDecodeQueryPart(pair.slice(eqIdx + 1)); For each pair, find the position of the first = sign. If there’s no = (like the parameter verbose in ?verbose&debug), the entire pair is the key and the value is an empty string. If there is an =, everything before it is the key and everything after it is the value. Both are decoded using our helper function.
const existing = result[key];
if (existing === undefined) {
result[key] = value;
} else if (Array.isArray(existing)) {
existing.push(value);
} else {
result[key] = [existing, value];
} This is the part that handles duplicate keys. The first time a key appears, store the value as a string. The second time the same key appears, convert it to an array containing both values. The third time and beyond, push onto the existing array.
Walking through examples
Simple query
parseQuery("?name=Alice&age=30");
// Step 1: remove "?" → "name=Alice&age=30"
// Step 2: split by "&" → ["name=Alice", "age=30"]
// Step 3: split each by "=" →
// "name" = "Alice"
// "age" = "30"
// Result: { name: "Alice", age: "30" } Duplicate keys
parseQuery("tag=js&tag=ts&tag=react");
// First "tag=js" → result.tag = "js" (string)
// Second "tag=ts" → result.tag = ["js", "ts"] (becomes array)
// Third "tag=react" → result.tag = ["js", "ts", "react"] (push)
// Result: { tag: ["js", "ts", "react"] } Encoded values
parseQuery("q=hello+world&filter=price+%3C+100");
// "hello+world" → + replaced with space → "hello world"
// "price+%3C+100" → + → space, %3C → "<" → "price < 100"
// Result: { q: "hello world", filter: "price < 100" } Key without a value
parseQuery("verbose&debug&format=json");
// "verbose" → no "=", so value is ""
// "debug" → no "=", so value is ""
// "format=json" → key is "format", value is "json"
// Result: { verbose: "", debug: "", format: "json" } Malformed encoding (graceful fallback)
parseQuery("q=%ZZbad");
// decodeURIComponent("%ZZbad") throws an error
// safeDecodeQueryPart catches it, returns "%ZZbad" as-is
// Result: { q: "%ZZbad" } The app doesn’t crash. It just stores the raw string. This kind of defensive coding matters in production. You can’t control what data users or bots send to your server.
Where parseQuery is used
Inside @hectoday/http’s request handler, when a request comes in, the framework does this:
// From setup.ts (simplified):
const url = new URL(request.url);
const rawQuery = parseQuery(url.search); It takes the incoming Request, creates a URL object from request.url, passes url.search (like "?color=red&size=10") to parseQuery(), and gets back a plain object like { color: "red", size: "10" }. That object is then made available to your route handler.
Now that you understand how queries are parsed, let’s look at the other half of the equation: how the framework decides which handler should process a request based on the URL’s pathname. That’s routing, and it’s the next lesson.
What does parseQuery('a=1&a=2&a=3') return?