“How would you add a property to an object that cannot be changed, does not show up in Object.keys, and cannot be deleted?” Or: “What is the difference between obj.x = 1 and Object.defineProperty(obj, 'x', { value: 1 })?” Questions about property descriptors appear in intermediate and senior JavaScript interviews because descriptors are the machinery behind Object.freeze, class getters, non-enumerable built-in methods, and much of how frameworks implement reactivity. Candidates who understand them can explain why for...in does not list toString, and why a “read-only” property silently ignored a write.
The trap interviewers set is the defaults: properties created with defineProperty behave very differently from properties created by assignment.
Before you start
You should know object literals, Object.keys, for...in, and what strict mode does to failed assignments (they throw instead of being ignored). Basic familiarity with getters and setters in classes or object literals helps.
The short answer
Every own property has a descriptor. A data descriptor has value and writable; an accessor descriptor has get and set; both kinds have enumerable (shown by Object.keys, for...in, spread and JSON) and configurable (can be deleted or redefined). Assignment creates properties with every flag true, but Object.defineProperty defaults any flag you omit to false. Writes to non-writable properties fail silently in sloppy mode and throw a TypeError in strict mode.
How it works
Use Object.getOwnPropertyDescriptor to inspect one property, or Object.getOwnPropertyDescriptors for all of them.
const user = { name: 'Ada' };
Object.defineProperty(user, 'id', { value: 42 });
console.log(Object.getOwnPropertyDescriptor(user, 'name'));
// { value: 'Ada', writable: true, enumerable: true, configurable: true }
console.log(Object.getOwnPropertyDescriptor(user, 'id'));
// { value: 42, writable: false, enumerable: false, configurable: false }
console.log(Object.keys(user)); // [ 'name' ]
console.log(JSON.stringify(user)); // {"name":"Ada"}
console.log(user.id); // 42The id property exists and can be read, but it is hidden from enumeration and serialization, and it cannot be changed or removed. This is exactly how built-in methods such as Array.prototype.map are defined, which is why they do not appear when you loop over an array with for...in.
Accessor properties replace value and writable with functions. A getter runs on every read; a setter receives the assigned value.
const temperature = { celsius: 20 };
Object.defineProperty(temperature, 'fahrenheit', {
get() { return this.celsius * 9 / 5 + 32; },
set(f) { this.celsius = (f - 32) * 5 / 9; },
enumerable: true,
});
console.log(temperature.fahrenheit); // 68
temperature.fahrenheit = 212;
console.log(temperature.celsius); // 100A descriptor cannot mix the two kinds: passing both value and get throws a TypeError.
Step-by-step walkthrough
Step 1: Make a property read-only
Set writable: false. In strict mode, writes throw, which makes bugs visible.
'use strict';
const config = {};
Object.defineProperty(config, 'apiVersion', { value: 'v2', enumerable: true });
try {
config.apiVersion = 'v3';
} catch (e) {
console.log(e.message); // Cannot assign to read only property 'apiVersion' of object '#<Object>'
}
console.log(config.apiVersion); // v2Step 2: Understand what configurable locks
configurable: false prevents deleting the property, switching between data and accessor, and changing enumerable or configurable. It is permanent. One change is still allowed: a writable data property can be made non-writable (and its value can still be changed while it is writable).
const o = {};
Object.defineProperty(o, 'x', { value: 1, writable: true, configurable: false });
o.x = 2;
Object.defineProperty(o, 'x', { writable: false });
console.log(Object.getOwnPropertyDescriptor(o, 'x'));
// { value: 2, writable: false, enumerable: false, configurable: false }
try {
Object.defineProperty(o, 'x', { writable: true });
} catch (e) {
console.log(e.constructor.name); // TypeError
}
console.log(Reflect.deleteProperty(o, 'x')); // falseReflect.deleteProperty reports failure by returning false. A plain delete o.x also returns false in sloppy mode, but in strict mode (including every ES module) it throws a TypeError.
Step 3: Add validation with a setter
Setters let you enforce invariants on assignment. Store the real value somewhere private, such as a closure or a #private field, so callers cannot bypass the check.
function createAccount() {
let balance = 0;
const account = {};
Object.defineProperty(account, 'balance', {
get: () => balance,
set: (v) => {
if (!Number.isFinite(v) || v < 0) throw new RangeError(`invalid balance: ${v}`);
balance = v;
},
enumerable: true,
});
return account;
}
const acct = createAccount();
acct.balance = 100;
try {
acct.balance = -5;
} catch (e) {
console.log(e.message); // invalid balance: -5
}
console.log(acct.balance); // 100Step 4: Copy descriptors, not just values
Spread and Object.assign read values through getters and create plain writable data properties on the target. Getters are flattened into a snapshot. To clone an object with its accessors and flags intact, copy the descriptors.
const source = {
first: 'Ada',
last: 'Lovelace',
get full() { return `${this.first} ${this.last}`; },
};
const spreadCopy = { ...source };
spreadCopy.first = 'Grace';
console.log(spreadCopy.full); // Ada Lovelace
const trueCopy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(source));
trueCopy.first = 'Grace';
console.log(trueCopy.full); // Grace LovelaceWorked scenario
A library exposed a VERSION constant on its exported object and wanted it read-only. A developer used defineProperty but copied the object with spread before export to “add a few helpers.” The resulting object’s VERSION was writable again, and a plugin overwrote it, breaking compatibility checks.
'use strict';
const core = {};
Object.defineProperty(core, 'VERSION', { value: '3.1.0', enumerable: true });
const exportedBroken = { ...core, helper() {} };
exportedBroken.VERSION = '9.9.9';
console.log(exportedBroken.VERSION); // 9.9.9Spread copied the value into a brand-new property with default assignment flags. The fix defines the protected property on the final exported object and verifies it at load time.
'use strict';
const exported = { helper() {} };
Object.defineProperty(exported, 'VERSION', { value: '3.1.0', enumerable: true });
try {
exported.VERSION = '9.9.9';
} catch (e) {
console.log('blocked'); // blocked
}
console.log(Object.getOwnPropertyDescriptor(exported, 'VERSION').writable); // falseCommon mistake
- “
definePropertycreates a normal property.” Omitted flags default tofalse, so the property is read-only, hidden and permanent unless you say otherwise. - “Non-writable means nobody can change the value.” A non-writable but configurable property can be redefined with a new value through
defineProperty. Onlywritable: falsetogether withconfigurable: falsetruly locks it. - “Writes to read-only properties always throw.” Only in strict mode. In sloppy mode they vanish silently.
- Infinite recursion in a setter.
set name(v) { this.name = v; }calls itself forever. Store the value under a different key or in a private field. - Expecting spread to keep getters. Spread and
Object.assigninvoke getters and copy the results. - Forgetting inherited setters. Assigning to a property that an object inherits as non-writable or as a setter goes through the prototype’s rules, not a new own property.
Verify the behavior
import test from 'node:test';
import assert from 'node:assert/strict';
test('defineProperty defaults flags to false', () => {
const o = Object.defineProperty({}, 'k', { value: 1 });
const d = Object.getOwnPropertyDescriptor(o, 'k');
assert.deepEqual([d.writable, d.enumerable, d.configurable], [false, false, false]);
});
test('strict-mode write to read-only property throws', () => {
const o = Object.defineProperty({}, 'k', { value: 1 });
assert.throws(() => { o.k = 2; }, TypeError);
});
test('descriptor copy preserves getters', () => {
const src = { a: 1, get double() { return this.a * 2; } };
const copy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(src));
copy.a = 5;
assert.equal(copy.double, 10);
});Save as descriptors.test.mjs and run node --test descriptors.test.mjs; ES modules are strict, so the second test sees the TypeError.
Follow-up questions
How do class getters relate to descriptors? A get in a class body creates a non-enumerable accessor on the prototype. Class methods are non-enumerable too, which is why they do not appear in Object.keys(instance).
How are freeze and seal implemented? seal sets every own property to configurable: false and prevents extensions; freeze also sets data properties to writable: false.
What does Object.defineProperties do? It defines several properties at once from an object of descriptors, which pairs with getOwnPropertyDescriptors for faithful cloning.
Did Vue use descriptors for reactivity? Vue 2 converted data properties into getters and setters with defineProperty to track reads and writes. Vue 3 switched to Proxy, which can also observe added and deleted properties.
Interview exercise
Write defineConstant(obj, key, value) so that the property is visible in Object.keys, cannot be reassigned, deleted or redefined, and explain what happens in sloppy mode when someone tries to change it.
Answer and reasoning
function defineConstant(obj, key, value) {
return Object.defineProperty(obj, key, {
value,
enumerable: true,
writable: false,
configurable: false,
});
}
const settings = defineConstant({}, 'MAX_USERS', 50);
console.log(Object.keys(settings)); // [ 'MAX_USERS' ]
try {
Object.defineProperty(settings, 'MAX_USERS', { value: 99 });
} catch (e) {
console.log(e.constructor.name); // TypeError
}
console.log(settings.MAX_USERS); // 50enumerable: true keeps it visible to Object.keys, spread and JSON. writable: false blocks assignment, and configurable: false blocks deletion and redefinition with a different value, which writable: false alone would not prevent. Being explicit about all four flags documents intent even though false is the default. In sloppy mode, settings.MAX_USERS = 99 is silently ignored and delete settings.MAX_USERS returns false; in strict mode both throw. A defineProperty call with a different value throws in both modes. If the value is an object, mention that its contents stay mutable unless frozen.
Continue learning
Explore more JavaScript interview questions and try the JavaScript MCQs. Related notes: Object.freeze and its limits, property enumeration order and Proxy validation traps. Official reference: Object.defineProperty on MDN.