Property flags and descriptors in JavaScript
The Short Answer
Every object property in JavaScript has hidden metadata called property flags (or descriptors) that control how the property behaves. There are three flags: writable (can the value be changed?), enumerable (does it show up in for...in loops?), and configurable (can the property be deleted or its flags changed?). You read them with Object.getOwnPropertyDescriptor() and set them with Object.defineProperty(). Understanding descriptors explains why some properties are read-only, why some don't appear in Object.keys(), and how freeze/seal work under the hood.
The Three Data Descriptor Flags
When you create a property normally (like obj.name = 'Alice'), all three flags default to true — the property is fully open. But you can lock down individual behaviors using Object.defineProperty(). This is how the language implements read-only properties, hidden properties, and permanent properties.
| Flag | Controls | Default (normal assignment) | Default (defineProperty) |
|---|---|---|---|
| writable | Can the value be reassigned? | true | false |
| enumerable | Shows in for...in, Object.keys()? | true | false |
| configurable | Can delete or change flags? | true | false |
Important default difference
Properties created with obj.x = 1 have all flags set to true. Properties created with Object.defineProperty() have all flags defaulting to false. This catches people off guard — a property defined with defineProperty is non-writable, non-enumerable, and non-configurable unless you explicitly say otherwise.
Reading Property Descriptors
Object.getOwnPropertyDescriptor() returns the full descriptor for a single property, including its value and all three flags. Object.getOwnPropertyDescriptors() (plural) returns descriptors for all own properties at once. These are essential for understanding why a property behaves a certain way and for copying properties with their full metadata intact.
const user = { name: 'Alice', age: 30 };
// Get descriptor for a single property
const nameDesc = Object.getOwnPropertyDescriptor(user, 'name');
console.log(nameDesc);
// {
// value: 'Alice',
// writable: true,
// enumerable: true,
// configurable: true
// }
// Get all descriptors at once
const allDescs = Object.getOwnPropertyDescriptors(user);
console.log(allDescs);
// {
// name: { value: 'Alice', writable: true, enumerable: true, configurable: true },
// age: { value: 30, writable: true, enumerable: true, configurable: true }
// }
// Built-in properties often have different flags
const arrLengthDesc = Object.getOwnPropertyDescriptor([1, 2, 3], 'length');
console.log(arrLengthDesc);
// { value: 3, writable: true, enumerable: false, configurable: false }
// Array.length is non-enumerable (doesn't show in for...in)
// and non-configurable (can't delete it)
Defining Properties with Flags
Object.defineProperty() lets you create or modify a property with explicit control over each flag. This is how you create read-only constants, hidden internal properties, and properties that can't be deleted. Remember that any flag you don't specify defaults to false when creating a new property this way.
'use strict';
const obj: Record<string, unknown> = {};
// Non-writable — read-only property
Object.defineProperty(obj, 'id', {
value: 'abc-123',
writable: false,
enumerable: true,
configurable: false,
});
obj.id = 'new-id'; // ❌ TypeError: Cannot assign to read-only property 'id'
// Non-enumerable — hidden from iteration
Object.defineProperty(obj, '_internal', {
value: 'secret',
writable: true,
enumerable: false, // Won't show in Object.keys() or for...in
configurable: true,
});
console.log(Object.keys(obj)); // ['id'] — _internal is hidden
console.log(obj._internal); // 'secret' — still accessible directly
// Non-configurable — can't delete or change flags
Object.defineProperty(obj, 'type', {
value: 'user',
writable: false,
enumerable: true,
configurable: false, // Permanent and locked
});
delete obj.type; // ❌ TypeError: Cannot delete property 'type'
Object.defineProperty(obj, 'type', { writable: true }); // ❌ TypeError: Cannot redefine
// Define multiple properties at once
Object.defineProperties(obj, {
firstName: { value: 'Alice', writable: true, enumerable: true, configurable: true },
lastName: { value: 'Smith', writable: true, enumerable: true, configurable: true },
});
Accessor Descriptors (Getters/Setters)
Properties can also be defined as accessors — instead of storing a value directly, they use get and set functions. Accessor descriptors have get, set, enumerable, and configurable — but NOT value or writable (you can't mix data and accessor descriptors). This is how computed properties, validation on assignment, and lazy initialization work at the language level.
const person: Record<string, unknown> = {
firstName: 'Alice',
lastName: 'Smith',
};
// Define a computed property with get/set
Object.defineProperty(person, 'fullName', {
get() {
return `${this.firstName} ${this.lastName}`;
},
set(value: string) {
const [first, last] = value.split(' ');
this.firstName = first;
this.lastName = last;
},
enumerable: true,
configurable: true,
});
console.log(person.fullName); // 'Alice Smith' (getter called)
person.fullName = 'Bob Jones'; // setter called
console.log(person.firstName); // 'Bob'
console.log(person.lastName); // 'Jones'
// Validation with setter
const account: Record<string, unknown> = { _balance: 0 };
Object.defineProperty(account, 'balance', {
get() { return this._balance; },
set(value: number) {
if (value < 0) throw new Error('Balance cannot be negative');
this._balance = value;
},
enumerable: true,
configurable: true,
});
account.balance = 100; // ✅ Works
account.balance = -50; // ❌ Error: Balance cannot be negative
// Descriptor type check
const desc = Object.getOwnPropertyDescriptor(person, 'fullName');
console.log('get' in desc); // true — it's an accessor descriptor
console.log('value' in desc); // false — no value property
How freeze/seal Use Descriptors
Now you can understand what Object.freeze() and Object.seal() actually do under the hood — they just modify property descriptors in bulk. seal makes every property non-configurable and prevents extensions. freeze does everything seal does plus makes every property non-writable. There's no magic — it's all property flags.
// What Object.seal() does internally:
function mySeal(obj: object) {
Object.preventExtensions(obj); // No new properties
Object.getOwnPropertyNames(obj).forEach((prop) => {
Object.defineProperty(obj, prop, { configurable: false });
});
return obj;
}
// What Object.freeze() does internally:
function myFreeze(obj: object) {
Object.preventExtensions(obj); // No new properties
Object.getOwnPropertyNames(obj).forEach((prop) => {
const desc = Object.getOwnPropertyDescriptor(obj, prop)!;
if ('value' in desc) {
// Data descriptor — make non-writable and non-configurable
Object.defineProperty(obj, prop, { writable: false, configurable: false });
} else {
// Accessor descriptor — just make non-configurable
Object.defineProperty(obj, prop, { configurable: false });
}
});
return obj;
}
// Verify:
const obj = { x: 1 };
Object.freeze(obj);
const desc = Object.getOwnPropertyDescriptor(obj, 'x');
console.log(desc);
// { value: 1, writable: false, enumerable: true, configurable: false }
Practical Use Cases
Property descriptors aren't just academic — they power real patterns in libraries and frameworks. Non-enumerable properties keep internal state hidden from serialization. Non-writable properties create true constants. Getters enable lazy computation and reactive systems. Here are patterns you'll encounter in production code.
// 1. Non-enumerable metadata (won't appear in JSON.stringify)
function addMetadata(obj: object, key: string, value: unknown) {
Object.defineProperty(obj, key, {
value,
enumerable: false, // Hidden from serialization
writable: true,
configurable: true,
});
}
const record = { name: 'Alice', age: 30 };
addMetadata(record, '__version', 2);
addMetadata(record, '__lastModified', Date.now());
console.log(JSON.stringify(record)); // {"name":"Alice","age":30} — metadata hidden
// 2. Lazy initialization with getter
Object.defineProperty(globalThis, 'heavyModule', {
get() {
const module = expensiveInit(); // Only runs on first access
// Replace getter with the value (one-time computation)
Object.defineProperty(this, 'heavyModule', { value: module });
return module;
},
configurable: true,
enumerable: true,
});
// 3. Copying objects with full descriptor fidelity
// Object.assign() loses non-enumerable props and accessor definitions
// Use this instead:
function cloneWithDescriptors<T extends object>(source: T): T {
return Object.create(
Object.getPrototypeOf(source),
Object.getOwnPropertyDescriptors(source)
);
}
Why Interviewers Ask This
Property descriptors are an advanced topic that separates developers who understand JavaScript's object model deeply from those who only use the surface API. Interviewers ask this to test whether you know the three flags and their defaults, can explain the difference between data and accessor descriptors, understand how freeze/seal work internally (they're just descriptor manipulation), know practical applications (hidden metadata, lazy init, validation), and can use defineProperty correctly. It's a hard question because most developers never interact with descriptors directly — but they underpin everything from framework reactivity to built-in object behavior.
Quick Revision Cheat Sheet
writable: Can value be changed? (false = read-only)
enumerable: Shows in for...in / Object.keys()? (false = hidden)
configurable: Can delete or change flags? (false = permanent)
Normal assignment: All flags default to true
defineProperty: All flags default to false — explicit is required
Accessor: get/set instead of value/writable — can't mix both