Skip to content

Objects and Property Access

beginner18 min read

Everything Interesting Is an Object

Arrays, functions, dates, regular expressions, errors — they're all objects under the hood. Once you understand how plain objects work, you understand the building block that powers almost everything in JavaScript. This isn't abstract theory. You'll use objects in every single file you write.

Mental Model

Think of an object as a labeled filing cabinet. Each drawer has a label (the property name) and contains something (the value). You can open a drawer by its exact label (dot notation), or look up a drawer using a label written on a sticky note (bracket notation). Some drawers are locked (non-writable), some are hidden from the directory (non-enumerable), and you can freeze the entire cabinet so nobody adds or removes drawers.

Object Literals and Shorthand Properties

The most common way to create an object is with curly braces — an object literal:

const user = {
  name: "Sarah",
  age: 28,
  isAdmin: false
};

When the variable name matches the property name, you can use shorthand syntax:

const name = "Sarah";
const age = 28;

// Without shorthand
const user = { name: name, age: age };

// With shorthand — same result, less noise
const user = { name, age };

This also works for methods. Instead of writing greet: function() {}, you can write:

const user = {
  name: "Sarah",
  greet() {
    return `Hi, I'm ${this.name}`;
  }
};
When to use shorthand

Always prefer shorthand when the variable name matches the property name. It reduces visual noise and is the standard pattern in modern codebases. You'll see it everywhere in React props, API responses, and destructuring.

Dot Notation vs Bracket Notation

There are two ways to access a property:

const user = { name: "Sarah", age: 28 };

// Dot notation
user.name;    // "Sarah"

// Bracket notation
user["name"]; // "Sarah"

They do the same thing, so why do both exist? Because dot notation has limitations. You need bracket notation when:

1. The property name is stored in a variable:

const key = "name";
user.key;   // undefined — looks for a property literally called "key"
user[key];  // "Sarah" — evaluates the variable, looks for "name"

2. The property name has special characters or spaces:

const config = {
  "api-key": "abc123",
  "content type": "json"
};

config.api-key;         // SyntaxError — JavaScript sees minus operator
config["api-key"];      // "abc123"
config["content type"]; // "json"

3. The property name is a number:

const scores = { 1: "one", 2: "two" };
scores.1;    // SyntaxError
scores[1];   // "one" — number is coerced to string "1"
scores["1"]; // "one"
Key Rules
  1. 1Use dot notation by default — it is cleaner and easier to read
  2. 2Use bracket notation when the key is dynamic (stored in a variable)
  3. 3Use bracket notation when the key contains special characters, spaces, or starts with a number
  4. 4All property keys are ultimately strings (or Symbols) — numbers get coerced to strings
Quiz
What does user[key] return when key = 'age' and user = { name: 'Sarah', age: 28, key: 'admin' }?

Computed Property Names

Sometimes you need to create an object where the property names are dynamic — determined at runtime. Computed property names let you use an expression inside square brackets as a key:

const field = "email";

const user = {
  name: "Sarah",
  [field]: "sarah@example.com"
};
// { name: "Sarah", email: "sarah@example.com" }

This is powerful for building objects programmatically:

function createPair(key, value) {
  return { [key]: value };
}

createPair("color", "blue"); // { color: "blue" }
createPair("size", 42);      // { size: 42 }

You can even use expressions:

const prefix = "user";

const obj = {
  [`${prefix}Name`]: "Sarah",
  [`${prefix}Age`]: 28
};
// { userName: "Sarah", userAge: 28 }
Quiz
What does { ['to' + 'String']: 42 } create?

Property Existence Checks

Reading a property that does not exist returns undefined. But here is the problem — a property can also exist and intentionally hold undefined as its value. So how do you tell the difference?

const config = { debug: undefined, verbose: false };

config.debug;   // undefined — but it exists!
config.missing; // undefined — and it doesn't exist!

There are three ways to check if a property actually exists:

The in operator

Checks the object and its prototype chain:

"debug" in config;    // true — property exists (even though value is undefined)
"missing" in config;  // false
"toString" in config; // true — inherited from Object.prototype

hasOwnProperty()

Checks only the object itself, not the prototype chain:

config.hasOwnProperty("debug");    // true
config.hasOwnProperty("toString"); // false — it is on the prototype, not the object

Object.hasOwn() (modern, preferred)

Does the same thing as hasOwnProperty(), but safer:

Object.hasOwn(config, "debug");    // true
Object.hasOwn(config, "toString"); // false
Why Object.hasOwn() is safer than hasOwnProperty()

hasOwnProperty is a method on Object.prototype. If someone creates an object with Object.create(null) (no prototype), or shadows hasOwnProperty with their own property, the method call breaks:

