Property attributes and immutability
Read and set the attributes behind every property with property descriptors, and compare preventExtensions, seal and freeze for protecting objects.
What you will learn
- Read a property's descriptor and say what writable, enumerable and configurable control
- Create read-only and hidden properties with Object.defineProperty
- Define accessor properties with get and set in a descriptor
- Compare preventExtensions, seal and freeze, and write a deep freeze
On this page
So far a property has been a name and a value. Behind each one the engine keeps a few more settings: may it be assigned to, does it show up in loops, can it be removed. Normally you never see them, because a plain { a: 1 } sets all of them to the permissive default. This lesson shows where they are, how to change them, and how they explain behaviour you have already seen: why class methods do not appear in Object.keys, why an assignment to a frozen object fails, and why Object.freeze from lesson 28 is shallow.
Descriptors
The settings of a property are collected in a property descriptor, an object you can read with Object.getOwnPropertyDescriptor(object, name).
const user = { name: "Ada", get label() { return `user ${this.name}`; },};
console.log(Object.getOwnPropertyDescriptor(user, "name"));console.log(Object.getOwnPropertyDescriptor(user, "label"));console.log(Object.getOwnPropertyDescriptor([1, 2], "length"));{ value: 'Ada', writable: true, enumerable: true, configurable: true }{ get: [Function: get label], set: undefined, enumerable: true, configurable: true}{ value: 2, writable: true, enumerable: false, configurable: false }A data property has a value and three flags:
writable: whether assignment can change the value.enumerable: whether the property shows up inObject.keys,for...in, spread andJSON.stringify.configurable: whether the property can be deleted or have its attributes changed.
The getter in the literal is an accessor property. It has get and set functions instead of value and writable, plus the same enumerable and configurable flags. The array’s length is a data property that is writable but not configurable, so it cannot be deleted.
The same view explains class methods:
class Point { norm() {}}console.log(Object.getOwnPropertyDescriptor(Point.prototype, "norm"));{ value: [Function: norm], writable: true, enumerable: false, configurable: true}Methods are writable and configurable but not enumerable, which is why methods do not show up when you list an instance’s keys or loop over its properties.
Creating properties with defineProperty
Object.defineProperty(object, name, descriptor) creates a property, or changes an existing one, with the attributes you give. This is the opposite of object literals: every flag you leave out is false.
const product = { name: "lamp" };
Object.defineProperty(product, "sku", { value: "L-100" });console.log(Object.getOwnPropertyDescriptor(product, "sku"));{ value: 'L-100', writable: false, enumerable: false, configurable: false}Object.defineProperty(product, "price", { value: 20, writable: true, enumerable: true, configurable: true,});console.log(Object.keys(product), product.sku);[ 'name', 'price' ] L-100sku gets the value and nothing else, so it cannot be changed, listed or deleted. price spells out the permissive flags and behaves like an ordinary property. Object.defineProperties(object, { name: descriptor, ... }) defines several at once, and Object.getOwnPropertyDescriptors(object) returns all descriptors.
writable and enumerable
A non-writable property keeps its value. In a module, which is strict, an attempt to assign throws a TypeError. A non-enumerable property is hidden from enumeration but is otherwise normal.
const config = { name: "app" };Object.defineProperty(config, "version", { value: "1.0", writable: false, enumerable: true,});Object.defineProperty(config, "token", { value: "abc123", enumerable: false,});
try { config.version = "2.0";} catch (error) { console.log(error.name, config.version);}
console.log(Object.keys(config), JSON.stringify(config));console.log({ ...config }, config.token, "token" in config);console.log(Object.getOwnPropertyNames(config));TypeError 1.0[ 'name', 'version' ] {"name":"app","version":"1.0"}{ name: 'app', version: '1.0' } abc123 true[ 'name', 'version', 'token' ]- The assignment to
versionthrows, and the value stays"1.0". Object.keys,JSON.stringifyand spread ({ ...config }) leave outtoken, so it is a way to keep a value out of serialised output. This is not security:config.tokenstill works,instill finds it, andObject.getOwnPropertyNameslists it. It only removes the property from the places that enumerate.
configurable
When configurable is false, the property is locked in place. You cannot delete it and you cannot redefine it in most ways.
const item = { count: 1 };Object.defineProperty(item, "id", { value: 7, writable: true, configurable: false,});
try { delete item.id;} catch (error) { console.log("delete:", error.name);}delete: TypeErrortry { Object.defineProperty(item, "id", { enumerable: true });} catch (error) { console.log("redefine:", error.name);}
item.id = 8;Object.defineProperty(item, "id", { writable: false });console.log(item.id, Object.getOwnPropertyDescriptor(item, "id").writable);redefine: TypeError8 falseBoth attempts throw. Changing the value is still allowed while writable is true, so item.id = 8 works. One more change is allowed: turning writable from true to false. That step is one-way, and the descriptor ends up read-only, as the last line shows.
Accessors in a descriptor
An accessor property is defined with get and set in the descriptor. This is how you add a computed property to an object that already exists, including one you did not write.
const temperature = { celsius: 20 };
Object.defineProperty(temperature, "fahrenheit", { get() { return (this.celsius * 9) / 5 + 32; }, set(value) { this.celsius = ((value - 32) * 5) / 9; }, enumerable: true, configurable: true,});
console.log(temperature.fahrenheit);temperature.fahrenheit = 212;console.log(temperature.celsius, Object.keys(temperature));68100 [ 'celsius', 'fahrenheit' ]A descriptor cannot mix value or writable with get or set. Here enumerable: true makes the property show up in Object.keys, like a getter written in an object literal. Leave it out and the property is hidden.
preventExtensions, seal and freeze
Three functions apply a whole-object lock, from lightest to heaviest:
Object.preventExtensions(object)stops new properties from being added.Object.seal(object)also marks every existing property non-configurable, so nothing can be added or deleted.Object.freeze(object)also marks every data property non-writable, so values cannot change either.
A helper that tries all three operations shows the differences:
function probe(label, object) { const results = []; for (const [action, fn] of [ ["add", () => (object.extra = 1)], ["change", () => (object.value = 2)], ["delete", () => delete object.value], ]) { try { fn(); results.push(`${action}:ok`); } catch (error) { results.push(`${action}:${error.name}`); } } console.log(label.padEnd(10), results.join(" "));}Run it on an ordinary object and on one at each level:
probe("normal", { value: 1 });probe("noExtend", Object.preventExtensions({ value: 1 }));probe("sealed", Object.seal({ value: 1 }));probe("frozen", Object.freeze({ value: 1 }));normal add:ok change:ok delete:oknoExtend add:TypeError change:ok delete:oksealed add:TypeError change:ok delete:TypeErrorfrozen add:TypeError change:TypeError delete:TypeErrorEach row loses one more ability. In a module the failures are TypeErrors. In older sloppy scripts they were silently ignored, which hid many bugs. Object.isExtensible, Object.isSealed and Object.isFrozen ask which level an object is at.
const sealed = Object.seal({ a: 1 });const frozen = Object.freeze({ a: 1 });
console.log(Object.isExtensible(sealed), Object.isSealed(sealed));console.log(Object.isFrozen(sealed), Object.isFrozen(frozen));false truefalse trueA sealed object is not frozen because its values can still change. All three functions are shallow, as lesson 28 showed: they lock the object you give, not the objects it points to. A frozen array whose element is an object still lets you change that object. The deep freeze exercise below fixes that.
Freezing also has a cost for design: you cannot “unfreeze” an object, so freeze values that are truly constants, such as configuration, and copy before changing. For data that changes, build a new object with spread, as lesson 27 showed.
Common pitfalls
- Forgetting that defineProperty defaults to
false. A property defined with onlyvalueis read-only, hidden and permanent. List the flags you want. - Expecting a non-enumerable property to be private. It still can be read, assigned (if writable) and found with
in. For real privacy use the#names of lesson 4. - Assuming
freezeis deep. Nested objects and arrays stay mutable. Freeze them too, or copy before sharing. - Mixing
getwithvaluein a descriptor. It throws aTypeError. - Redefining a non-configurable property. The engine throws. Decide the flags when you first define it.
- Relying on silent failure. Sloppy code ignores writes to read-only properties. Modules throw, so a script that worked in a
.jsfile may start failing as an ES module.
Exercises
Create an empty object settings and, with Object.defineProperties, give it two visible read-only properties, host set to "localhost" and port set to 8080, and a hidden read-only property secret set to "s3cr3t". Try to assign settings.port = 9090 inside try/catch, printing the error name. Then print the port together with Object.keys(settings), and finally the result of JSON.stringify(settings) together with settings.secret. The expected output is TypeError, 8080 [ 'host', 'port' ] and {"host":"localhost","port":8080} s3cr3t.
Show solution
Leaving out writable makes a property read-only, and enumerable: true is what makes host and port visible to Object.keys and JSON.stringify. secret leaves both flags out, so it is read-only and hidden, but still readable by name.
const settings = {};
Object.defineProperties(settings, { host: { value: "localhost", enumerable: true }, port: { value: 8080, enumerable: true }, secret: { value: "s3cr3t" },});
try { settings.port = 9090;} catch (error) { console.log(error.name);}
console.log(settings.port, Object.keys(settings));console.log(JSON.stringify(settings), settings.secret);TypeError8080 [ 'host', 'port' ]{"host":"localhost","port":8080} s3cr3tWrite deepFreeze(value) that freezes an object and everything reachable through its properties, and returns the value. Skip values that are not objects, and take care that null is not an object for this purpose. Freeze { db: { host: "localhost", ports: [1, 2] } } with it, then try three changes in a loop, each inside try/catch: setting config.db.host, calling config.db.ports.push(3) and deleting config.db. Print the error name for each, or changed if it works, and finally print the config. The expected output is three lines of TypeError, followed by { db: { host: 'localhost', ports: [ 1, 2 ] } }.
Show solution
The function freezes the children first with a recursive call and then the object itself. The typeof value === "object" && value !== null check stops at numbers, strings and null. Arrays are objects, so push on the frozen array throws too.
function deepFreeze(value) { if (typeof value === "object" && value !== null) { for (const child of Object.values(value)) { deepFreeze(child); } Object.freeze(value); } return value;}
const config = deepFreeze({ db: { host: "localhost", ports: [1, 2] } });Try three changes on the frozen configuration:
for (const change of [ () => (config.db.host = "remote"), () => config.db.ports.push(3), () => delete config.db,]) { try { change(); console.log("changed"); } catch (error) { console.log(error.name); }}console.log(config);TypeErrorTypeErrorTypeError{ db: { host: 'localhost', ports: [ 1, 2 ] } }Given this object:
const source = { first: "Ada", last: "Lovelace", get full() { return `${this.first} ${this.last}`; },};Make assigned with Object.assign({}, source) and cloned with Object.defineProperties({}, Object.getOwnPropertyDescriptors(source)). Change first to "Grace" on both copies, and print the full of each on one line, separated by " | ". Then print the value of the descriptor of assigned.full, and the typeof the get of the descriptor of cloned.full. The expected output is Ada Lovelace | Grace Lovelace, then Ada Lovelace and function.
Show solution
Object.assign reads each property with an ordinary get, so it runs the getter once and stores the resulting string as plain data. The copy is a snapshot, and changing first does not change full. Copying the descriptors keeps the accessor itself, so cloned still computes full from its own first.
const source = { first: "Ada", last: "Lovelace", get full() { return `${this.first} ${this.last}`; },};
const assigned = Object.assign({}, source);const cloned = Object.defineProperties( {}, Object.getOwnPropertyDescriptors(source),);
assigned.first = "Grace";cloned.first = "Grace";console.log(assigned.full, "|", cloned.full);console.log(Object.getOwnPropertyDescriptor(assigned, "full").value);console.log(typeof Object.getOwnPropertyDescriptor(cloned, "full").get);Ada Lovelace | Grace LovelaceAda LovelacefunctionSummary
- Every property has attributes. A data property has
value,writable,enumerableandconfigurable. An accessor hasgetandsetinstead of the first two. - Properties from literals and assignments have all flags
true.Object.definePropertydefaults every missing flag tofalse. - Non-writable properties reject assignment with a
TypeErrorin modules. Non-enumerable ones are hidden fromObject.keys,for...in, spread andJSON.stringify, but not private. - Non-configurable properties cannot be deleted or redefined, apart from the one-way step of making them non-writable.
preventExtensions,sealandfreezelock a whole object at increasing strength, and all are shallow. Write a recursive freeze for nested data.Object.assigncopies values. Copy descriptors to keep accessors.
Next: symbols, unique keys that let objects take part in the language’s own protocols.