Skip to main content
JavaScript · lesson 10 of 24
JavaScript beginner Lesson 10 of 24

Objects in JavaScript

Learn JavaScript objects — literals, destructuring, spread/rest, Object utility methods, symbols, and patterns like config merging.

By SkillByExample · Updated

An object is a collection of key–value pairs, and almost everything in JavaScript that isn’t a primitive is one. This lesson covers creating, reading, copying and merging them. Every example is a complete file run with node.

Object Literals

Create an object with { }; read with dot notation for fixed names and brackets for dynamic ones.

const user = { id: 1, name: "Alice", age: 30, active: true, "favourite-colour": "teal" };

console.log(user.name, user["age"]);
console.log(user["favourite-colour"]);

const key = "id";
console.log(user[key], user.key);

user.email = "alice@example.com";
user.age = 31;
delete user.active;
console.log(user);
console.log(user.phone);
$ node literal.js
Alice 30
teal
1 undefined
{
  id: 1,
  name: 'Alice',
  age: 31,
  'favourite-colour': 'teal',
  email: 'alice@example.com'
}
undefined

user[key] looked up the property named by the variable ("id"); user.key looked for a property literally called key and found nothing. A missing property is undefined, not an error. Objects are mutable even when held in a const — const stops you reassigning user, not changing what’s inside it.

Shorthand: Properties, Methods, Computed Keys

Three pieces of literal syntax you’ll see everywhere.

const name = "Bob";
const age = 25;
const field = "email";
const index = 2;

const person = {
  name,
  age,
  [field]: "bob@example.com",
  [`item_${index}`]: "banana",
  greet() { return `Hi, I'm ${this.name}`; },
};

console.log(person);
console.log(person.greet());

const calculator = {
  value: 0,
  add(n) { this.value += n; return this; },
  subtract(n) { this.value -= n; return this; },
};
console.log(calculator.add(10).add(5).subtract(3).value);
$ node shorthand.js
{
  name: 'Bob',
  age: 25,
  email: 'bob@example.com',
  item_2: 'banana',
  greet: [Function: greet]
}
Hi, I'm Bob
12

{ name, age } means { name: name, age: age }. Square brackets in a key position evaluate an expression, so the keys became email and item_2. Node prints methods as [Function: greet]. Returning this from each method is what makes the calculator chainable.

Destructuring

Destructuring unpacks properties into variables — with renaming, defaults and nesting.

const product = { id: 42, name: "Laptop", price: 999, discount: undefined, meta: { sku: "LP-1" } };

const { id, name, price } = product;
console.log(id, name, price);

const { name: productName, price: cost } = product;
console.log(productName, cost);

const { discount = 0, category = "general", price: p = 1 } = product;
console.log(discount, category, p);

const { meta: { sku }, meta } = product;
console.log(sku, meta);

function formatAddress({ street, city, country = "US", zip }) {
  return `${street}, ${city}, ${country} ${zip}`;
}
console.log(formatAddress({ street: "123 Main St", city: "Austin", zip: "73301" }));
$ node destructure.js
42 Laptop 999
Laptop 999
0 general 999
LP-1 { sku: 'LP-1' }
123 Main St, Austin, US 73301

{ name: productName } reads “take name, call it productName” — the colon renames, it isn’t a type. Defaults apply only when the value is undefined: discount exists but is undefined, so it got 0; price exists, so the default 1 was ignored. Destructuring a function’s parameter documents which options it reads.

Spread and Rest

Spread (...obj inside a literal) copies properties into a new object, later keys winning; rest (...name in a pattern) collects whatever wasn’t named.

const defaults = { theme: "light", fontSize: 14, language: "en" };
const userPrefs = { fontSize: 16, language: "fr" };

const config = { ...defaults, ...userPrefs };
const updated = { ...config, theme: "dark" };
console.log(config);
console.log(updated, config.theme);

const { id, password, ...publicFields } = { id: 1, password: "hunter2", name: "Alice", city: "London" };
console.log(publicFields);

const wrongOrder = { theme: "dark", ...defaults };
console.log(wrongOrder.theme);
$ node spread.js
{ theme: 'light', fontSize: 16, language: 'fr' }
{ theme: 'dark', fontSize: 16, language: 'fr' } light
{ name: 'Alice', city: 'London' }
light

Order is everything: { ...defaults, ...userPrefs } lets the user win, and { theme: "dark", ...defaults } silently loses the override because defaults came later. Rest is the clean way to drop fields — here, stripping password before sending a user object anywhere — without mutating the original.

keys, values, entries, fromEntries

These convert an object to arrays so you can use map, filter and reduce, and back.

const scores = { alice: 90, bob: 78, carol: 95 };

console.log(Object.keys(scores));
console.log(Object.values(scores));
console.log(Object.entries(scores));

const total = Object.values(scores).reduce((sum, n) => sum + n, 0);
console.log("total", total, "average", (total / Object.keys(scores).length).toFixed(1));

const curved = Object.fromEntries(
  Object.entries(scores).map(([name, score]) => [name, Math.min(score + 5, 100)]),
);
console.log(curved);