const obj = Object.create(null);
obj.name = "test";
obj.hasOwnProperty("name"); // TypeError: obj.hasOwnProperty is not a function

Object.hasOwn(obj, "name"); // true — works regardless

Object.hasOwn() is a static method, so it does not depend on the object having a prototype. Use it whenever you can.

Quiz
What does 'toString' in {} return?

Object.keys(), Object.values(), Object.entries()

These three methods let you extract parts of an object into arrays:

const user = { name: "Sarah", age: 28, role: "engineer" };

Object.keys(user);    // ["name", "age", "role"]
Object.values(user);  // ["Sarah", 28, "engineer"]
Object.entries(user); // [["name", "Sarah"], ["age", 28], ["role", "engineer"]]

Object.entries() is especially useful for iterating with destructuring:

for (const [key, value] of Object.entries(user)) {
  console.log(`${key}: ${value}`);
}
// name: Sarah
// age: 28
// role: engineer
Common Trap

These methods only return own enumerable string-keyed properties. They skip inherited properties, non-enumerable properties, and Symbol keys. This is usually what you want, but if you need inherited properties, use a for...in loop (which walks the prototype chain).

You can also convert entries back to an object:

const entries = [["name", "Sarah"], ["age", 28]];
const user = Object.fromEntries(entries);
// { name: "Sarah", age: 28 }

This makes Object.entries() and Object.fromEntries() a powerful pair for transforming objects:

const prices = { apple: 1.5, banana: 0.75, cherry: 3.0 };

const discounted = Object.fromEntries(
  Object.entries(prices).map(([fruit, price]) => [fruit, price * 0.9])
);
// { apple: 1.35, banana: 0.675, cherry: 2.7 }

Object.assign() and Spread for Shallow Copying

Two ways to copy properties from one object to another:

Object.assign()

Copies own enumerable properties from source objects into a target:

const defaults = { theme: "dark", lang: "en" };
const userPrefs = { lang: "fr" };

const settings = Object.assign({}, defaults, userPrefs);
// { theme: "dark", lang: "fr" }

The first argument is the target — it gets mutated. Later sources overwrite earlier ones. Passing {} as the target creates a new object, leaving the originals untouched.

Spread syntax

Does the same thing, but cleaner:

const settings = { ...defaults, ...userPrefs };
// { theme: "dark", lang: "fr" }
Both are shallow copies

Neither spread nor Object.assign() creates deep copies. Nested objects are still shared by reference:

const original = { name: "Sarah", address: { city: "Paris" } };
const copy = { ...original };

copy.address.city = "London";
original.address.city; // "London" — the nested object is shared!

For deep copies, use structuredClone():

const deepCopy = structuredClone(original);
deepCopy.address.city = "London";
original.address.city; // "Paris" — truly independent
Quiz
After running: const a = { x: 1 }; const b = { ...a, y: 2 }; a.x = 99; — what is b.x?

Property Descriptors — A Brief Introduction

Every property you create has hidden metadata called a descriptor. It has three boolean flags:

FlagDefaultControls
writabletrueCan the value be changed?
enumerabletrueDoes it show in Object.keys() and for...in?
configurabletrueCan the descriptor be modified or the property deleted?

You can inspect them:

const user = { name: "Sarah" };
Object.getOwnPropertyDescriptor(user, "name");
// { value: "Sarah", writable: true, enumerable: true, configurable: true }

And you can define properties with specific flags:

const user = {};
Object.defineProperty(user, "id", {
  value: 42,
  writable: false,
  enumerable: true,
  configurable: false
});

user.id = 100; // silently fails (or throws in strict mode)
user.id;       // 42
delete user.id; // false — can't delete non-configurable properties
More depth ahead

Property descriptors are covered in full detail in the JavaScript Deep Dive module. For now, just know they exist and that they control what you can do with each property.

Object.freeze() and Object.seal()

JavaScript gives you three levels of locking down an object:

Object.freeze() — Full lockdown

No adding, removing, or changing properties:

const config = { apiUrl: "https://api.example.com", timeout: 5000 };
Object.freeze(config);

config.timeout = 10000;   // silently ignored (throws in strict mode)
config.retries = 3;       // silently ignored
delete config.apiUrl;     // false

Object.seal() — Sealed but writable

No adding or removing properties, but existing values can change:

const user = { name: "Sarah", age: 28 };
Object.seal(user);

user.age = 29;           // works — existing property, value is writable
user.email = "s@test.com"; // silently ignored — can't add
delete user.name;        // false — can't remove

Object.preventExtensions() — Just no new properties

Existing properties can be changed or deleted, but you cannot add new ones:

const obj = { a: 1 };
Object.preventExtensions(obj);

obj.b = 2;     // silently ignored
obj.a = 99;    // works
delete obj.a;  // true — works

