How did I leverage Proxies to make JavaScript errors pleasant to work with?

If you ever happened to toss errors around different contexts (say between ports or iframe -> parent window), or perhaps you just intended to forward them over to some external entity such as a server, then it's quite possible your first attempt was unsuccessful.

You most probably executed JSON.stringify(err) and it yielded '{}'. Since the output was not what you expected, you probably had another stub at it and changed your approach. This time around, you used Object.keys or Object.getOwnPropertyNames to obtain the list of all available own keys, yet still, no dice, the result was not quite something you were looking for.

The former call returned an empty array, while the latter had an array containing message as well as some other engine-specific keys, but was missing both the stack and the name of the error.

Then, you supposedly looked for a solution on StackOverflow, copied it, and finished your task. Alternatively, you ended up using some npm package and called it a day.

If you actually looked up a spec or just found a more in-depth explanation of the problem (is it a problem, though?), and it still bothers you, this article will shed light on an alternative approach to the issue, you might not have heard about yet.

Listing keys of a given object

Before we start, it feels appropriate to briefly showcase some of the available ways to obtain the list of keys of an object.

It's worth mentioning, that a valid key of a property cannot be a number. In JS, the key can be either a string or a Symbol. That's it.

If you need to distinguish between the number and string, you should consider using a keyed collection such as Map.

All the functions mentioned can be used to list the properties of an object.

  • Object.keys - own, enumerable, string
  • Object.getOwnPropertyName - own, enumerable & non-enumerable, string
  • Object.getOwnSymbols - own, enumerable & non-enumerable, symbol
  • Reflect.ownKeys - own, enumerable & non-enumerable, symbol & string

You can also use any of the above with conjunction of Object.getOwnPropertyDescriptors (own, enumerable & non-enumerable, symbol & string).

Usage of for..in (own & inherited enumerable, enumerable, string) or Object.entries (own, enumerable, string) is also a possibility.

What do these functions do internally?

I will cover it in another section.

Alternative ways

If you don't want to use any of the functions listed above, or perhaps you feel fancier or are eager to fool your co-workers, here is a bunch of other snippets showing alternative ways to accomplish that.

The following is similar to Object.keys.

const keys = '' in obj ? [''] : [];
JSON.stringify(obj, (key, value) =>
  key === '' ? (keys.length <= 1 ? value : void 0) : void keys.push(key)
);

Ditto, similar to Object.keys.

const keys = Object.assign(
  new Proxy([], {
    set(target, key) {
      target.push(key);
      return true;
    },
  }),
  obj
); // you can toss more objects in, just remember to filter out duplicates if you mind about them
const EXCLUDE_KIND = null; // null for including all keys, true for including non-enumerable only, and false for enumerable only

const keys = [];
Object.defineProperties(
  new Proxy(Object.create(null), {
    defineProperty(target, key, desc) {
      desc.enumerable !== EXCLUDE_KIND && keys.push(key);
      return Reflect.defineProperty(target, key, desc);
    },
  }),
  Object.getOwnPropertyDescriptors(obj)
);

or the same solution as above with a tiny twist

const EXCLUDE_KIND = null; // null for including all keys, true for including non-enumerable only, and false for enumerable only

const keys = Object.defineProperties(
  new Proxy([], {
    defineProperty(target, key, desc) {
      desc.enumerable !== EXCLUDE_KIND && target.push(key);
      return true;
    },
    getOwnPropertyDescriptor: (target, key) =>
      Reflect.getOwnPropertyDescriptor(obj, key),
  }),
  Object.getOwnPropertyDescriptors(obj)
);

A brief note on a property descriptor

I won't go into details here, but in order to better understand the topic, please familiarize yourself with spec or refer to the following documentation on MDN.

So, what's the case with Error?

To get a better handle of why using Object.keys or a different method does not yield the desired results, let's see how it looks like internally.

You can either do it empirically or by looking up the spec. Do note that the implementation may differ. Firefox (SpiderMonkey), for instance, exposes extra properties on each error, while Chromium (V8) does not. Nonetheless, let's consider spec as the source of truth.

The error bit can be found here.

For us, the most relevant part of it is step number 3.
In the JS code, this could be expressed in the following way:

function Type(x) {
  if (x === null) {
    return 'null';
  }

  return typeof x;
}