const passed = Object.fromEntries(Object.entries(scores).filter(([, s]) => s >= 80));
console.log(passed);
$ node entries.js
[ 'alice', 'bob', 'carol' ]
[ 90, 78, 95 ]
[ [ 'alice', 90 ], [ 'bob', 78 ], [ 'carol', 95 ] ]
total 263 average 87.7
{ alice: 95, bob: 83, carol: 100 }
{ alice: 90, carol: 95 }

entries → array transform → fromEntries is the general recipe for “map/filter over an object”. In ([, s]) => … the leading comma skips the key.

Key Order

Property order is defined, but it isn’t simply insertion order.

const obj = { b: 1, 10: "ten", a: 2, 2: "two", "-1": "minus one", 1.5: "x" };
console.log(Object.keys(obj));

const byId = Object.fromEntries([[30, "Carol"], [4, "Alice"], [12, "Bob"]]);
console.log(Object.values(byId));
$ node order.js
[ '2', '10', 'b', 'a', '-1', '1.5' ]
[ 'Alice', 'Bob', 'Carol' ]

Keys that look like non-negative integers ("2", "10") come first in ascending numeric order; every other string key follows in insertion order — "-1" and "1.5" count as ordinary strings. So an object keyed by numeric id “re-sorts” itself. If order matters, use an array or a Map, which keeps pure insertion order.

Object.assign, freeze and seal

Object.assign copies into (and mutates) its first argument. freeze and seal lock an object down — shallowly.

"use strict";
const target = { a: 1, b: 2 };
const result = Object.assign(target, { b: 3, c: 4 });
console.log(target, result === target);

const CONFIG = Object.freeze({ timeout: 5000, db: { host: "localhost" } });
try { CONFIG.timeout = 9999; } catch (err) { console.log(err.constructor.name + ":", err.message); }
CONFIG.db.host = "prod-db";
console.log(CONFIG);

const sealed = Object.seal({ x: 1 });
sealed.x = 2;
try { sealed.y = 3; } catch (err) { console.log(err.message); }
console.log(sealed, Object.isFrozen(CONFIG), Object.isSealed(sealed));
$ node freeze.js
{ a: 1, b: 3, c: 4 } true
TypeError: Cannot assign to read only property 'timeout' of object '#<Object>'
{ timeout: 5000, db: { host: 'prod-db' } }
Cannot add property y, object is not extensible
{ x: 2 } true true

In strict mode ("use strict", and always inside classes and ES modules), writing to a frozen object throws; in a plain script without it, the write is silently ignored — easy to miss. Either way freeze is shallow: CONFIG.db is a separate, unfrozen object, so its host changed. seal allows changing existing values but not adding or removing keys.

Symbol Keys

A Symbol key can never collide with a string key and is skipped by Object.keys, for...in and JSON.stringify.

const ID = Symbol("id");
const record = { name: "Alice", [ID]: 1001 };

console.log(record[ID]);
console.log(Object.keys(record), JSON.stringify(record));
for (const k in record) console.log("for...in sees", k);
console.log(Object.getOwnPropertySymbols(record));
console.log(record);
$ node symbols.js
1001
[ 'name' ] {"name":"Alice"}
for...in sees name
[ Symbol(id) ]
{ name: 'Alice', [Symbol(id)]: 1001 }

Symbols are hidden from normal enumeration but not secret: Object.getOwnPropertySymbols lists them and console.log shows them. They’re for avoiding name clashes — a library can tag your objects without risking overwriting your id — not for privacy; use closures or #private fields for that.

Shallow vs. Deep Copies

Spread and Object.assign copy only the top level; nested objects are shared.

const original = {
  name: "Alice",
  address: { city: "London" },
  joined: new Date("2024-01-15"),
  tags: new Set(["admin"]),
  greet() { return "hi"; },
};

const shallow = { ...original };
shallow.address.city = "Paris";
console.log("original city after shallow copy edit:", original.address.city);

const viaJson = JSON.parse(JSON.stringify(original));
console.log("JSON:", viaJson);

const { greet, ...data } = original;
const cloned = structuredClone(data);
cloned.address.city = "Berlin";
console.log("structuredClone:", cloned.joined instanceof Date, cloned.tags, original.address.city);
try { structuredClone(original); } catch (err) { console.log(err.name + ":", err.message); }
$ node clone.js
original city after shallow copy edit: Paris
JSON: {
  name: 'Alice',
  address: { city: 'Paris' },
  joined: '2024-01-15T00:00:00.000Z',
  tags: {}
}
structuredClone: true Set(1) { 'admin' } Paris
DataCloneError: greet() { return "hi"; } could not be cloned.

The shallow copy shared address, so editing it changed the original too. The JSON round-trip deep-copies but mangles types — the Date became a string, the Set became {}, and the method vanished. structuredClone keeps Date, Set, Map and nested objects intact (the original stayed in Paris), but refuses objects containing functions — hence greet was split off first.

Checking Whether a Key Exists

