A property looks like a name and a value. Underneath, each one also has flags that decide whether it can be changed, whether it shows up when you list an object's keys, and whether it can be deleted or redefined. Those flags are the property's descriptor:
Object.getOwnPropertyDescriptor({ port: 5432 }, 'port')
// { value: 5432, writable: true, enumerable: true, configurable: true }
Most code never touches them. But they explain how built-ins behave, how
Object.freeze works, and why some assignments fail silently.
Every property has a descriptor: its value plus three flags. Properties created by assignment get true for all three. With defineProperty, any flag you leave out defaults to false.
The three flags
- writable — can the value be changed by assignment?
- enumerable — does the property appear in
Object.keys,for…in, spread, andJSON.stringify? - configurable — can the property be deleted, or its flags changed? Once false, it's permanent: you can't set it back to true.
Properties created by assignment or an object literal get true for all
three. Properties created with Object.defineProperty default to false
for any flag you don't specify — a common surprise:
const config = {}
Object.defineProperty(config, 'apiUrl', { value: 'https://api.example.com' })
Object.getOwnPropertyDescriptor(config, 'apiUrl')
// { value: 'https://api.example.com', writable: false, enumerable: false, configurable: false }
Accessor properties
Instead of value and writable, a property can have get and set
functions — an accessor. Reading runs the getter; assigning runs the
setter:
const thermostat = {
_celsius: 21,
get celsius() { return this._celsius },
set celsius(v) {
if (typeof v !== 'number') throw new TypeError('celsius must be a number')
this._celsius = v
},
}
thermostat.celsius = 'warm' // TypeError: celsius must be a number
Class get and set members create accessors on the prototype. Frameworks
have used them for reactivity: Vue 2 rewrote each data property as an
accessor so that reads and writes could be tracked. (Vue 3 switched to
Proxies, which can also see new
properties.)
Strict mode turns silent failures into errors
Assigning to a non-writable property fails silently in sloppy-mode scripts. In strict mode — every ES module and class body is strict — it throws:
TypeError: Cannot assign to read only property 'apiUrl' of object '#<Object>'
The same applies to adding properties to a non-extensible object and deleting non-configurable ones. If you've ever seen an assignment "not work" with no error, a descriptor in sloppy mode is a likely cause.
Locking whole objects
Three built-ins apply restrictions to every property at once:
| add properties | delete properties | change values | |
|---|---|---|---|
| Object.preventExtensions | no | yes | yes |
| Object.seal | no | no | yes |
| Object.freeze | no | no | no |
Object.freeze makes every data property non-writable and
non-configurable, and the object non-extensible. Object.isFrozen,
isSealed and isExtensible check the state.
All three are shallow. A frozen object's nested objects are untouched:
const settings = Object.freeze({ port: 5432, db: { host: 'a' } })
settings.db.host = 'b' // works — settings.db isn't frozen
A deep freeze has to recurse:
function deepFreeze(obj) {
for (const value of Object.values(obj)) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) deepFreeze(value)
}
return Object.freeze(obj)
}
Freeze or readonly?
TypeScript's readonly modifier, Readonly<T> and as const catch writes
at compile time, at zero runtime cost:
const settings = { port: 5432, db: { host: 'a' } } as const
settings.db.host = 'b' // error: Cannot assign to 'host' because it is a read-only property
They disappear after compilation, though, so they don't protect against plain JavaScript callers or data from outside. A reasonable split:
- Use
readonlyandas constthroughout your own TypeScript code. - Use
Object.freezeat real boundaries: constants exported from a library, configuration objects shared across modules, and values you hand to untrusted code. - For state that changes over time, prefer creating new objects to mutating old ones — see immutability and structural sharing.
Freezing also makes intent clear in tests: if some code is quietly mutating
a shared fixture, freezing the fixture turns that bug into an immediate
TypeError in strict mode.