Lesson overview · Free interview practice

Meta-programming

Learn about Proxies and Reflect for dynamic programming.

Topics in the full lesson
  • Introduction to Meta-programming
  • Understanding Proxies
  • Intercepting Object Operations
  • Using the Reflect API
  • Practical Use Cases for Proxies
  • Best Practices
  • Exercises

Practice Problems

Work through a question before revealing its explanation. These questions and answers are free; Premium adds the full lesson walkthrough, examples and implementation detail.

Completion marks record your own progress, not an automatically checked result. The task type does not determine whether it is optional.

TheoryEasy

`Reflect` API as Default Trap Implementation

Question

How does the Reflect API relate to Proxy handler traps? Why is Reflect.get(target, key, receiver) preferred over target[key] inside a get trap?

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryEasy

`Proxy` and Handler Traps

Question

What is a Proxy in JavaScript? Show a Proxy intercepting get, set, and has with the handler trap pattern.

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryMedium

Practical Proxy Patterns Catalog

Question

What are the canonical practical uses of Proxy in JavaScript? Show code for four patterns.

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

CodingEasy

`apply` and `construct` Traps

Implement and check your solution in the stated environment before revealing the answer.

Question

Implement two logging wrappers: loggedCall(fn, log) for function calls and loggedConstructor(cls, log) for class construction. Use the apply and construct traps rather than replacing the target's body.

Contract

  • fn is a callable function. cls is a constructible class. The wrappers return proxies over those targets.
  • log is a synchronous callback that does not throw or mutate supplied values.
  • Before forwarding each call, send log one record shaped as { kind: 'call', args: [...] }. Before forwarding each construction, use { kind: 'construct', args: [...] }.
  • The args field is a fresh shallow array of the arguments in order. Keep object arguments by identity. Do not stringify them or log a result.
  • Forward the same receiver and arguments with Reflect.apply. Preserve the returned value or the exact thrown error.
  • Forward construction with Reflect.construct, including newTarget. A subclass of the wrapped class must still produce subclass instances. Constructor errors propagate unchanged.
  • A log entry records an attempt, so it still appears when the target throws. Do not add catch-and-log behavior.

Review this, class inheritance, and the two Reflect operations before attempting this core practice. A proxy does not make an arrow function constructible or make an object callable.

Local setup

In a new local folder, save the JavaScript starter as starter.mjs and the checks as checks.mjs. Use Node.js 24.21.0. No browser, packages, or network are needed.

node --version
cp starter.mjs solution.mjs
node checks.mjs

The unfinished starter is expected to fail. Edit solution.mjs, then rerun the checks.

For the TypeScript alternative, save starter.mts and this tsconfig.json. Use TypeScript 5.9.3 in strict mode.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": [],
    "noEmitOnError": true,
    "outDir": "build"
  },
  "files": ["solution.mts"]
}
tsc --version
cp starter.mts solution.mts
tsc --project tsconfig.json
node checks.mjs ./build/solution.mjs

The explicit module extensions avoid a package manifest. Use the course's environment setup guidance if Node or the TypeScript compiler is unavailable.

Starters

JavaScript starter: starter.mjs

export function loggedCall(fn, log) {
  throw new Error('Implement loggedCall')
}

export function loggedConstructor(cls, log) {
  throw new Error('Implement loggedConstructor')
}

TypeScript starter: starter.mts

type LogEntry = { kind: 'call' | 'construct', args: unknown[] }
type Logger = (entry: LogEntry) => void

export function loggedCall<This, Args extends unknown[], Result>(
  fn: (this: This, ...args: Args) => Result,
  log: Logger,
): (this: This, ...args: Args) => Result {
  throw new Error('Implement loggedCall')
}

export function loggedConstructor<Args extends unknown[], Instance extends object>(
  cls: new (...args: Args) => Instance,
  log: Logger,
): new (...args: Args) => Instance {
  throw new Error('Implement loggedConstructor')
}

Visible checks

Save these checks as checks.mjs.

import assert from 'node:assert/strict'
import test from 'node:test'

const { loggedCall, loggedConstructor } =
  await import(process.argv[2] ?? './solution.mjs')