A truthiness check can’t tell “missing” from “present but falsy”.

const config = { debug: false, port: 0, name: "" };

for (const key of ["debug", "port", "name", "missing", "toString"]) {
  console.log(
    key.padEnd(8),
    "truthy:", String(Boolean(config[key])).padEnd(5),
    "in:", String(key in config).padEnd(5),
    "hasOwn:", Object.hasOwn(config, key),
  );
}
$ node exists.js
debug    truthy: false in: true  hasOwn: true
port     truthy: false in: true  hasOwn: true
name     truthy: false in: true  hasOwn: true
missing  truthy: false in: false hasOwn: false
toString truthy: true  in: true  hasOwn: false

All three real keys look “missing” to the truthy test because false, 0 and "" are falsy. in finds them — but also finds toString, which is inherited from Object.prototype. Object.hasOwn answers the question you usually mean: does this object have the key?

this in Arrow Functions vs. Methods

Arrow functions don’t get their own this, so they make poor methods.

const counter = {
  value: 42,
  getArrow: () => typeof this === "object" ? JSON.stringify(this) : String(this),
  getMethod() { return this.value; },
  getLater() { return [1, 2].map(() => this.value); },
};

console.log(counter.getArrow());
console.log(counter.getMethod());
console.log(counter.getLater());
$ node this.js
{}
42
[ 42, 42 ]

getArrow’s this is whatever this was outside the object literal — in a CommonJS file that’s the empty module.exports object, {}, and in a browser script it would be window; never counter. That same property is what makes arrows useful inside methods: in getLater, the arrow passed to map inherits the method’s this, so it reads counter.value.

Key Takeaways

  • Dot notation for fixed keys, brackets for dynamic ones; missing properties are undefined.
  • Destructuring renames with : and defaults only on undefined.
  • In spreads, later keys win — put defaults first.
  • Integer-like keys are ordered numerically before other keys.
  • Spread copies are shallow; use structuredClone for deep copies of data.
  • Use Object.hasOwn to test for a key, not truthiness.

Practice

1. Write pick(obj, ...keys) that returns a new object with only the given keys.
function pick(obj, ...keys) {
  return Object.fromEntries(keys.filter((k) => Object.hasOwn(obj, k)).map((k) => [k, obj[k]]));
}

const user = { id: 1, name: "Alice", password: "hunter2", email: "a@example.com" };
console.log(pick(user, "id", "email"));
console.log(pick(user, "name", "nope"));
$ node p1.js
{ id: 1, email: 'a@example.com' }
{ name: 'Alice' }

Filtering with hasOwn first means asking for a missing key doesn’t create a nope: undefined entry.

2. Build a lookup of users by id and fetch one without scanning the array.
const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
  { id: 3, name: "Carol" },
];
const byId = Object.fromEntries(users.map((u) => [u.id, u]));
console.log(byId[2].name);
console.log(Object.keys(byId));
$ node p2.js
Bob
[ '1', '2', '3' ]

The keys became strings ("1", "2", "3") — object keys always are — but byId[2] still works because the number is converted to "2" on lookup.

3. Merge user options into defaults so that nested headers are merged too, not replaced.
const defaults = { timeout: 5000, headers: { "Content-Type": "application/json" } };

function createClient(options = {}) {
  return { ...defaults, ...options, headers: { ...defaults.headers, ...options.headers } };
}

console.log(createClient({ timeout: 10000, headers: { Authorization: "Bearer abc" } }));
console.log({ ...defaults, ...{ headers: { Authorization: "Bearer abc" } } });
$ node p3.js
{
  timeout: 10000,
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer abc' }
}
{ timeout: 5000, headers: { Authorization: 'Bearer abc' } }

The second line is the bug this fixes: a plain spread replaced the whole headers object and lost Content-Type.

4. Count how many times each word appears in a sentence, as an object.
const sentence = "the cat and the hat and the bat";
const counts = {};
for (const word of sentence.split(" ")) {
  counts[word] = (counts[word] ?? 0) + 1;
}
console.log(counts);
const [top] = Object.entries(counts).sort((a, b) => b[1] - a[1]);
console.log("most common:", top);
$ node p4.js
{ the: 3, cat: 1, and: 2, hat: 1, bat: 1 }
most common: [ 'the', 3 ]

counts[word] ?? 0 supplies 0 the first time a word is seen; sorting the entries by count gives the winner.

Frequently Asked Questions

What is the difference between Object.assign and the spread operator for merging objects?
Both perform a shallow merge. Object.assign mutates the first argument (the target) and returns it. The spread operator always creates a new object. Prefer spread for immutable patterns; use Object.assign when you intentionally want to mutate a target.
Does spreading an object create a deep clone?
No. Spread only copies the top-level properties. Nested objects are still shared by reference. For a deep clone use structuredClone() (modern browsers/Node 17+) or JSON.parse(JSON.stringify(obj)) for plain data.
What are computed property keys?
Computed property keys let you use an expression inside square brackets as the key name when defining an object literal, e.g. { [variableName]: value }. This is handy when the key is dynamic.