
learnings, nerdisms, bicycles
When you're writing web applications, it's extremely common to take a URL and
transform it in some way. The common approaches have always felt off. Clunky,
imperative, or deprecated APIs are your only default platform options. That's
why I built whatwg-url-fns - to give myself (and hopefully others) a better
way to work with URLs.
The WHATWG URL constructor works fine for parsing URLs, but transforming them? Not so much:
// Want to change the host and add a query param? Prepare for mutation hell
const url = new URL("https://foo.bar/users?page=1");
url.hostname = "bar.baz";
url.origin = "http://bar.qux";
url.port = "3000";
url.pathname += "123";
url.searchParams.set("version", "v1");
url.searchParams.set("version", "v2");
url.searchParams.delete("page");
// https://bar.baz:3000/users123?version=v2The problems here:
url.parse()/url.format()If the url module was first class in the browser AND the module wasn't deprecated,
honestly, I might still use it. However, neither is the case!
const url = require("url");
const parsed = url.parse("https://example.com/path");
const result = url.format({
...parsed,
hostname: "newhost.com",
search: "?foo=bar",
});It's not too bad, but woof--look at that stringy search! You're back to
manually handling query strings, which is hazardous.
Here's something cool - the library uses TypeScript discriminated unions to make certain mistakes literally impossible:
// This won't compile - prevents runtime errors
transform(url, {
origin: "https://example.com",
hostname: "different.com", // ❌ TypeScript error: conflicting origin specification
});
// Instead, you must choose one approach:
transform(url, { origin: "https://example.com" }); // ✅
transform(url, { hostname: "example.com" }); // ✅I haven't seen another URL library that catches this stuff at compile time. Pretty neat.
Check out the difference:
// whatwg-url-fns: Single declarative statement
const newUrl = transform(originalUrl, {
hostname: "api-staging.com",
port: "3000",
pathname: { append: "/v2" },
searchParams: {
set: { version: "2", env: "staging" },
unset: ["debug", "legacy"],
},
});
// Standard approach: Multiple imperative mutations
const newUrl = new URL(originalUrl);
newUrl.hostname = "api-staging.com";
newUrl.port = "3000";
newUrl.pathname += "/v2";
newUrl.searchParams.set("version", "2");
newUrl.searchParams.set("env", "staging");
newUrl.searchParams.delete("debug");
newUrl.searchParams.delete("legacy");The functional version is just... nicer. Easier to read, easier to test, easier to modify later.
// Intelligent path joining with normalization
transform("https://api.com/users/", { pathname: { append: "/123/profile" } });
// Result: https://api.com/users/123/profile (no double slashes)
// Compare to manual string concatenation:
const manual = new URL(baseUrl);
manual.pathname =
manual.pathname.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "");
// Verbose, error-prone, needs edge case handling// Atomic, ordered operations with clear semantics
transform(url, {
searchParams: {
clear: true, // 1. Clear existing params
set: { foo: "bar", baz: "qux" }, // 2. Set new params
unset: ["temp"], // 3. Remove specific params
},
});
// Standard approach requires manual coordination:
url.searchParams.clear();
url.searchParams.set("foo", "bar");
url.searchParams.set("baz", "qux");
url.searchParams.delete("temp");It creates new URL objects instead of mutating existing ones. If you're transforming URLs tens of thousands of times per second, the allocation overhead might matter.
Does it prevent bugs? Yeah. Is it more maintainable? I think so!
The question isn't whether you can manipulate URLs with the standard APIs - of course you can. It's whether dealing with mutation and verbose syntax is worth it when there's a cleaner option. For me, on TypeScript projects with any kind of complex URL handling, it's a no-brainer.