Most JavaScript properties are created with assignment or object literals. That is usually the right choice, but it hides several controls that every property carries: whether the property can be assigned, whether common enumeration APIs expose it, and whether its definition can later be changed.
Property descriptors make those controls explicit. They are useful for library APIs, metadata, computed properties, compatibility layers, and cases where ordinary assignment exposes more behavior than intended.
Understand the two descriptor types
JavaScript has two kinds of property descriptors.
A data descriptor stores a value and can specify writable:
const settings = {};
Object.defineProperty(settings, "environment", {
value: "production",
writable: false,
enumerable: true,
configurable: false,
});An accessor descriptor defines behavior with get and optionally set:
const rectangle = {
width: 8,
height: 5,
};
Object.defineProperty(rectangle, "area", {
get() {
return this.width * this.height;
},
enumerable: true,
configurable: true,
});
console.log(rectangle.area); // 40
A descriptor cannot be both types at once. Combining value or writable with get or set causes Object.defineProperty() to throw a TypeError.
Know the surprising defaults
Properties created by ordinary assignment are writable, enumerable, and configurable by default:
const object = {};
object.status = "ready";
console.log(Object.getOwnPropertyDescriptor(object, "status"));The descriptor is effectively:
{
value: "ready",
writable: true,
enumerable: true,
configurable: true
}Object.defineProperty() uses different defaults when it creates a property. Omitted writable, enumerable, and configurable fields default to false.
Therefore this definition creates a much more restricted property than assignment:
Object.defineProperty(object, "version", {
value: 1,
});For maintainability, specify descriptor flags explicitly when their behavior matters. Readers should not have to remember implicit defaults to understand an API.
Use writable to control assignment
For a data property, writable: false prevents assignment from replacing its value.
"use strict";
const config = {};
Object.defineProperty(config, "schemaVersion", {
value: 3,
writable: false,
enumerable: true,
configurable: false,
});
config.schemaVersion = 4; // TypeError in strict mode
Outside strict mode, an assignment to a non-writable property can fail without throwing. Code should not rely on that difference as a validation mechanism.
Also, non-writable does not mean deeply immutable. If the stored value is an object, its contents can still change unless that object is separately constrained:
const state = {};
Object.defineProperty(state, "options", {
value: { retries: 2 },
writable: false,
});
state.options.retries = 3; // Allowed
The descriptor controls assignment to state.options; it does not freeze the referenced object.
Use enumerable to shape object surfaces
enumerable affects whether a property participates in common enumeration and copying operations.
A non-enumerable own string property is omitted by Object.keys(), object spread, and Object.assign() as a source property:
const user = { name: "Ada" };
Object.defineProperty(user, "cacheKey", {
value: "user:42",
writable: false,
enumerable: false,
configurable: false,
});
console.log(Object.keys(user)); // ["name"]
console.log({ ...user }); // { name: "Ada" }
console.log(user.cacheKey); // "user:42"
Non-enumerable does not make a property private or secret. The property remains directly accessible, and reflection APIs such as Object.getOwnPropertyNames() can discover non-enumerable string keys.
Use non-enumerability to keep implementation metadata out of ordinary iteration, not as a security boundary.
Treat configurable as a one-way design decision
configurable: false makes a property difficult or impossible to redefine. In general, a non-configurable property cannot be deleted, switched between data and accessor forms, or have attributes such as enumerable changed.
There is a narrow exception for writable data properties: while writable is still true, the value can change, and writable can be changed from true to false. The reverse transition is not allowed once the property is non-configurable.
This means configurable: false should be used deliberately. It is appropriate for invariants that must not be redefined, but it can make testing, instrumentation, subclassing patterns, and future API evolution harder.
For library code, configurable: true is often the more flexible default unless preventing redefinition is part of the contract.
Build computed properties with accessors
Accessors are useful when a property-shaped API is clearer than a method call.
const account = {
firstName: "Ada",
lastName: "Lovelace",
};
Object.defineProperty(account, "displayName", {
get() {
return `${this.firstName} ${this.lastName}`;
},
enumerable: true,
configurable: true,
});
console.log(account.displayName); // "Ada Lovelace"
A getter runs whenever the property is read. Avoid hiding expensive I/O, surprising mutations, or other heavyweight work behind a getter because callers generally expect property access to be inexpensive and predictable.
Validate assignments with a setter
An accessor can also define a setter:
const profile = {};
let age = 0;
Object.defineProperty(profile, "age", {
get() {
return age;
},
set(value) {
if (!Number.isInteger(value) || value < 0) {
throw new TypeError("age must be a non-negative integer");
}
age = value;
},
enumerable: true,
configurable: true,
});
profile.age = 36;
console.log(profile.age); // 36
This can enforce a local invariant, but setters should remain small and unsurprising. Complex domain operations are usually clearer as named methods.
Inspect descriptors instead of guessing
Use Object.getOwnPropertyDescriptor() when code needs to understand an own property’s exact behavior:
const descriptor = Object.getOwnPropertyDescriptor(user, "cacheKey");
console.log(descriptor.enumerable); // false
console.log(descriptor.writable); // false
For all own properties at once, Object.getOwnPropertyDescriptors() returns a descriptor map. This is useful when copying objects while preserving getters, setters, and descriptor flags rather than copying only current values.
For example:
const clone = Object.defineProperties(
{},
Object.getOwnPropertyDescriptors(account),
);This is not the same as a deep clone. Referenced objects remain shared, and prototype behavior is separate from own property descriptors.
Understand copying trade-offs
Object spread and Object.assign() are intentionally value-oriented. They copy enumerable own properties from their sources, and the resulting target properties normally do not preserve the source descriptors as-is.
That matters for accessor properties. Reading an enumerable getter during a spread evaluates the getter and copies its returned value rather than recreating the getter definition on the new object.
If descriptor behavior is part of the object’s contract, copy descriptors explicitly. If only current values matter, spread syntax is usually simpler and clearer.
Common pitfalls
Assuming non-enumerable means hidden
It only changes enumeration behavior. Reflection and direct access can still expose the property.
Omitting flags accidentally
With Object.defineProperty(), omitted descriptor flags default to restrictive values. State important flags explicitly.
Making properties non-configurable too early
A non-configurable definition can prevent later instrumentation or API changes. Use it only for a real invariant.
Expecting writable false to freeze nested data
writable controls replacement of the property value, not mutation inside an object stored there.
Mixing descriptor types
A descriptor cannot contain data fields such as value together with accessor fields such as get.
Hiding expensive work in getters
Property syntax makes work look cheap. Prefer a method when an operation performs substantial computation, I/O, or surprising side effects.
Choose descriptors for behavior, not ceremony
Most application properties should still use ordinary assignment and object literals. Property descriptors are most valuable when the details of property behavior are themselves part of the design.
Use them to expose computed values, keep metadata out of routine enumeration, preserve precise object contracts, or prevent assignments that should never succeed. Keep the flags explicit, remember that non-enumerability is not privacy, and be cautious with non-configurable properties because their restrictions are intentionally difficult to reverse.