Blog

Validate JavaScript Objects With Proxy

Reading a property that does not exist gives you undefined. A typo in a config key stays quiet until something further down the line breaks.

A Proxy wraps an object and lets you decide what a read or a write means. The handler methods are called traps, and the two you will use most are get and set.

Turn typos into errors

This proxy throws when you read a property the target does not have.

// reject unknown properties
const strict = target => new Proxy(target, {
  get(obj, prop) {
    if (!(prop in obj)) throw new ReferenceError(`No property "${String(prop)}"`);
    return obj[prop];
  }
});
const config = strict({ host: 'localhost', port: 8080 });
console.log(config.host);
try { config.hsot; }
catch (err) { console.log(err.constructor.name + ':', err.message); }

It prints:

localhost
ReferenceError: No property "hsot"

A misspelled key now fails where it is written, instead of producing undefined and failing somewhere else.

Check values on the way in

The set trap runs before the write. It has to return true, or strict mode code throws a TypeError.

// validate on write
const user = new Proxy({}, {
  set(obj, prop, value) {
    if (prop === 'age') {
      if (!Number.isInteger(value)) throw new TypeError('age must be an integer');
      if (value < 0) throw new RangeError('age must be 0 or more');
    }
    obj[prop] = value;
    return true;                       // must return true or the set throws
  }
});
user.age = 30;
console.log('age ->', user.age);
try { user.age = -1; }
catch (err) { console.log(err.constructor.name + ':', err.message); }
try { user.age = 'thirty'; }
catch (err) { console.log(err.constructor.name + ':', err.message); }

It prints:

age -> 30
RangeError: age must be 0 or more
TypeError: age must be an integer

The object cannot be put into a bad state, no matter which code does the writing.

Default values

A get trap can also fill in a value for keys that are missing.

// default values
const withDefault = (obj, fallback) => new Proxy(obj, {
  get: (t, p) => (p in t ? t[p] : fallback)
});
const counts = withDefault({ apples: 3 }, 0);
console.log(counts.apples, counts.pears);

It prints:

3 0

Useful for counters and lookup tables, where the missing case is always the same.

Watch what code touches

Because the trap runs on every read, a proxy is a quick way to see which properties are actually used.

// log every read
const reads = [];
const tracked = new Proxy({ x: 1, y: 2 }, {
  get(t, p) { reads.push(p); return t[p]; }
});
tracked.x; tracked.y; tracked.x;
console.log('reads ->', reads);

It prints:

reads -> [ 'x', 'y', 'x' ]

This is how reactive frameworks track dependencies. They record the reads during a render and re-run when one of those properties changes.

Forward with Reflect

Writing t[p] inside a trap works for simple objects, but it loses the correct this when getters are involved. Reflect has one method per trap with matching arguments.

// forwarding correctly with Reflect
const safe = new Proxy({ a: 1 }, {
  get: (t, p, receiver) => Reflect.get(t, p, receiver)
});
console.log(safe.a, 'a' in safe, Object.keys(safe));

It prints:

1 true [ 'a' ]

Use Reflect as the default action, then add your own behaviour around it.

What to remember

  • A proxy wraps a target and intercepts operations through traps.
  • get runs on reads, set runs on writes and must return true.
  • Reflect.get and friends give you the normal behaviour to fall back on.
  • There is a cost per access, so keep proxies out of hot loops.

Proxies are best used at the edges of a system, such as config objects and test doubles, where the extra checking pays for itself.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.

Leave a Reply