test('normal: a call logs before forwarding and preserves this and result', () => {
  const events = []
  const receiver = { base: 10 }
  const payload = { value: 2 }
  const result = { done: true }
  function work(item, extra) {
    assert.equal(events.length, 1)
    assert.equal(this, receiver)
    assert.equal(item, payload)
    assert.equal(extra, 3)
    return result
  }
  receiver.work = loggedCall(work, entry => events.push(entry))
  assert.equal(receiver.work(payload, 3), result)
  assert.deepEqual(events, [{ kind: 'call', args: [payload, 3] }])
})

test('edge: zero arguments, arrow calls, and fresh log arrays', () => {
  const entries = []
  const wrapped = loggedCall((...args) => args.length, entry => entries.push(entry))
  assert.equal(wrapped(), 0)
  assert.equal(wrapped(undefined), 1)
  assert.deepEqual(entries.map(entry => entry.args), [[], [undefined]])
  assert.notEqual(entries[0].args, entries[1].args)
  assert.throws(() => new wrapped(), TypeError)
  assert.equal(entries.length, 2)
})

test('error: a throwing call still logs exactly once', () => {
  const error = new Error('target failed')
  const entries = []
  const wrapped = loggedCall(() => { throw error }, entry => entries.push(entry))
  assert.throws(() => wrapped(1), candidate => candidate === error)
  assert.deepEqual(entries, [{ kind: 'call', args: [1] }])
})

test('normal and edge: construction preserves arguments and subclass newTarget', () => {
  const entries = []
  const token = {}
  class User {
    constructor(name, suppliedToken) {
      assert.equal(entries.at(-1).kind, 'construct')
      this.name = name
      this.token = suppliedToken
      this.actualNewTarget = new.target
    }
  }
  const LoggedUser = loggedConstructor(User, entry => entries.push(entry))
  const user = new LoggedUser('Alice', token)
  assert.equal(user instanceof User, true)
  assert.equal(user.actualNewTarget, LoggedUser)
  assert.equal(user.name, 'Alice')
  assert.equal(user.token, token)
  class Admin extends LoggedUser {}
  const admin = new Admin('Bob', token)
  assert.equal(admin instanceof Admin, true)
  assert.equal(admin instanceof User, true)
  assert.equal(admin.actualNewTarget, Admin)
  assert.equal(Object.getPrototypeOf(admin), Admin.prototype)
  assert.deepEqual(entries, [
    { kind: 'construct', args: ['Alice', token] },
    { kind: 'construct', args: ['Bob', token] },
  ])
  assert.notEqual(entries[0].args, entries[1].args)
})

test('error: a throwing constructor logs the attempt and preserves the error', () => {
  const error = new Error('constructor failed')
  const entries = []
  class Broken {
    constructor() {
      assert.equal(entries.length, 1)
      throw error
    }
  }
  const LoggedBroken = loggedConstructor(Broken, entry => entries.push(entry))
  assert.throws(() => new LoggedBroken(), candidate => candidate === error)
  assert.deepEqual(entries, [{ kind: 'construct', args: [] }])
})

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

CodingHard

Proxy Validator Pattern

Implement and check your solution in the stated environment before revealing the answer.

Question

Implement validated(target, schema) with a Proxy that checks property assignments. Explain when one schema-driven trap is more useful than individual class setters.

Contract

  • target is a trusted ordinary record with a null prototype, such as Object.create(null). It contains only data properties. It may already have values or be non-extensible. You do not need to validate its existing contents.
  • schema is a trusted, fixed object whose own string and symbol keys name allowed properties. Its values are typeof result strings: 'string', 'number', 'boolean', 'bigint', 'symbol', 'undefined', 'object', or 'function'. Schema entries are data properties. Inherited schema entries do not count.
  • Return a proxy over the original target, not a copy. For assignments through that proxy, throw TypeError for an unknown key or a mismatched typeof value before changing the target. Error wording is your choice.
  • Forward accepted writes with Reflect.set(target, key, value, receiver). Return its boolean result rather than claiming every write succeeded.
  • This is shallow assignment validation. In particular, NaN is a number and null is an object under this contract. Direct target writes, deletion, property definition, nested mutations, schema mutation, and arbitrary hostile objects are outside the guarantee. This is not a security barrier.

Before attempting this task, be comfortable with set, Reflect.set, own properties, and typeof. This extends the host lesson's single-property validator.

Local setup

Use a new local folder for this exercise. The checks run on Node.js 24.21.0 without a browser or packages. Save the JavaScript starter as starter.mjs and the visible checks as checks.mjs.

node --version
cp starter.mjs solution.mjs
node checks.mjs