// https://tc39.es/ecma262/#sec-tostring
function ToString(argument) {
  switch (Type(argument)) {
    case 'undefined':
      return 'undefined';
    case 'null':
      return 'null';
    case 'number':
      // this should implement https://tc39.es/ecma262/#sec-numeric-types-number-tostring, but to keep things simple, we just call String
      // this is not exactly the same, as it firstly 'calls' https://tc39.es/ecma262/#sec-string-constructor-string-value
      // and eventually falls back to that https://tc39.es/ecma262/#sec-numeric-types-number-tostring
      return String(argument);
    case 'boolean':
      return argument ? 'true' : 'false';
    case 'string':
      return argument;
    case 'symbol':
      throw TypeError();
    case 'bigint':
      // same problem as with number https://tc39.es/ecma262/#sec-numeric-types-bigint-tostring
      return String(argument);
    case 'object':
      return ToString(ToPrimitive(argument, 'string'));
  }
}

// https://tc39.es/ecma262/#sec-getmethod
function GetMethod(V, P) {
  // we're skipping the assertion here, as mentioned in step no 1
  // to make sure this doesn't get too lengthy, we're avoiding GetV as well.
  // simplified step no 2
  const func = V[P];
  // step 3
  if (func === void 0 || func === null) {
    return void 0;
  }

  // simplified step 4, this should be using IsCallable
  if (typeof func !== 'function') {
    throw TypeError();
  }

  return func;
}

function ArrayLike() {
  const obj = Object.create(null);

  for (let i = 0; i < arguments.length; i++) {
    obj[i] = arguments[i];
  }

  obj.length = arguments.length;

  return obj;
}

// https://tc39.es/ecma262/#sec-ordinarytoprimitive
function OrdinaryToPrimitive(O, hint) {
  // step 1
  if (Type(O) !== 'object') {
    throw TypeError();
  }

  // step 2
  if (hint !== 'string' && hint !== 'number') {
    throw TypeError();
  }

  // step 3 & 4
  const methodNames =
    hint === 'string'
      ? ArrayLike('toString', 'valueOf')
      : ArrayLike('valueOf', 'toString'); // slightly safer alternative to arrays

  // modified step 5
  for (let i = 0; i < methodNames.length; i++) {
    const name = methodNames[i];
    // modified step 5.a
    const method = O[name];

    // modified step 5.b
    if (typeof method === 'function') {
      // modified step 5.b.i
      const result = Reflect.apply(method, O, []);
      // step 5.b.ii
      if (Type(result) !== 'object') {
        return result;
      }
    }
  }

  // step 6
  throw TypeError();
}

// https://tc39.es/ecma262/#sec-toprimitive
function ToPrimitive(input, preferredType) {
  // we're skipping the assertion here, as mentioned in step no 1

  // step 2
  if (Type(input) === 'object') {
    // 2.a
    const exoticToPrim = GetMethod(input, Symbol.toPrimitive);
    // 2.b
    if (exoticToPrim !== void 0) {
      if (preferredType === void 0) {
        // 2.b.i
        var hint = 'default';
      } else if (preferredType === 'string') {
        // 2.b.ii
        var hint = 'string';
      } else {
        // 2.b.iii
        if (preferredType !== 'number') {
          // 2.b.iii.1
          throw AssertionError();
        }

        var hint = 'number'; // 2.b.iii.2
      }

      // simplified 2.b.iv
      const result = Reflect.apply(exoticToPrim, input, [hint]);

      // 2.b.v
      if (Type(result) !== 'object') {
        return result;
      }

      // 2.b.vi
      throw TypeError();
    }

    // 2.c
    if (preferredType === void 0) {
      preferredType = 'number';
    }

    // 2.d
    return OrdinaryToPrimitive(input, preferredType);
  }

  return input;
}

// https://tc39.es/ecma262/#sec-definepropertyorthrow
function DefinePropertyOrThrow(O, P, desc) {
  // we avoid steps 1 and 2

  // simplified step 3
  const success = Reflect.defineProperty(O, P, desc);

  // step 4
  if (success === false) {
    throw TypeError();
  }

  // step 5
  return success;
}

// this is a tweaked version of https://tc39.es/ecma262/#sec-nativeerror
// we avoid step and assume our Error is always invoked with `new` or with `Reflect.construct`.
function MyError(message) {
  // modified step 2
  const O = Object.create(Error.prototype);
  // step 3
  if (message !== void 0) {
    // 3.a
    const msg = ToString(message);
    const msgDesc = {
      value: msg,
      writable: true,
      enumerable: false,
      configurable: false,
    };
    DefinePropertyOrThrow(O, 'message', msgDesc);
  }

  // step 4
  return O;
}

