Objects are the heart of JavaScript
Almost everything in JavaScript that isn't a primitive is an object: arrays, functions,
dates, regexes, and the plain {} you reach for every day. An object is a collection of
properties, where each property maps a key (a string or a Symbol) to a
value. That sounds simple, but the object model has a surprising amount of depth —
descriptors, getters, enumeration rules, and immutability controls that most developers
never look at directly. Understanding them turns "objects just work" into "I know exactly
why they behave this way," which is precisely what interviewers probe for.
This guide walks through how objects are created, what a property really is under the hood, and the tools the language gives you to inspect, protect, and copy them.
Ways to create an object
There are several ways to make an object, and each communicates a different intent.
const a = {} // object literal — by far the most common
const b = new Object() // constructor form — almost never used
const c = Object.create(proto) // create with an explicit prototype
function Point(x) { this.x = x } // constructor function
const d = new Point(1)
class P {} // class
const e = new P()
The object literal {} is what you'll use 95% of the time. Object.create(proto) is
special because it's the only form that lets you set the prototype directly — including
Object.create(null) to make a "bare" object with no prototype at all, which is handy
for using an object as a clean dictionary with no inherited keys like toString.
const dict = Object.create(null)
dict.hasOwnProperty // undefined — no inherited methods safe as a map
Shorthand and computed keys
Modern object literals are expressive. Shorthand properties let you omit the value when a variable matches the key name, and computed keys let you build a key from an expression.
const name = 'Ada', age = 36
const prefix = 'user'
const user = {
name, // shorthand for name: name
age,
[`${prefix}Id`]: 1, // computed key -> userId: 1
greet() { return `Hi ${this.name}` } // method shorthand
}
These reduce noise dramatically compared to the old name: name style, and computed keys
are essential when keys are dynamic.
A property is more than a value
Here's the part most people miss: each property has not just a value but a set of attributes described by a property descriptor. For a normal "data property" the descriptor has four fields:
value— the stored value.writable— can the value be reassigned?enumerable— does it show up infor...in,Object.keys, and spreading?configurable— can the property be deleted or its descriptor changed?
You can inspect a descriptor with Object.getOwnPropertyDescriptor:
const o = { x: 1 }
Object.getOwnPropertyDescriptor(o, 'x')
// { value: 1, writable: true, enumerable: true, configurable: true }
Properties created by normal assignment default all three booleans to true. But
properties you define explicitly default them to false — a classic gotcha.
const obj = {}
Object.defineProperty(obj, 'hidden', { value: 42 })
obj.hidden // 42
Object.keys(obj) // [] not enumerable by default
obj.hidden = 99 // silently fails (writable: false)
Object.defineProperty (and Object.defineProperties for several at once) is how you
create non-enumerable, read-only, or otherwise specialized properties — the same machinery
the language uses internally for things like array length.
Getters and setters
Instead of a static value, a property can be an accessor backed by functions. A getter runs when the property is read; a setter runs when it's assigned. They let a property look like data while running logic.
const temp = {
celsius: 20,
get fahrenheit() { return this.celsius * 9 / 5 + 32 },
set fahrenheit(f) { this.celsius = (f - 32) * 5 / 9 }
}
temp.fahrenheit // 68 — getter runs
temp.fahrenheit = 212 // setter runs
temp.celsius // 100
Accessors are great for computed/derived values and validation. The pitfall: an accessor that does heavy work looks like a cheap field but isn't, so callers may read it repeatedly without realizing the cost. Keep getters fast and side-effect free.
Enumerating properties
There are several ways to walk an object's keys, and they differ in what they include.
const obj = { a: 1, b: 2 }
Object.keys(obj) // ['a', 'b'] own, enumerable, string keys
Object.values(obj) // [1, 2]
Object.entries(obj) // [['a', 1], ['b', 2]]
for (const key in obj) {} // own AND inherited enumerable string keys
The crucial difference: for...in walks the prototype chain, so it can surface
inherited keys, while Object.keys/values/entries only see the object's own enumerable
properties. That's why for...in is risky on objects with custom prototypes — guard it
with Object.hasOwn(obj, key) (or the older obj.hasOwnProperty(key)).
for (const key in obj) {
if (Object.hasOwn(obj, key)) { /* only own keys */ }
}
Symbol-keyed and non-enumerable properties are invisible to all of these; use
Object.getOwnPropertySymbols or Reflect.ownKeys to see everything.
Checking for a property
There are three common membership tests, and they answer subtly different questions.
'x' in obj // true if 'x' exists anywhere on the chain
Object.hasOwn(obj, 'x') // true only if it's an OWN property modern
obj.x !== undefined // false negative if the value IS undefined
in includes inherited properties; Object.hasOwn (ES2022, the safe replacement for
hasOwnProperty) restricts to own properties. Avoid the !== undefined check — a property
that exists with the value undefined would be missed.
Deleting properties
The delete operator removes an own property entirely (descriptor and all), provided it's
configurable.
const o = { a: 1, b: 2 }
delete o.a
o // { b: 2 }
Don't use delete to "clear" a value in performance-sensitive code — it can deoptimize the
object's hidden class in some engines. Setting the value to null or undefined is often
better if you only want to blank it, and reserve delete for genuinely removing a key.
Freezing and sealing — controlling mutability
JavaScript gives three levels of lock-down:
Object.preventExtensions(o)— no new properties can be added; existing ones stay mutable.Object.seal(o)— prevent extensions and mark all properties non-configurable (can't delete or reconfigure), but values can still change.Object.freeze(o)— seal and make every property non-writable: a fully read-only object.
const config = Object.freeze({ apiUrl: '/api', retries: 3 })
config.retries = 5 // silently ignored (throws in strict mode)
Object.isFrozen(config) // true
The big caveat: Object.freeze is shallow. Nested objects are still mutable.
const state = Object.freeze({ user: { name: 'Ada' } })
state.user.name = 'Grace' // still works — nested object not frozen
For deep immutability you need a recursive freeze helper, or a library. This shallowness trips up people relying on freeze to protect nested config or state.
Copying objects
Copying is where reference semantics bite. A plain assignment copies the reference, so
both variables point at the same object. To get a real copy you spread or use
Object.assign:
const original = { a: 1, nested: { b: 2 } }
const shallow1 = { ...original } // spread — shallow copy
const shallow2 = Object.assign({}, original) // same effect
Both produce a shallow copy: top-level properties are duplicated, but nested objects are still shared by reference.
shallow1.nested.b = 99
original.nested.b // 99 nested object was shared
For a true deep copy of plain data, use the built-in structuredClone:
const deep = structuredClone(original)
deep.nested.b = 99
original.nested.b // 2 fully independent
structuredClone handles nested objects, arrays, Maps, Sets, and dates, but it can't clone
functions or DOM nodes. For those rare cases you'll need a custom approach.
Merging objects
Spread and Object.assign also merge, with later sources winning on key conflicts.
const defaults = { theme: 'light', size: 'md' }
const overrides = { size: 'lg' }
const settings = { ...defaults, ...overrides } // { theme: 'light', size: 'lg' }
This "override order" is the foundation of options/config patterns. Remember it's a shallow merge — nested objects are replaced wholesale, not deep-merged.
Key takeaways
- Objects map string/
Symbolkeys to values; each property carries a descriptor (value,writable,enumerable,configurable). - Assignment creates fully-enumerable, writable, configurable properties;
Object.definePropertydefaults them tofalse. - Getters/setters make accessor properties that run logic but look like data.
Object.keysand friends see only own enumerable string keys;for...inalso walks the prototype chain — guard withObject.hasOwn.freeze/seal/preventExtensionslock objects down, butfreezeis shallow.- Spread and
Object.assignmake shallow copies/merges; reach forstructuredClonefor deep copies.
Master these and you'll understand not just how to use objects, but why they behave the way they do — the difference between memorizing syntax and genuinely knowing the language.