Converting a URL Object to a Plain Object in JavaScript

I needed to convert a URL object to a plain object yesterday. You might have used it before. It's pretty handy for working with URLs!

const url = new URL('https://example.com/');

console.log(url);

// URL {origin: 'https://example.com', protocol: 'https:', username: '', password: '', host: 'example.com', …}

Alas, you can't provide a URL object as data to Handlebars for security reasons. Essentially, allowing prototype properties in that context can lead to arbitrary code execution, which we definitely want to avoid.

So the solution is simple! Turn the URL object into a plain object and pass that to Handlebars instead. Surely we can destructure it and move on.

const url = new URL('https://example.com/');
const plainObject = { ...url };

console.log({ ...url });

// {}

Weird. It's empty. Let's try iterating over its keys instead. If we have the keys, we can loop through them and generate a plain object that way.

const url = new URL('https://example.com/');

console.log(Object.keys(url));

// []

An empty array? Oh, right, URL uses Symbol internally so we have to use getOwnPropertySymbols().

const url = new URL('https://example.com/');

console.log(Object.getOwnPropertySymbols(url).map(key => url[key]))

// []

Ugh, it's still empty? What type of black magic is this?! Fine. Surely we can use Reflect to get the keys.

const url = new URL('https://example.com/');

console.log(Reflect.ownKeys(url))

// []

Well, that's lame. Might as well do it the hard way…

const url = new URL('https://example.com/');
const plainObject = {
  hash: url.hash,
  host: url.host,
  hostname: url.hostname,
  href: url.href,
  origin: url.origin,
  password: url.password,
  pathname: url.pathname,
  port: url.port,
  protocol: url.protocol,
  search: url.search,
  searchParams: url.searchParams,
  username: url.username
}

console.log(plainObject);

// {hash: '', host: 'example.com', hostname: 'example.com', href: 'https://example.com/path/to/file.ext', origin: 'https://example.com', …}

Well, this is what we want, but it's verbose and unreliable if future properties are added. Surely there must be a better way. After throwing this question over to Konnor Rogers, he found a solution.

const url = new URL('https://example.com/');

console.log(Object.getOwnPropertyNames(Object.getPrototypeOf(url)));

// (15) ['origin', 'protocol', 'username', 'password', 'host', 'hostname', 'port', 'pathname', 'search', 'searchParams', 'hash', 'href', 'toJSON', 'toString', 'constructor']

(Cue the "doh!" moment…) 🙈

Well, we finally have the keys so we can create a new object using them. Here's a function that neatly wraps the conversion up.

function urlToPlainObject(url) {
  const keys = Object.getOwnPropertyNames(Object.getPrototypeOf(url));
  const plainObject = {};

  for (key of keys) {
    plainObject[key] = url[key];
  }

  return plainObject;
}

const url = new URL('https://example.com/');

console.log(urlToPlainObject(url));

// {origin: 'https://example.com', protocol: 'https:', username: '', password: '', host: 'example.com', …}

Well, that was harder than it needed to be. Thanks, Konnor!

Update (March 23, 2023) #

Special thanks to Nick Williams who discovered an even simpler approach.

const url = new URL('https://example.com/');

for (field in url) {
  console.log(field);
}

// (14) ['origin', 'protocol', 'username', 'password', 'host', 'hostname', 'port', 'pathname', 'search', 'searchParams', 'hash', 'href', 'toJSON', 'toString']

You might notice there are 14 fields instead of the original 15 now. That's because the constructor property is omitted, which we didn't really want anyways. An updated version of our function looks like this, and it also discards the other non-string properties now.

const url = new URL('https://example.com/');

function urlToPlainObject(url) {
  const plainObject = {};

  for (const key in url) {
    if (typeof url[key] === 'string') {
      plainObject[key] = url[key];
    }
  }

  return plainObject;
}

console.log(urlToPlainObject(url));

// {origin: 'https://example.com', protocol: 'https:', username: '', password: '', host: 'example.com', …}

I have no idea why I didn't try a for loop in the first place. 😅