An extra note on handling numbers.

I avoided toString method on purpose. It's just not a safe way to cast a number to a string.

import { expect } from 'chai';

Number.prototype.toString = () => '4';

const message = 2;

expect(Error(message).message).to.equal('2');
expect(Error(message.toString()).message).to.equal('4');

All assertions above are met.

Alright, back to the article.

As you can see, there is only one own property that's defined with a key equaling message.

The property descriptor has [[Enumerable]] set to false.

This is why, Object.keys returns an empty array. There is literally 'nothing' that would be enumerable.

Object.getOwnPropertyNames or Reflect.ownKeys return an array with message among its entries.

Alright, but what about name and stack?

Let's scroll down a bit to find out. Perhaps they are inherited?
name is indeed defined on the prototype, hence inherited, yet stack is missing. Well, the explanation is fairly simple here. stack is basically a non-standard property.

It's implemented in most of the engines, but it's non-standard, meaning it's not required to be present.

message is also among the properties.

Solution

Now, that we got a grasp of how the instance of Error looks like, we can try to think of possible solutions to our problem.

We know that Error has one own property called message and it's non-enumerable, as well as a few inherited.

Can we make any effective use of that? The answer is: yes, we can.

Can we instrument someMagic to make some actual magic for us?

import { expect } from 'chai';

const err = someMagic(ReferenceError('Impossible does not exist'));

expect(Object.getOwnPropertyNames(err)).to.deep.equal([
  'message',
  'name',
  'stack',
]);
expect(Object.keys(err)).to.deep.equal(['message', 'name', 'stack']);
expect(err).to.be.instanceof(ReferenceError);
expect(err.constructor).to.equal(ReferenceError);
expect(Object.getPrototypeOf(err)).to.equal(ReferenceError.prototype);

Yes, we can!

First of all, we need to learn how Object.keys and co. works under the hood.

We'll need to immerse a bit into the spec, but you won't sink, no worries.

If you take a look at a function such as Object.keys, you should be able to see it invokes EnumerableOwnPropertyNames.

EnumerableOwnPropertyNames? That may not tell you much. Yet.
Let's see if it's used in a different spot.

More matches found!

Object.entries,
Object.values,
InternalizeJSONProperty and
SerializeJSONObject.

Interesting.

I don't see Object.getOwnPropertyNames or Object.getOwnPropertySymbols listed.

They must be using something else, don't they? Let's explore the specification again.

Alright, they both invoke GetOwnPropertyKeys.

Okay, so we have most of them covered, but we still didn't get to Reflect.ownKeys.

Huh, this one looks simple.

It seems like it 'calls' some [[OwnPropertyKeys]] internal method of my object.

Now, let's study EnumerableOwnPropertyNames and GetOwnPropertyKeys to see whether they have anything in common.

Oh, huh, our previously mentioned [[OwnPropertyKeys]] is in use again.

In both cases.

Moreover, we consume the result of that invocation as the list of keys.

Great, we know that we need to intercept that call somehow and return the keys we care about, namely name & message, and, if available, stack.

Learning more about [[OwnPropertyKeys]] won't hurt, will it?

As linked above, the spec says that the following internal method is ought to

Return a List whose elements are all of the own property keys for the object.

It's been pretty obvious since this is what the name suggests as well as we could deduct it based on how it's used.

If you scroll down a tiny bit, you'll find a more detailed expectation of how [[OwnPropertyKeys]] is supposed to behave.

As per spec, we must comply with the following rules:

  • The normal return type is List.
  • The returned List must not contain any duplicate entries.
  • The Type of each element of the returned List is either String or Symbol.
  • The returned List must contain at least the keys of all non-configurable own properties that have previously been observed.
  • If the object is non-extensible, the returned List must contain only the keys of all own properties of the object that are observable using [[GetOwnProperty]].

In other words, we must return an array with no duplicates, and elements of two kinds are allowed: string and symbol.

Furthermore, we need to make sure to include certain keys as described in requirement no 4.

The last one does not apply to us since Errors are extensible... assuming you didn't alter the behavior at runtime.

Before we get straight to it, let's see if we can intercept that call somehow, whether there is any way to provide our own [[OwnPropertyKeys]].

Prior to that, we won't be able to move forward.

After spending a bit of time going through spec, it seems like Proxies will let us achieve the goal.

The table of available internal methods makes it clear to us.

We got to use ownKeys trap.

Now, the best part - code.

import { expect } from 'chai';

