Object.create & Descriptors
Most objects in JavaScript inherit from Object.prototype, but sometimes you need precise control over an object’s prototype and the exact behavior of its properties. Object.create lets you build an object with a prototype you choose, and property descriptors let you control whether each property can be changed, listed, or deleted. Together they give you a low-level, declarative way to shape objects that constructors and object literals can’t fully express.
Creating objects with a chosen prototype
Object.create(proto) returns a brand-new object whose internal [[Prototype]] is the object you pass in. This is the cleanest way to set up inheritance without invoking a constructor.
const animal = {
describe() {
return `${this.name} is a ${this.type}`;
},
};
const dog = Object.create(animal);
dog.name = "Rex";
dog.type = "dog";
console.log(dog.describe());
console.log(Object.getPrototypeOf(dog) === animal);
Output:
Rex is a dog
true
You can also create an object with no prototype at all by passing null. The result has no inherited methods like hasOwnProperty or toString, which makes it a safe, collision-free dictionary (often called a “bare” or “dict-mode” object).
const lookup = Object.create(null);
lookup.constructor = "this is just data, not Object's constructor";
console.log("constructor" in lookup);
console.log(Object.getPrototypeOf(lookup));
Output:
true
null
Use
Object.create(null)for maps keyed by untrusted strings — it eliminates prototype-pollution surprises where keys like__proto__ortoStringwould otherwise collide with inherited members. For general key/value storage, a realMapis usually the better choice.
Property descriptors
Every property on an object is backed by a descriptor — an object that defines not just the value, but how the property behaves. There are two kinds: data descriptors and accessor descriptors.
A data descriptor has these attributes:
| Attribute | Default | Meaning |
|---|---|---|
value | undefined | The value stored in the property. |
writable | false | Whether the value can be reassigned with =. |
enumerable | false | Whether the property shows up in for...in, Object.keys, and spread. |
configurable | false | Whether the property can be deleted or its descriptor changed. |
An accessor descriptor replaces value/writable with get and set functions, and still supports enumerable and configurable.
Defaults differ by API. Properties created with assignment or object literals default to
writable: true, enumerable: true, configurable: true. ButObject.definePropertyand the descriptor map ofObject.createdefault every flag tofalse.
Object.defineProperty
Object.defineProperty(obj, key, descriptor) adds or modifies a single property with full control. This is how you create read-only fields, hidden internal state, or computed accessors.
const account = {};
Object.defineProperty(account, "id", {
value: "ACC-9001",
writable: false,
enumerable: true,
configurable: false,
});
Object.defineProperty(account, "balance", {
value: 0,
writable: true,
enumerable: false, // hidden from Object.keys / JSON
});
account.id = "HACKED"; // silently ignored (throws in strict mode)
account.balance = 500;
console.log(account.id);
console.log(Object.keys(account));
console.log(account.balance);
Output:
ACC-9001
[ 'id' ]
500
For accessor properties, supply get and set instead of value:
const temperature = { _celsius: 20 };
Object.defineProperty(temperature, "fahrenheit", {
get() {
return this._celsius * 1.8 + 32;
},
set(f) {
this._celsius = (f - 32) / 1.8;
},
enumerable: true,
});
console.log(temperature.fahrenheit);
temperature.fahrenheit = 212;
console.log(temperature._celsius);
Output:
68
100
Use Object.defineProperties(obj, descriptors) to define several at once.
Descriptors in Object.create
The second argument to Object.create is a descriptor map — the same shape Object.defineProperties accepts — letting you set both the prototype and the property behavior in one call.
const proto = {
greet() {
return `Hi, I'm ${this.name}`;
},
};
const user = Object.create(proto, {
name: { value: "Ada", enumerable: true, writable: true },
role: { value: "admin", enumerable: false }, // hidden, read-only
});
console.log(user.greet());
console.log(Object.keys(user));
console.log(user.role);
Output:
Hi, I'm Ada
[ 'name' ]
admin
Inspecting descriptors
To read how a property is configured, use Object.getOwnPropertyDescriptor(obj, key), or Object.getOwnPropertyDescriptors(obj) for every own property at once. These are essential for debugging and for faithfully cloning objects (since spread copies values but not descriptor flags).
const config = {};
Object.defineProperty(config, "version", { value: "2.0", enumerable: true });
console.log(Object.getOwnPropertyDescriptor(config, "version"));
// Faithful clone that preserves writable/enumerable/configurable flags:
const clone = Object.create(
Object.getPrototypeOf(config),
Object.getOwnPropertyDescriptors(config)
);
console.log(clone.version);
Output:
{ value: '2.0', writable: false, enumerable: false, configurable: false }
const config = {};
2.0
The cloning idiom above is the recommended way to copy an object completely, because the spread operator ({ ...config }) only copies enumerable own properties and always resets their flags to true.
Best Practices
- Reach for
Object.create(proto)when you want clean prototypal inheritance without the ceremony of a constructor. - Use
Object.create(null)for prototype-free dictionaries that won’t clash with inherited keys. - Prefer
Object.definePropertyto expose read-only or non-enumerable fields rather than relying on naming conventions alone. - Remember that
definePropertyandObject.create’s descriptor map default every flag tofalse— setenumerable/writable/configurableexplicitly when you need them. - Clone objects with
Object.create(proto, getOwnPropertyDescriptors(src))when descriptor flags and accessors must be preserved. - Mark properties
configurable: falseonly when you truly want them locked — it can’t be undone later. - Run in strict mode so writes to read-only properties throw loudly instead of failing silently.