The starter deliberately fails. Implement the function in solution.mjs, then rerun the checks.

For TypeScript, use the alternate starter as starter.mts. Save this configuration as tsconfig.json. The compiler path was checked with TypeScript 5.9.3 in strict mode. The .mts extension makes the module format explicit without a package manifest.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": [],
    "noEmitOnError": true,
    "outDir": "build"
  },
  "files": ["solution.mts"]
}
tsc --version
cp starter.mts solution.mts
tsc --project tsconfig.json
node checks.mjs ./build/solution.mjs

Use the course's JavaScript and TypeScript environment setup guidance if either command is unavailable. There is no network access in this exercise.

Starters

JavaScript starter: starter.mjs

export function validated(target, schema) {
  throw new Error('Implement validated')
}

TypeScript starter: starter.mts

type TypeName =
  | 'string' | 'number' | 'boolean' | 'bigint'
  | 'symbol' | 'undefined' | 'object' | 'function'

type Schema = Readonly<Partial<Record<PropertyKey, TypeName>>>

export function validated(
  target: Record<PropertyKey, unknown>,
  schema: Schema,
): Record<PropertyKey, unknown> {
  throw new Error('Implement validated')
}

Visible checks

These checks cover normal writes, ownership and symbol edges, invalid assignments, and an accepted write that the target cannot perform. Both language versions use the same checks.

Save these checks as checks.mjs.

import assert from 'node:assert/strict'
import test from 'node:test'

const { validated } = await import(process.argv[2] ?? './solution.mjs')

test('normal: writes update the original target', () => {
  const target = Object.create(null)
  const user = validated(target, { name: 'string', age: 'number' })
  assert.notEqual(user, target)
  user.name = 'Alice'
  user.age = 30
  assert.equal(target.name, 'Alice')
  assert.equal(user.age, 30)
  assert.equal(Reflect.set(user, 'age', 31), true)
  assert.equal(target.age, 31)
})

test('edge: own schema keys include symbols but not inherited entries', () => {
  const id = Symbol('id')
  const otherId = Symbol('id')
  const schema = Object.create({ inherited: 'string' })
  schema[id] = 'number'
  schema.toString = 'string'
  const target = Object.create(null)
  const value = validated(target, schema)
  value[id] = 7
  value.toString = 'allowed own key'
  assert.equal(target[id], 7)
  assert.equal(target.toString, 'allowed own key')
  assert.throws(() => Reflect.set(value, otherId, 7), TypeError)
  assert.throws(() => Reflect.set(value, 'inherited', 'no'), TypeError)
  assert.throws(() => Reflect.set(value, 'constructor', 'no'), TypeError)
})

test('error: wrong types and unknown keys do not mutate the target', () => {
  const target = Object.assign(Object.create(null), { age: 30 })
  const user = validated(target, { age: 'number' })
  assert.throws(() => { user.age = '30' }, TypeError)
  assert.throws(() => { user.email = 'a@example.test' }, TypeError)
  assert.equal(target.age, 30)
  assert.equal(Object.hasOwn(target, 'email'), false)
})

test('edge: every supported typeof label uses shallow typeof semantics', () => {
  const schema = {
    text: 'string', count: 'number', flag: 'boolean', big: 'bigint',
    token: 'symbol', absent: 'undefined', data: 'object', run: 'function',
  }
  const target = Object.create(null)
  const value = validated(target, schema)
  const entries = {
    text: '', count: NaN, flag: false, big: 1n,
    token: Symbol('token'), absent: undefined, data: null, run: () => 1,
  }
  for (const key of Object.keys(entries)) {
    assert.equal(Reflect.set(value, key, entries[key]), true)
    assert.equal(target[key], entries[key])
  }
  const nested = { age: 'not recursively checked' }
  value.data = nested
  assert.equal(target.data, nested)
})

test('error: forwarding preserves a failed target write', () => {
  const target = Object.freeze(Object.assign(Object.create(null), { age: 30 }))
  const user = validated(target, { age: 'number' })
  assert.equal(Reflect.set(user, 'age', 31), false)
  assert.throws(() => { user.age = 31 }, TypeError)
  assert.equal(target.age, 30)
})

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

Explore the full Meta-programming material

Premium includes the complete lessons and implementation references. Free practice questions remain available without a subscription.

All course tracks & premium content
From basics to advanced masterclasses
Built for JS/TS developers like you
Real-world tips & common pitfalls
Upgrade to Premium