Comparison

CapabilitypreventExtensionssealfreeze
Add new propertiesNoNoNo
Remove propertiesYesNoNo
Change valuesYesYesNo
Reconfigure descriptorsYesNoNo
Common Trap

All three operations are shallow. Object.freeze() does not freeze nested objects:

const app = { config: { debug: true } };
Object.freeze(app);

app.config.debug = false; // This works — nested object is not frozen
app.config;               // { debug: false }

If you need deep freeze, you have to write a recursive function or use structuredClone() to create an immutable copy.

Reference Equality — Why !==

This trips up every beginner:

{} === {};             // false
[] === [];             // false
{ a: 1 } === { a: 1 }; // false (if evaluated as expressions)

Why? Because objects are compared by reference, not by value. Each {} creates a brand new object at a unique memory address. Two different objects with identical contents are still two different objects.

const a = { x: 1 };
const b = { x: 1 };
const c = a;

a === b; // false — different objects, different references
a === c; // true — same reference, c points to the same object as a

c.x = 99;
a.x; // 99 — a and c are the same object
Mental Model

Think of objects as houses. Two houses can have the exact same floor plan, paint color, and furniture — but they are still different houses at different addresses. When you compare objects with ===, you are comparing addresses, not contents. The only way two variables are === is if they point to the exact same house.

To compare contents, you need to check property by property — or use JSON.stringify() for simple flat objects (with caveats around key order and special values like undefined).

Quiz
What does [1, 2, 3] === [1, 2, 3] return?

Optional Chaining with Objects

Real-world data is messy. APIs return partial responses. Configuration objects have optional nesting. Without protection, accessing a deep property can throw:

const user = { name: "Sarah", address: null };

user.address.city; // TypeError: Cannot read properties of null

Optional chaining (?.) short-circuits to undefined when it hits null or undefined:

user.address?.city;         // undefined — no error
user.settings?.theme;       // undefined — settings doesn't exist
user.getProfile?.();        // undefined — getProfile doesn't exist, not called

You can chain multiple levels:

const company = {
  ceo: {
    name: "Alex",
    address: {
      city: "Berlin"
    }
  }
};

company.ceo?.address?.city;     // "Berlin"
company.cto?.address?.city;     // undefined — cto doesn't exist
Do not overuse optional chaining

Optional chaining is for genuinely optional data. If a property should exist and is missing, you have a bug — and optional chaining will silently hide it. Use it for external data boundaries (API responses, user input), not for internal data structures where you control the shape.

Combining with Nullish Coalescing

Use ?. with ?? to provide default values:

const theme = user.settings?.theme ?? "dark";
// If settings or theme is null/undefined, fall back to "dark"

Why ?? instead of ||? Because || treats 0, "", and false as falsy:

const volume = user.settings?.volume || 50;  // If volume is 0, this gives 50 (wrong!)
const volume = user.settings?.volume ?? 50;  // If volume is 0, this gives 0 (correct!)
What developers doWhat they should do
Using typeof obj.prop !== 'undefined' to check existence
typeof checks the value, not whether the property exists. A property can exist with the value undefined. The in operator and Object.hasOwn() check actual existence.
Use the in operator or Object.hasOwn() for existence checks
Using obj1 === obj2 to compare object contents
=== compares references (memory addresses), not contents. Two objects with identical properties are still different objects.
Compare properties individually or use JSON.stringify() for simple objects
Using spread or Object.assign() for deep copies
Spread and Object.assign() only copy the top level. Nested objects remain shared references. Mutating a nested property in the copy changes the original.
Use structuredClone() for deep copies
Using optional chaining everywhere as a safety blanket
Overusing ?. hides real bugs. If data should exist, let it throw so you catch the problem early. Reserve ?. for external data you do not control.
Only use optional chaining for genuinely optional properties
Using || for default values when 0 or empty string are valid
The || operator treats 0, '', and false as falsy and skips them. The ?? operator only falls back on null or undefined, preserving intentional falsy values.
Use ?? (nullish coalescing) when 0, empty string, or false are legitimate values
Key Rules
  1. 1Objects are compared by reference — two separate objects with the same content are never equal
  2. 2Use dot notation by default, bracket notation for dynamic or special-character keys
  3. 3Use Object.hasOwn() to check property existence — it is safer than hasOwnProperty()
  4. 4Spread and Object.assign() are shallow — use structuredClone() for deep copies
  5. 5Object.freeze() is shallow — nested objects remain mutable
  6. 6Use optional chaining for genuinely optional data, not as a universal safety net
Quiz
What is the output of: const a = { x: { y: 1 } }; const b = Object.freeze(a); b.x.y = 2; console.log(b.x.y);