docs/beyond/concepts/property-descriptors.mdx
Why can you delete most object properties but not Math.PI? Why do some properties show up in for...in loops while others don't? And how do you create a property that can never be changed?
// You can't modify Math.PI
Math.PI = 3 // Silently fails (or throws in strict mode)
console.log(Math.PI) // 3.141592653589793 - unchanged!
// You can't delete it either
delete Math.PI // false
console.log(Math.PI) // 3.141592653589793 - still there!
The answer is property descriptors. Every property in JavaScript has hidden attributes that control how it behaves. Understanding these unlocks powerful patterns for creating robust, secure objects.
// Check Math.PI's hidden attributes
const descriptor = Object.getOwnPropertyDescriptor(Math, 'PI')
console.log(descriptor)
// {
// value: 3.141592653589793,
// writable: false, ← Can't change the value
// enumerable: false, ← Won't show in for...in
// configurable: false ← Can't delete or reconfigure
// }
Property descriptors are metadata objects that describe the characteristics of an object property. Every property in JavaScript has a descriptor that controls whether the property can be changed, deleted, or enumerated. When you create a property the "normal" way (with assignment), JavaScript sets all flags to permissive defaults. As defined in the ECMAScript specification, every property has internal attributes that determine its behavior — this mechanism is what powers built-in immutable properties like Math.PI.
const user = { name: "Alice" }
// Check the descriptor for 'name'
console.log(Object.getOwnPropertyDescriptor(user, 'name'))
// {
// value: "Alice",
// writable: true, ← Can change the value
// enumerable: true, ← Shows in for...in
// configurable: true ← Can delete or reconfigure
// }
Think of property descriptors as the "permissions" for each property. Just like file permissions on your computer control who can read, write, or execute a file, property descriptors control what you can do with a property.
If you've used a computer, you've encountered file permissions. Property descriptors work the same way for object properties.
┌─────────────────────────────────────────────────────────────────────────┐
│ PROPERTY DESCRIPTORS: FILE PERMISSIONS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ FILE PERMISSIONS (Computer) PROPERTY DESCRIPTORS (JS) │
│ ──────────────────────────── ───────────────────────── │
│ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ Read [✓] │ │ enumerable [✓] │ │
│ │ Write [✓] │ → │ writable [✓] │ │
│ │ Delete [✓] │ │ configurable [✓] │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ Normal file Normal property │
│ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ Read [✓] │ │ enumerable [✓] │ │
│ │ Write [✗] │ → │ writable [✗] │ │
│ │ Delete [✗] │ │ configurable [✗] │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ Read-only file Constant property │
│ │
│ Just like you can protect files, you can protect object properties. │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Every data property has three flags that control its behavior. Let's explore each one.
writable: Can the Value Be Changed?When writable is false, the property becomes read-only. Assignment attempts silently fail in non-strict mode or throw a TypeError in strict mode.
"use strict"
const config = {}
Object.defineProperty(config, 'apiVersion', {
value: 'v2',
writable: false, // Read-only
enumerable: true,
configurable: true
})
console.log(config.apiVersion) // "v2"
config.apiVersion = 'v3' // TypeError: Cannot assign to read-only property
enumerable: Does It Show in Loops?When enumerable is false, the property is hidden from iteration methods like for...in, Object.keys(), and the spread operator.
const user = { name: "Alice" }
// Add a hidden metadata property
Object.defineProperty(user, '_id', {
value: 12345,
writable: true,
enumerable: false, // Hidden from iteration
configurable: true
})
// The property exists and works
console.log(user._id) // 12345
// But it's invisible to iteration
console.log(Object.keys(user)) // ["name"] - no _id!
for (const key in user) {
console.log(key) // Only logs "name"
}
// Spread also ignores it
const copy = { ...user }
console.log(copy) // { name: "Alice" } - no _id!
This is how JavaScript hides internal properties. For example, the length property of arrays is non-enumerable:
const arr = [1, 2, 3]
console.log(arr.length) // 3
// But it doesn't show up in keys
console.log(Object.keys(arr)) // ["0", "1", "2"] - no "length"
configurable: Can It Be Deleted or Reconfigured?When configurable is false, you cannot:
writable: you can still change true → false)"use strict"
const settings = {}
Object.defineProperty(settings, 'debug', {
value: true,
writable: true,
enumerable: true,
configurable: false // Locked configuration
})
// Can still change the value (writable is true)
settings.debug = false
console.log(settings.debug) // false
// But can't delete it
delete settings.debug // TypeError: Cannot delete property 'debug'
// Can't make it enumerable: false
Object.defineProperty(settings, 'debug', {
enumerable: false
}) // TypeError: Cannot redefine property: debug
Object.defineProperty()The Object.defineProperty() method is how you create or modify properties with specific descriptors.
Object.defineProperty(obj, propertyName, descriptor)
obj: The object to modifypropertyName: A string or Symbol for the property namedescriptor: An object with the property settingsconst product = {}
Object.defineProperty(product, 'price', {
value: 99.99,
writable: true,
enumerable: true,
configurable: true
})
console.log(product.price) // 99.99
When using Object.defineProperty(), any flag you don't specify defaults to false. This is the opposite of normal assignment!
const obj = {}
// Normal assignment - all flags default to TRUE
obj.a = 1
console.log(Object.getOwnPropertyDescriptor(obj, 'a'))
// { value: 1, writable: true, enumerable: true, configurable: true }
// defineProperty - unspecified flags default to FALSE
Object.defineProperty(obj, 'b', { value: 2 })
console.log(Object.getOwnPropertyDescriptor(obj, 'b'))
// { value: 2, writable: false, enumerable: false, configurable: false }
You can use defineProperty to change flags on existing properties:
const user = { name: "Alice" }
// Make name read-only
Object.defineProperty(user, 'name', {
writable: false
})
// Now it can't be changed
user.name = "Bob" // Silently fails (throws in strict mode)
console.log(user.name) // "Alice"
Object.defineProperties() lets you define multiple properties in one call:
const config = {}
Object.defineProperties(config, {
apiUrl: {
value: 'https://api.example.com',
writable: false,
enumerable: true,
configurable: false
},
timeout: {
value: 5000,
writable: true,
enumerable: true,
configurable: true
},
_internal: {
value: 'secret',
writable: false,
enumerable: false, // Hidden
configurable: false
}
})
console.log(Object.keys(config)) // ["apiUrl", "timeout"] - no _internal
Object.getOwnPropertyDescriptor()const user = { name: "Alice", age: 30 }
const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
console.log(nameDescriptor)
// {
// value: "Alice",
// writable: true,
// enumerable: true,
// configurable: true
// }
Object.getOwnPropertyDescriptors()Object.getOwnPropertyDescriptors() returns descriptors for all own properties:
const user = { name: "Alice", age: 30 }
console.log(Object.getOwnPropertyDescriptors(user))
// {
// name: { value: "Alice", writable: true, enumerable: true, configurable: true },
// age: { value: 30, writable: true, enumerable: true, configurable: true }
// }
The spread operator and Object.assign() don't preserve property descriptors. As documented by MDN, Object.getOwnPropertyDescriptors() was added in ES2017 specifically to enable proper cloning of objects including their accessor properties and flags:
const original = {}
Object.defineProperty(original, 'id', {
value: 1,
writable: false,
enumerable: true,
configurable: false
})
// ❌ WRONG - spread loses the descriptor settings
const badClone = { ...original }
badClone.id = 999 // Works! Not read-only anymore
console.log(badClone.id) // 999
// ✓ CORRECT - preserves all descriptors
const goodClone = Object.defineProperties(
{},
Object.getOwnPropertyDescriptors(original)
)
goodClone.id = 999 // Silently fails (throws in strict mode)
console.log(goodClone.id) // 1 - still protected!
There are two types of property descriptors:
A data descriptor has a value and optionally writable. This is what we've been using:
{
value: "something",
writable: true,
enumerable: true,
configurable: true
}
An accessor descriptor has get and/or set functions instead of value and writable. See Getters & Setters for a deeper dive into accessor properties.
const user = {
firstName: "Alice",
lastName: "Smith"
}
Object.defineProperty(user, 'fullName', {
get() {
return `${this.firstName} ${this.lastName}`
},
set(value) {
const parts = value.split(' ')
this.firstName = parts[0]
this.lastName = parts[1]
},
enumerable: true,
configurable: true
})
console.log(user.fullName) // "Alice Smith"
user.fullName = "Bob Jones"
console.log(user.firstName) // "Bob"
console.log(user.lastName) // "Jones"
// ❌ This throws an error
Object.defineProperty({}, 'broken', {
value: 42,
get() { return 42 } // TypeError: Invalid property descriptor
})
If you only define a get without set, the property becomes read-only:
"use strict"
const circle = { radius: 5 }
Object.defineProperty(circle, 'area', {
get() {
return Math.PI * this.radius ** 2
},
enumerable: true,
configurable: true
})
console.log(circle.area) // 78.53981633974483
circle.area = 100 // TypeError: Cannot set property 'area' which has only a getter
Property descriptors control individual properties. JavaScript also provides methods to protect entire objects.
Object.preventExtensions(): No New Propertiesconst user = { name: "Alice" }
Object.preventExtensions(user)
// Can still modify existing properties
user.name = "Bob"
console.log(user.name) // "Bob"
// But can't add new ones
user.age = 30 // Silently fails (throws in strict mode)
console.log(user.age) // undefined
// Check if extensible
console.log(Object.isExtensible(user)) // false
Object.seal(): No Add/Delete, Can Still ModifyObject.seal() prevents adding or deleting properties by setting configurable: false on all existing properties. MDN notes that sealed objects are one of the most common patterns for creating configuration objects that should not have their structure modified at runtime:
const config = { debug: true, version: 1 }
Object.seal(config)
// Can modify values
config.debug = false
console.log(config.debug) // false
// Can't add properties
config.newProp = "test" // Silently fails
console.log(config.newProp) // undefined
// Can't delete properties
delete config.version // Silently fails
console.log(config.version) // 1
console.log(Object.isSealed(config)) // true
Object.freeze(): Complete ImmutabilityObject.freeze() makes an object completely immutable by setting writable: false and configurable: false on all properties:
const CONSTANTS = {
PI: 3.14159,
E: 2.71828,
GOLDEN_RATIO: 1.61803
}
Object.freeze(CONSTANTS)
// Can't modify
CONSTANTS.PI = 3 // Silently fails
console.log(CONSTANTS.PI) // 3.14159
// Can't add
CONSTANTS.NEW = 1 // Silently fails
// Can't delete
delete CONSTANTS.E // Silently fails
console.log(Object.isFrozen(CONSTANTS)) // true
const user = {
name: "Alice",
address: { city: "NYC" }
}
Object.freeze(user)
user.name = "Bob" // Fails - frozen
user.address.city = "LA" // Works! Nested object isn't frozen
console.log(user.address.city) // "LA"
For deep freeze, you need a recursive function or a library. </Warning>
| Method | Add | Delete | Modify Values | Modify Descriptors |
|---|---|---|---|---|
| Normal object | ✅ | ✅ | ✅ | ✅ |
preventExtensions() | ❌ | ✅ | ✅ | ✅ |
seal() | ❌ | ❌ | ✅ | ❌ |
freeze() | ❌ | ❌ | ❌ | ❌ |
const AppConfig = {}
Object.defineProperties(AppConfig, {
API_URL: {
value: 'https://api.myapp.com',
writable: false,
enumerable: true,
configurable: false
},
MAX_RETRIES: {
value: 3,
writable: false,
enumerable: true,
configurable: false
}
})
// Works like constants
console.log(AppConfig.API_URL) // "https://api.myapp.com"
AppConfig.API_URL = "hacked" // Fails silently
console.log(AppConfig.API_URL) // "https://api.myapp.com" - unchanged
This pattern is similar to how you might use closures to hide data, but works directly on object properties:
function createUser(name, password) {
const user = { name }
// Store password hash as non-enumerable
Object.defineProperty(user, '_passwordHash', {
value: hashPassword(password),
writable: false,
enumerable: false, // Won't show up in JSON.stringify or Object.keys
configurable: false
})
return user
}
const user = createUser("Alice", "secret123")
console.log(JSON.stringify(user)) // {"name":"Alice"} - no password!
console.log(Object.keys(user)) // ["name"] - no _passwordHash!
const rectangle = {
width: 10,
height: 5
}
Object.defineProperty(rectangle, 'area', {
get() {
return this.width * this.height
},
enumerable: true,
configurable: true
})
console.log(rectangle.area) // 50
rectangle.width = 20
console.log(rectangle.area) // 100 - automatically updates!
This pattern is especially useful in factory functions and classes where you want to enforce data integrity:
const person = { _age: 0 }
Object.defineProperty(person, 'age', {
get() {
return this._age
},
set(value) {
if (typeof value !== 'number' || value < 0) {
throw new TypeError('Age must be a positive number')
}
this._age = value
},
enumerable: true,
configurable: true
})
person.age = 25
console.log(person.age) // 25
person.age = -5 // TypeError: Age must be a positive number
person.age = "old" // TypeError: Age must be a positive number
Every property has a descriptor. It controls whether the property is writable, enumerable, and configurable.
Normal assignment sets all flags to true. Properties created with = are fully permissive by default.
defineProperty defaults flags to false. Always explicitly set the flags you want when using this method.
writable: false makes a property read-only. Assignment silently fails in non-strict mode, throws in strict mode.
enumerable: false hides the property. It won't appear in for...in, Object.keys(), JSON.stringify(), or spread.
configurable: false is permanent. You can never undo it. The property can't be deleted or reconfigured.
Data descriptors have value and writable. Accessor descriptors have get and set. You can't mix them.
Object.freeze() is shallow. Nested objects remain unfrozen. Use recursion for deep freeze.
Use getOwnPropertyDescriptors() for true cloning. Spread and Object.assign() don't preserve descriptors.
Property descriptors power JavaScript's built-ins. This is how Math.PI and array .length have special behavior.
When you assign a property normally (with `=`), all descriptor flags default to `true`:
```javascript
const obj = {}
obj.name = "Alice"
// { value: "Alice", writable: true, enumerable: true, configurable: true }
```
When you use `Object.defineProperty()`, unspecified flags default to `false`:
```javascript
Object.defineProperty(obj, 'id', { value: 1 })
// { value: 1, writable: false, enumerable: false, configurable: false }
```
This means properties created with `defineProperty` are restrictive by default.
Non-enumerable properties are hidden from iteration. This is useful for:
1. **Internal/metadata properties** that shouldn't be serialized:
```javascript
Object.defineProperty(user, '_internalId', {
value: 'xyz123',
enumerable: false
})
JSON.stringify(user) // Won't include _internalId
```
2. **Methods on objects** that shouldn't appear in `for...in` loops
3. **Matching built-in behavior** like `Array.prototype.length`
You get a `TypeError`. A descriptor must be either a data descriptor (with `value` and optionally `writable`) or an accessor descriptor (with `get` and/or `set`). You cannot combine both:
```javascript
Object.defineProperty({}, 'prop', {
value: 42,
get() { return 42 }
})
// TypeError: Invalid property descriptor. Cannot both specify accessors
// and a value or writable attribute
```
Use `Object.defineProperty()` with `writable: false` and `configurable: false`:
```javascript
const CONFIG = {}
Object.defineProperty(CONFIG, 'MAX_SIZE', {
value: 1024,
writable: false, // Can't change the value
enumerable: true, // Visible in iteration
configurable: false // Can't delete or reconfigure
})
CONFIG.MAX_SIZE = 9999 // Silently fails
delete CONFIG.MAX_SIZE // Returns false
console.log(CONFIG.MAX_SIZE) // 1024 - unchanged
```
For an entire object, use `Object.freeze()`. But remember it's shallow.
`Object.freeze()` only affects the direct properties of the object, not nested objects. This is called "shallow" freezing:
```javascript
const data = {
user: { name: "Alice" }
}
Object.freeze(data)
data.user = {} // Fails - data is frozen
data.user.name = "Bob" // Works! user object isn't frozen
```
For deep freezing, you need a recursive function:
```javascript
function deepFreeze(obj) {
Object.freeze(obj)
for (const key of Object.keys(obj)) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
deepFreeze(obj[key])
}
}
return obj
}
```
Use `Object.defineProperties()` with `Object.getOwnPropertyDescriptors()`:
```javascript
const original = {}
Object.defineProperty(original, 'id', {
value: 1,
writable: false,
enumerable: true,
configurable: false
})
// ❌ Spread loses descriptors
const badClone = { ...original }
// ✓ This preserves descriptors
const goodClone = Object.defineProperties(
{},
Object.getOwnPropertyDescriptors(original)
)
console.log(Object.getOwnPropertyDescriptor(goodClone, 'id'))
// { value: 1, writable: false, enumerable: true, configurable: false }
```