function someMagic(err) {
  return new Proxy(err, {
    // you can safely move these traps out of the someMagic fn
    ownKeys(target) {
      const keys = ['message', 'name'];
      if ('stack' in target) keys.push('stack');
      return keys;
    },
  });
}

const err = someMagic(ReferenceError('Impossible does not exist'));

expect(Object.getOwnPropertyNames(err)).to.deep.equal([
  'message',
  'name',
  'stack',
]);
expect(Object.keys(err)).to.deep.equal(['message', 'name', 'stack']);
expect(err).to.be.instanceof(ReferenceError);
expect(err.constructor).to.equal(ReferenceError);
expect(Object.getPrototypeOf(err)).to.equal(ReferenceError.prototype);

Upon execution, an AssertionError is thrown.

expect(Object.keys(err)).to.deep.equal(['message', 'stack', 'name']);
                                 ^
AssertionError: expected [] to deeply equal [ 'message', 'stack', 'name' ]

Object.getOwnPropertyNames, however, did yield the results we assumed.

What about JSON.stringify?

Evaluate the snippet below to find out.

expect(JSON.stringify(err)).to.equal('{}');

As expected, the assertion is met.

What are we missing?

Let's pay yet another visit to our beloved spec.

EnumerableOwnPropertyNames makes another call and access the value returned by [[GetOwnProperty]].

When desc exists and desc.[[Enumerable]] is true, it appends the key to the list.

Interesting. Before we learned that our properties were non-enumerable, thus we need to intercept that call and make it return a slightly adjusted descriptor.

import { expect } from 'chai';

function someMagic(err) {
  return new Proxy(err, {
    // you can safely move these traps out of the someMagic fn
    getOwnPropertyDescriptor(target, key) {
      return { configurable: true, enumerable: true, value: target[key] };
    },
    ownKeys(target) {
      const keys = ['message', 'name'];
      if ('stack' in target) keys.push('stack');
      return keys;
    },
  });
}

const err = someMagic(ReferenceError('Impossible does not exist'));

expect(Object.getOwnPropertyNames(err)).to.deep.equal([
  'message',
  'name',
  'stack',
]);
expect(Object.keys(err)).to.deep.equal(['message', 'name', 'stack']);
expect(err).to.be.instanceof(ReferenceError);
expect(err.constructor).to.equal(ReferenceError);
expect(Object.getPrototypeOf(err)).to.equal(ReferenceError.prototype);

Bingo. All assertions are met!

What about JSON.stringify?

{
  "message": "Impossible does not exist",
  "name": "ReferenceError",
  "stack": "ReferenceError: Impossible does not exist\n    at file:///<redacted>:18:23\n    at ModuleJob.run (internal/modules/esm/module_job.js:146:23)\n    at async Loader.import (internal/modules/esm/loader.js:165:24)\n    at async Object.loadESM (internal/process/esm_loader.js:68:5)"
}

Boom. Here we go.

What if I want to return all keys a given instance of Error exposes?

It's as easy as tweaking the ownKeys method above.

function someMagic(err) {
  return new Proxy(err, {
    getOwnPropertyDescriptor(target, key) {
      return { configurable: true, enumerable: true, value: target[key] };
    },
    ownKeys(target) {
      return Array.from(
        new Set([
          ...Reflect.ownKeys(Object.getPrototypeOf(target)),
          ...Reflect.ownKeys(target),
        ])
      );
    },
  });
}

We use Set for our convenience to make sure the array we create has no duplicate entries.

That's all.

If you deal with an error that extends native error, you will most likely end up with an extra prototype chain.

The following example accounts for that.

function someMagic(err) {
  return new Proxy(err, {
    getOwnPropertyDescriptor(target, key) {
      return { configurable: true, enumerable: true, value: target[key] };
    },
    ownKeys(target) {
      const keys = [];

      for (const prototype of traversePrototypeUntilErrorPrototype(target)) {
        for (const key of Reflect.ownKeys(prototype)) {
          if (!keys.includes(key)) {
            keys.push(key);
          }
        }
      }

      return keys;
    },
  });
}

function* traversePrototypeUntilErrorPrototype(prototype) {
  while (prototype !== null && prototype !== Error.prototype) {
    yield prototype;
    prototype = Object.getPrototypeOf(prototype);
  }
}

I've created an npm package with a more robust implementation of the above concept.

It's called magic-error.


The image is an official screenshot from bioshockinfinite.com, 2016

Leave a Comment

Your email address will not be published. Required fields are marked *