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.
Theory questions
`Reflect` API as Default Trap Implementation
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.
`Proxy` and Handler Traps
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.
Practical Proxy Patterns Catalog
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.
Coding practice
`apply` and `construct` Traps
Implement and check your solution in the stated environment before revealing the answer.
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
fnis a callable function.clsis a constructible class. The wrappers return proxies over those targets.logis a synchronous callback that does not throw or mutate supplied values.- Before forwarding each call, send
logone record shaped as{ kind: 'call', args: [...] }. Before forwarding each construction, use{ kind: 'construct', args: [...] }. - The
argsfield 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, includingnewTarget. 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.
Proxy Validator Pattern
Implement and check your solution in the stated environment before revealing the answer.
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
targetis a trusted ordinary record with a null prototype, such asObject.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.schemais a trusted, fixed object whose own string and symbol keys name allowed properties. Its values aretypeofresult 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
TypeErrorfor an unknown key or a mismatchedtypeofvalue 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,
NaNis a number andnullis 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.