You already know the shape of an object: curly braces, a few labeled values inside, dot notation to read them back out. That’s enough to get going, and we covered it in the arrays and objects article. But objects are the backbone of almost everything you’ll build in JavaScript, and once you start writing real code you bump into questions the basics don’t answer. How do you read a key whose name you only know at runtime? Why does copying an object sometimes not actually copy it? How do you loop over an object’s contents? What does it mean when an object has a method?
This article digs into all of that. By the end you’ll understand objects well enough to read other people’s code without flinching and to avoid the handful of subtle traps that bite nearly everyone at some point.
Two ways to read and write properties
Dot notation is the everyday way to reach into an object, and it’s the one you should default to because it reads cleanly:
const user = {
name: "John Doe",
age: 28,
};
console.log(user.name); // "John Doe"
user.age = 29; // update
user.city = "Jakarta"; // add a new property
But there’s a second way: bracket notation, written with square brackets and a string key. It does the exact same thing — until it doesn’t, because brackets can do something dot notation simply can’t.
console.log(user["name"]); // "John Doe" — same as user.name
So far that just looks more verbose. The payoff is that the thing inside the brackets is an expression. It can be a variable, which means you can read a property whose name you don’t know until the program runs:
const field = "age";
console.log(user[field]); // 28 — reads the "age" property
// This is different from dot notation, which is literal:
console.log(user.field); // undefined — looks for a key literally named "field"
This is the difference that matters. user.field looks for a property literally called field. user[field] evaluates field first (to the string "age") and then looks that up. Whenever the key comes from a variable, user input, or a loop, you need brackets.
When you're forced to use brackets
Bracket notation isn’t only about variables — it’s also the only way to reach keys that aren’t valid identifiers. A key with a space or a dash in it ({ "first name": "John" }), or a key that’s purely numeric, can’t be written with a dot: user.first name is a syntax error. You have to write user["first name"]. So the rule of thumb is simple: use dot notation by default for its readability, and switch to brackets when the key is dynamic or has an unusual name.
Computed property names
Brackets work when you write an object literal too, not just when you read from one. Wrapping a key in square brackets inside a { } lets you compute the key name from a variable — this is called a computed property name:
const key = "status";
const order = {
id: 101,
[key]: "shipped", // the key becomes "status"
};
console.log(order.status); // "shipped"
Without the brackets, key: "shipped" would create a property literally named key. With them, JavaScript evaluates the variable first and uses its value as the property name. You’ll see this constantly in code that builds objects dynamically — turning a list of fields into an object, for instance.
Shorthand properties
When a variable’s name is the same as the key you want, ES2015 lets you skip the repetition. Instead of writing name: name, you write name once:
const name = "Jane Doe";
const age = 34;
// The long way:
const userA = { name: name, age: age };
// Shorthand — identical result:
const userB = { name, age };
console.log(userB); // { name: "Jane Doe", age: 34 }
This is purely a convenience, but you’ll see it everywhere in modern code, especially when returning objects from functions. It pairs naturally with functions that gather a few values and hand them back as a tidy object.
Methods: functions living inside objects
A property’s value can be anything — a number, a string, an array, even another object. It can also be a function. A function stored as a property of an object is called a method, and methods are how objects do things rather than just hold things.
const calculator = {
total: 0,
add(amount) {
this.total += amount;
},
reset() {
this.total = 0;
},
};
calculator.add(50);
calculator.add(30);
console.log(calculator.total); // 80
The add(amount) { ... } syntax is the modern shorthand for defining a method (it’s the same as add: function(amount) { ... }, just shorter). You call a method exactly like you’d expect: object.method(args).
The this keyword inside methods
Notice this.total in the example above. Inside a method, this refers to the object the method was called on — so this.total means “the total property of whatever object I’m a method of.” That’s how add knows which total to update.
const account = {
owner: "John Doe",
balance: 1000,
describe() {
return `${this.owner} has ${this.balance}`;
},
};
console.log(account.describe()); // "John Doe has 1000"
this is one of the trickier corners of JavaScript, and how it behaves depends entirely on how a function is called, not where it’s defined. The full story deserves its own article, but for ordinary methods called as object.method(), the rule is straightforward: this is the object before the dot.
Arrow functions don't get their own this
There’s one trap worth flagging now. If you define a method using an arrow function, it will not bind this to the object — arrow functions inherit this from the surrounding scope instead. So describe: () => this.owner inside an object will usually give you undefined, not the owner. For methods that need this to point at the object, use the shorthand describe() { ... } form, not an arrow. Save arrows for callbacks where you actually want to keep the outer this.
Objects are copied by reference, not by value
This is the single most important — and most surprising — thing about objects, and it causes more confusion than anything else on this list. When you assign an object to a new variable, you do not get a copy of the object. You get a second name pointing at the same object.
const original = { count: 1 };
const copy = original; // NOT a copy — same object, two names
copy.count = 99;
console.log(original.count); // 99 — original changed too!
Both original and copy are labels stuck on the one underlying object. Change it through either label and the change shows up through both. This is what “objects are stored by reference” means, and it’s completely different from how primitives (numbers, strings, booleans) work — those are copied by value:
let a = 5;
let b = a; // b is a real, independent copy
b = 10;
console.log(a); // 5 — unaffected
The same reference behavior applies when you pass an object into a function: the function receives a reference to the same object, so changes it makes are visible to the caller. Arrays work this way too, since arrays are objects under the hood.
Making an actual copy: spread and Object.assign
When you genuinely want an independent copy, you have to ask for one. The cleanest modern way is the spread operator (...), which copies all of an object’s properties into a fresh object:
const original = { count: 1, label: "a" };
const realCopy = { ...original };
realCopy.count = 99;
console.log(original.count); // 1 — untouched this time
console.log(realCopy.count); // 99
Object.assign({}, original) does the same job and you’ll still see it in older code:
const realCopy = Object.assign({}, original);
Spread also makes it easy to copy and tweak in one step, which is a hugely common pattern (it’s how state updates work in frameworks like React):
const updated = { ...original, count: 2 };
// all of original's properties, but with count overridden to 2
Spread is a shallow copy — nested objects still share
Here’s the catch that bites people after they’ve learned about spread: it only copies one level deep. If a property is itself an object or array, the copy gets the same reference to that nested object, not a copy of it. So const copy = { ...user } followed by copy.address.city = "Bandung" will change user.address.city too, because both objects point at the same address. For a fully independent deep copy, use structuredClone(original) in modern environments, or a library function. Just know that { ...obj } and Object.assign are shallow — they’re perfect for flat objects and a trap for nested ones.
Looping over an object
Arrays have indexes, so a for loop walks them naturally. Objects have named keys instead, so iterating them needs a different approach. The three workhorse methods turn an object’s contents into arrays you can loop over with the array tools you already know.
Object.keys() gives you an array of the keys, Object.values() gives you the values, and Object.entries() gives you both as [key, value] pairs:
const scores = {
math: 90,
science: 85,
history: 78,
};
console.log(Object.keys(scores)); // ["math", "science", "history"]
console.log(Object.values(scores)); // [90, 85, 78]
console.log(Object.entries(scores)); // [["math", 90], ["science", 85], ["history", 78]]
Because each of these returns a real array, you can chain on forEach, map, or any of the array methods you’ve already learned:
Object.entries(scores).forEach(([subject, score]) => {
console.log(`${subject}: ${score}`);
});
// math: 90
// science: 85
// history: 78
That [subject, score] in the callback is array destructuring — it pulls the two elements of each pair into named variables. It reads beautifully once you’re used to it.
The for…in loop
There’s also a dedicated loop for objects, for...in, which iterates over an object’s keys directly. It works, but it has a sharp edge worth knowing about:
for (const key in scores) {
console.log(key, scores[key]);
}
Prefer Object.keys/entries over for...in
for...in walks not just an object’s own keys but also any keys it inherits from its prototype chain, which can surprise you with properties you didn’t put there. Object.keys() and Object.entries() return only the object’s own properties, so they’re more predictable and they hand you a normal array you can map and filter. For everyday object iteration, reach for Object.entries(obj).forEach(...) first. If you ever do use for...in, guard it with Object.hasOwn(obj, key) to skip inherited keys.
Checking whether a property exists
Reaching for a property that doesn’t exist returns undefined rather than throwing an error, which is convenient but can hide bugs. A few clean ways to check for existence:
const user = { name: "John Doe", age: 28 };
console.log("name" in user); // true — the `in` operator
console.log("email" in user); // false
console.log(Object.hasOwn(user, "age")); // true — modern, own-property only
console.log(user.email !== undefined); // false — also works
The in operator is short and readable. Object.hasOwn() is the modern, reliable choice when you specifically want to ignore inherited properties. Comparing against undefined works but is slightly fragile — a property that genuinely holds the value undefined would read as “missing,” which usually isn’t what you mean.
Optional chaining: reaching into nested objects safely
Real data nests deeply, and reaching through a chain of properties is risky when a middle piece might be absent. Accessing a property on undefined throws an error and crashes that line:
const order = { id: 1 }; // no `customer` here
// console.log(order.customer.name); // TypeError: Cannot read properties of undefined
Optional chaining (?.) solves this cleanly. If the value before ?. is null or undefined, the whole expression short-circuits to undefined instead of throwing:
console.log(order.customer?.name); // undefined — no crash
const full = {
customer: { name: "Jane Doe", address: { city: "Jakarta" } },
};
console.log(full.customer?.address?.city); // "Jakarta"
It pairs naturally with the nullish coalescing operator (??) to supply a fallback when something is missing:
const city = order.customer?.address?.city ?? "Unknown";
console.log(city); // "Unknown"
These two operators have quietly become some of the most-used features in modern JavaScript, precisely because they make working with messy, partially-filled data painless.
Freezing an object to prevent changes
By default every object is mutable — anyone with a reference can change it. When you want to lock an object so it can’t be modified, Object.freeze() does exactly that:
const config = Object.freeze({
apiUrl: "https://api.example.com",
timeout: 5000,
});
config.timeout = 9000; // silently ignored (or throws in strict mode)
console.log(config.timeout); // 5000 — unchanged
This is handy for configuration objects and constants you never want touched. Two caveats: an attempted change fails silently unless your code runs in strict mode (where it throws), and like spread, freezing is shallow — nested objects inside a frozen object can still be changed. For most config use cases the shallow freeze is plenty.
A realistic example
Let’s tie the pieces together into something close to real code — a small order with a method, dynamic keys, and safe access:
function createOrder(customer, items) {
return {
customer, // shorthand property
items,
get itemCount() { // a computed accessor
return this.items.length;
},
total() { // a method using `this`
return this.items.reduce((sum, item) => sum + item.price, 0);
},
};
}
const order = createOrder("Jane Doe", [
{ name: "Keyboard", price: 250000 },
{ name: "Mouse", price: 120000 },
]);
console.log(order.customer); // "Jane Doe"
console.log(order.itemCount); // 2
console.log(order.total()); // 370000
// safely read something that might not be there:
console.log(order.coupon?.code ?? "no coupon"); // "no coupon"
This one factory function uses shorthand properties, a method that leans on this, a getter, and optional chaining at the call site — all of the ideas from this article working together the way they do in everyday code.
A note on working with JSON
Objects are also how you handle data that arrives from an API or gets saved to storage. That data travels as JSON — text that looks like a JavaScript object — and you convert between the two with JSON.parse (text to object) and JSON.stringify (object to text). When you need to inspect or tidy that text, a JSON formatter makes a wall of minified data readable in seconds. We’ll cover JSON properly in its own article — for now, just know that the object skills you’re building here are exactly what you’ll use the moment real data shows up.
Wrapping up
You’ve gone well past the basics of objects:
- Bracket notation (
obj[key]) reads properties with a dynamic or unusual key; dot notation is the readable default for fixed keys. - Computed property names (
[key]: value) and shorthand ({ name }) make building objects concise. - A function stored on an object is a method, and
thisinside it points at the object it was called on — but arrow functions don’t bindthis, so use the shorthand method form. - Objects are held by reference — assigning one doesn’t copy it. Use spread (
{ ...obj }) orObject.assignfor a shallow copy, andstructuredClonefor a deep one. - Iterate with
Object.keys/values/entries, which return arrays you can use with array methods; prefer them overfor...in. - Check existence with
inorObject.hasOwn, reach into nested data safely with?.and??, and lock objects withObject.freezewhen needed.
Objects, together with the array methods from the last article, are the two halves of how JavaScript stores and shapes data. With references, copying, and iteration under your belt, you’re ready for the syntax that makes pulling values out of objects and arrays effortless — destructuring, which is up next.