DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

A URL is a tiny structured record, not a string — parse the frame with the platform, hand-roll only the query, rebuild it

A URL looks like one opaque string, but it's really a handful of labelled fields packed together by a strict grammar: scheme://user:pass@host:port/path?query#hash. I built an inspector that takes any URL apart into editable fields, turns the query into a key/value table, and rebuilds the whole thing from scratch when you edit any piece — bidirectional, correctly encoded, no library. The discipline that made it small was knowing what not to hand-roll.

Don't reinvent the grammar — use the platform's URL object

The URL grammar is a minefield: userinfo boundaries, IPv6 [::1] hosts, default-port stripping, punycode, relative resolution. Every browser already implements the WHATWG spec correctly, so I lean on it. new URL(str) throws on anything that isn't a valid absolute URL, and otherwise hands you every part as a property. This one call is my parser and my validator — for everything except the query.

function parseInput(str){
  const u = new URL(str);          // throws if not a valid absolute URL
  return { protocol:u.protocol, username:u.username, password:u.password,
           hostname:u.hostname, port:u.port, pathname:u.pathname,
           hash:u.hash, pairs:parseQuery(u.search) };  // query is ours
}
Enter fullscreen mode Exit fullscreen mode

The query is the one part worth owning

The reason to hand-roll the query is that it is not a map — it's an ordered list of pairs. ?tag=a&tag=b is perfectly legal, and most servers read the repeat as an array. Split on &, then split each piece on the first = (values can contain more), and keep the result as an array so repeated keys survive. Group into {tag:["a","b"]} too early and you silently drop the duplicates.

function parseQuery(search){
  let s = (search || "").replace(/^\?/, "");
  if (s === "") return [];
  return s.split("&").filter(Boolean).map(pair => {
    const eq = pair.indexOf("=");           // first '=' only
    if (eq === -1) return { key: dec(pair), value: "", hasEq: false };
    return { key: dec(pair.slice(0,eq)), value: dec(pair.slice(eq+1)), hasEq: true };
  });
}
Enter fullscreen mode Exit fullscreen mode

Decoding: + is a space, %XX is a byte, never throw

In a query, + means space — a form-encoding leftover — so replace it before decodeURIComponent, which turns each %XX back into its byte and reassembles UTF-8. The trap: a malformed escape like %zz makes decodeURIComponent throw, so I wrap it and fall back to the raw text rather than crash the whole parse on one bad param.

function dec(s){
  try { return decodeURIComponent(s.replace(/\+/g, "%20")); }
  catch (e) { return s; }        // "Caf%C3%A9" -> "Café"; "1+2" -> "1 2"
}
Enter fullscreen mode Exit fullscreen mode

Rebuild: encode each component on its own

Going the other way, encode the key and value separately with encodeURIComponent, then join with = and &. Per-component encoding is the whole trick — a & inside a value becomes %26 and can never be mistaken for a separator. Then reassemble in the one order the grammar allows, giving // only to special schemes or any URL with a host, so mailto: stays mailto:foo@bar without a bogus slash-slash.

Validate the rebuild by feeding it back

The cheapest possible validator is to parse what you just built. If new URL(assembled) succeeds, the edits produced a legal URL and u.href is the normalized form — default ports dropped, host lowercased — which I show alongside the raw rebuild so you see what the browser would actually send. If it throws, surface the message.

It's worth also showing the grouped view, because that's how frameworks hand the query to your code — the first time you see a key store its value, and on a repeat promote it to an array and push, exactly what URLSearchParams.getAll(key) returns for you.

function grouped(pairs){
  const out = {};
  for (const { key, value } of pairs){
    if (!(key in out)) out[key] = value;                  // first: scalar
    else if (Array.isArray(out[key])) out[key].push(value);
    else out[key] = [out[key], value];                    // 2nd: array
  }
  return out;   // tag=food&tag=drink&page=2 -> { tag:["food","drink"], page:"2" }
}
Enter fullscreen mode Exit fullscreen mode

The two directions share one state object. Typing in the URL box parses and repaints every field; editing a field or a cell reads the fields back and re-runs the rebuild. What keeps it loop-free is a quirk of the DOM: setting an input's .value in code never fires its input event, so "parse → fill fields" and "edit field → rebuild" can't ping-pong. Well under 150 lines of real logic. Paste a URL, flip a port, rename a param, and watch it rebuild:

https://dev48v.infy.uk/solve/day50-url-parser.html

Top comments (0)