Simple object validation

Monday 01/08/2022

·6 min read
Share:

The simplest way to add runtime validation to a JavaScript object — without pulling in a schema library — is a Proxy. You wrap the object once, hook the get and set traps, and any write that violates your rules throws or rejects silently depending on how you want to fail. It's the kind of utility that takes 20 lines to write and saves you from the "why is this field a string when it should be a number" debugging session. Below: three Proxy patterns from "validate one field" to "schema-driven validation."

1. Validating a single field

const car = {
    brand: 'Ford',
    year: 2022,
    canDrive: false,
}

const validatedCar = new Proxy(car, {
    set(obj, prop, value) {
        if (prop === 'year' && typeof value !== 'number') {
            throw new TypeError('year must be a number')
        }
        obj[prop] = value
        return true
    },
})

validatedCar.year = 2023      // ✓ ok, sets year to 2023
validatedCar.year = '2024'    // ✗ throws TypeError

The set trap intercepts every assignment. Returning true confirms the write succeeded; throwing aborts it. The original object car is still mutated through the proxy — Proxies don't clone, they intercept.

For non-throwing validation (silently rejecting bad writes):

set(obj, prop, value) {
    if (prop === 'year' && typeof value !== 'number') {
        console.warn(`Rejected: year must be a number, got ${typeof value}`)
        return true  // still return true to suppress strict-mode TypeError
    }
    obj[prop] = value
    return true
}

In strict mode, returning false from set throws a TypeError — usually not what you want for soft validation.

2. Multi-field validation with a rules object

For more than one validated field, pull the rules into a config object:

const rules = {
    year: (v) => typeof v === 'number' && v >= 1900 && v <= 2100,
    brand: (v) => typeof v === 'string' && v.length > 0,
    mileage: (v) => typeof v === 'number' && v >= 0,
}

function validate(target, rules) {
    return new Proxy(target, {
        set(obj, prop, value) {
            const rule = rules[prop]
            if (rule && !rule(value)) {
                throw new TypeError(`Invalid value for ${String(prop)}: ${value}`)
            }
            obj[prop] = value
            return true
        },
    })
}

const car = validate({ brand: 'Ford', year: 2022, mileage: 0 }, rules)

car.year = 2023      // ✓
car.year = 2200      // ✗ throws — out of range
car.brand = ''       // ✗ throws — empty string
car.color = 'red'    // ✓ no rule, passes through

Now adding a new validated field is one line. Unrecognized fields pass through unchanged — useful when you want to validate known properties but allow extension.

3. Schema-driven validation

For a more declarative API, define a schema once and generate the proxy from it:

function createSchema(schema) {
    return (target) => new Proxy(target, {
        set(obj, prop, value) {
            const expected = schema[prop]
            if (!expected) return (obj[prop] = value, true)

            if (typeof value !== expected.type) {
                throw new TypeError(
                    `${String(prop)}: expected ${expected.type}, got ${typeof value}`
                )
            }
            if (expected.min !== undefined && value < expected.min) {
                throw new RangeError(`${String(prop)}: must be >= ${expected.min}`)
            }
            if (expected.max !== undefined && value > expected.max) {
                throw new RangeError(`${String(prop)}: must be <= ${expected.max}`)
            }
            obj[prop] = value
            return true
        },
    })
}

const carSchema = createSchema({
    brand: { type: 'string' },
    year: { type: 'number', min: 1900, max: 2100 },
    mileage: { type: 'number', min: 0 },
})

const car = carSchema({ brand: 'Ford', year: 2022, mileage: 0 })

car.year = 2025        // ✓
car.year = 1850        // ✗ RangeError
car.mileage = -1       // ✗ RangeError

You've reinvented a small fraction of Zod or Yup. The advantage of doing it yourself: no dependency, no bundle cost, and you understand exactly what's happening. The disadvantage: real schema libraries handle nested objects, arrays, custom error messages, async validators, and TypeScript inference — none of which the 20-line proxy does.

Edge cases and gotchas

  • Proxies don't auto-validate at construction. Existing fields pass through unchecked. If you need to validate the initial state, run it through your rules once before wrapping.
  • get trap can validate reads too. Less common, but useful for redacting sensitive fields, computing derived values, or logging access patterns.
  • Nested objects need recursive proxying. A proxy on the outer object doesn't catch writes to obj.nested.field. Either freeze the nested object or proxy it recursively.
  • JSON.stringify works on proxies — the proxy is transparent during serialization, returning the underlying object's data.
  • Performance cost is real but small. Each property access goes through a trap; for a hot path of millions of accesses per second, this measurably matters. For ordinary application code, it's invisible.
  • Reflect.set(obj, prop, value) inside the trap is the spec-compliant way to do the actual write — handles edge cases like setter inheritance and frozen objects.

When to use Proxy validation vs a library

| You have | Use | |---|---| | One or two fields to validate | Inline set trap | | A small schema, no dependency budget | Custom createSchema | | Nested objects, async validators, TS types | Zod, Yup, or Valibot | | Form validation specifically | React Hook Form + Zod |

FAQ

Can a Proxy validate the initial object state?

Not automatically — new Proxy(obj, ...) doesn't run any traps on existing fields. To validate the initial state, run your rules manually before wrapping, or assign each initial value through the proxy.

How do I validate nested object writes through a Proxy?

A top-level proxy only catches writes to top-level properties. Either freeze nested objects with Object.freeze so they can't be mutated, or recursively wrap them in their own proxies. The latter is what schema libraries do under the hood.

Should I throw or return false from the set trap?

Throw if the validation failure should be loud — a bug in the calling code. Return true (after rejecting the assignment) if you want soft validation that logs and continues. Returning false triggers a TypeError in strict mode, which is rarely useful.

Is Proxy validation slower than a schema library?

Pure-JS proxy validation is actually competitive — schema libraries do the same per-property work plus more checks. The real cost difference is when libraries cache compiled validators; for one-off validation, Proxy is fine.

Share:
VA

Vadim Alakhverdov

Software developer writing about JavaScript, web development, and developer tools.

Related Posts