JavaScript · Objects & Prototypes

JavaScript Objects & Properties — Creation, Descriptors, Getters and Enumeration

8 min read Updated 2026-06-18

Practice Objects & Properties interview questions

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 in for...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/Symbol keys to values; each property carries a descriptor (value, writable, enumerable, configurable).
  • Assignment creates fully-enumerable, writable, configurable properties; Object.defineProperty defaults them to false.
  • Getters/setters make accessor properties that run logic but look like data.
  • Object.keys and friends see only own enumerable string keys; for...in also walks the prototype chain — guard with Object.hasOwn.
  • freeze/seal/preventExtensions lock objects down, but freeze is shallow.
  • Spread and Object.assign make shallow copies/merges; reach for structuredClone for 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.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.