056: Objects and Property Mechanics
Learning outcomes
By the end of this lesson, you can:
- model a product, user, or order with an object literal;
- read and write properties with dot and bracket notation;
- use methods and nested objects appropriately;
- explain object identity and shared references;
- safely handle a property that may not exist.
Retrieval warm-up
Before starting, retrieve three ideas from the earlier array work:
- Which method returns one matching array element or
undefined? - What does a predicate return conceptually?
- Does
toSorted()deeply clone object elements?
Answers: find(), truthy/falsy, and no. It creates a new outer array but keeps element references.
Vocabulary
- Object: A collection of keyed properties that represents composite data. — Source: MDN: Object
- Property: An association between a key and a value on an object. — Source: MDN: Working with objects
- Key: A String/Symbol identifier used to address a property. — Source: MDN: Property accessors
- Object literal: The
{ key: value }notation used to construct an object inline. — Source: MDN: Object initializer - Method: A property whose value is a function. — Source: MDN: Method definitions
- Nested object: An object stored as a property value inside another object. — Source: MDN: Working with objects
- Reference: Shared-pointer semantics: assignment copies an address, not the contents. — Source: MDN: Data structures
- Identity: Two references are equal only when they point to the same object (
===). — Source: MDN: Strict equality - Object (official): "An object is a collection of properties, where each property is a key-value pair." — Source: MDN: Object
- Property (official): "A property is a key (string or Symbol) associated with a value in an object." — Source: MDN: Working with objects
Beginner mental model
An array is a good fit for an ordered shelf of similar values. An object is a good fit for one record whose fields have labels. An array of products answers, "Which product is at position 2?" A product object answers, "What is this product's price?"
const product = {
id: "p3",
name: "Water Bottle",
price: 16,
inStock: true,
};
When the key is a known identifier-like name, use dot notation: product.price. Bracket notation is the right choice when the key is in a variable or cannot legally follow a dot:
const selectedField = "price";
console.log(product[selectedField]); // 16
const importedProduct = { "warehouse-code": "A-12" };
console.log(importedProduct["warehouse-code"]); // A-12
This is a common source of confusion: product.selectedField looks for a property literally named selectedField. It does not substitute the value held by the variable. Reading a property that is not present normally gives undefined.
Objects are reference values. const prevents reassigning the variable; it does not make the object immutable:
There are two separate questions here: can the variable be pointed at a different value, and can the current object be changed? const answers only the first question. That distinction is why a const object can still be a useful mutable record, while code that needs immutable updates must create a replacement object instead.
const item = { stock: 5 };
item.stock = 4; // allowed
// item = { stock: 4 }; // TypeError: assignment to constant variable
Two object literals with the same visible fields are still two different objects, so { id: 1 } === { id: 1 } is false. When you assign an object, JavaScript copies the reference rather than duplicating the object:
const original = { name: "Notebook", stock: 12 };
const alias = original;
alias.stock = 10;
console.log(original.stock); // 10
console.log(alias === original); // true
This matters in ordinary application code, not just in language puzzles. Product arrays, carts, and application state frequently contain several references to the same object.
When a state update unexpectedly appears in more than one place, identity is one of the first things to investigate. Look for assignments that copied a reference, rather than assuming that the values were independently copied. A debugger showing equal-looking records does not by itself tell you whether they are the same object.
Worked beginner example: an API-shaped order
Nested records are normal API data. Here, the order has a customer, shipping information, and line items, along with two pieces of behavior that belong naturally to the order.
const order = {
id: "ord-1042",
status: "processing",
customer: {
id: "u7",
name: "Maya",
email: "maya@example.com",
},
shipping: {
city: "Chennai",
method: "standard",
},
items: [
{ productId: "p1", name: "Notebook", price: 4, quantity: 3 },
{ productId: "p3", name: "Water Bottle", price: 16, quantity: 2 },
],
getItemCount() {
return this.items.reduce(
(count, item) => count + item.quantity,
0,
);
},
getSubtotal() {
return this.items.reduce(
(total, item) => total + item.price * item.quantity,
0,
);
},
};
console.log(order.id);
console.log(order.customer.name);
console.log(order["status"]);
console.log(order.getItemCount());
console.log(order.getSubtotal());
order.status = "packed";
console.log(order.status);
console.log(order.shipping.trackingCode);
Output:
ord-1042
Maya
processing
5
44
packed
undefined
Follow the shape instead of getting lost in the punctuation. order is one object. customer and shipping each refer to nested objects. items refers to an array, and each array element refers to a line-item object. The methods use method shorthand and this to read from the order that receives the call. Call them as order.getSubtotal() so the receiver, and therefore this, is order.
The missing trackingCode is also intentional. JavaScript does not invent a value for a property that is absent, so the read produces undefined. That result is different from a tracking code whose value is an empty string, and application code should decide which of those cases means "not available."
For ordinary product calculations, standalone functions are often easier to reuse and test than methods. A method is a good fit when the behavior clearly belongs to one record. Do not use an arrow function for a method that needs its own this; arrow functions do not bind this from the call site.
Dynamic fields and safe access
Imagine a table where the user can choose which product field to display. The field is data, so bracket notation is required, but external input should not be allowed to select every possible property:
function displayField(product, field) {
const allowedFields = ["name", "price", "stock"];
if (!allowedFields.includes(field)) {
return "Unsupported field";
}
return product[field] ?? "Not provided";
}
console.log(displayField({ name: "Lamp", stock: 0 }, "stock")); // 0
console.log(displayField({ name: "Lamp" }, "price")); // Not provided
The ?? is intentional. 0, false, and "" can all be valid values, so || would incorrectly replace them with the fallback. Restrict externally supplied keys instead of permitting arbitrary object access.
The allow-list also makes the function's contract visible. A caller can choose among the fields the UI supports, but cannot use the same lookup as an accidental way to inspect methods or unrelated inherited properties. In larger applications, this boundary belongs close to the point where untrusted input enters the program.
Optional chaining stops a property lookup when its base is null or undefined:
console.log(order.delivery?.estimatedDate ?? "No estimate yet");
That is a safe access operation, not a complete data validator. If delivery is a string when the program expects an object, the data shape is still invalid and needs to be handled at the appropriate boundary.
Intermediate example: model and summarize an order
When several plain objects should have the same shape, a factory function keeps their construction consistent. This example is still object-literal modeling; it is not yet a constructor or prototype lesson.
function createProduct(id, name, price, stock) {
return {
id,
name,
price,
stock,
isAvailable() {
return this.stock > 0;
},
};
}
const notebook = createProduct("p1", "Notebook", 4, 12);
const bottle = createProduct("p3", "Water Bottle", 16, 0);
console.log(notebook.isAvailable()); // true
console.log(bottle.isAvailable()); // false
const catalog = [notebook, bottle];
const cartLine = { product: notebook, quantity: 2 };
cartLine.product.stock -= 2;
console.log(notebook.stock); // 10
console.log(catalog[0].stock); // 10
The final two results are a deliberate demonstration of shared identity. notebook, catalog[0], and cartLine.product all refer to the same object. That can be useful when a single shared record is intended, but mutations then become harder to find and reason about. Later lessons favor returning updated records and arrays to make those changes more explicit.
The factory creates a fresh object on each call, so notebook and bottle are not aliases of one another. The aliasing begins when the already-created notebook object is placed into both the catalog and the cart line. That is the point to distinguish object creation from later reference assignment.
Interview focus: const, freezing, prototypes, and classes
const and Object.freeze() solve different problems. const user = ... prevents user = anotherUser; it does not prevent user.name = .... Object.freeze(user) prevents changes to the object's own properties in strict code, but freezing is shallow. A nested object remains mutable unless it is frozen separately.
const settings = Object.freeze({ theme: "light", nested: { enabled: true } });
// settings.theme = "dark"; // TypeError in strict/module code
settings.nested.enabled = false; // still possible: nested is not frozen
A prototype is another object that JavaScript consults after the object's own properties when a property is not found. This lets instances share behavior instead of storing a separate copy of each method on every instance. class provides clearer syntax for this prototype-based mechanism; it does not make JavaScript a class-only language.
class Product {
constructor(name, price) {
this.name = name;
this.price = price;
}
label() {
return `${this.name}: $${this.price}`;
}
}
const first = new Product("Notebook", 4);
const second = new Product("Bottle", 16);
console.log(first.label()); // Notebook: $4
console.log(first.label === second.label); // true
console.log(Object.getPrototypeOf(first) === Product.prototype); // true
Useful interview follow-ups are: is this an own property or an inherited one? Use Object.hasOwn(value, key) to check. Why is first.label === second.label true? Because the method is shared on the prototype. Conceptually, what does new do? It creates an object, links that object to the constructor's prototype, calls the constructor with the new object as this, and returns the object unless the constructor explicitly returns another object. A class constructor must be called with new.
This prototype behavior is an implementation detail you should be able to explain, not an instruction to reach for classes automatically. A plain object or factory is often the clearer design when the data does not need shared prototype behavior or a lifecycle enforced by a constructor.
Optional advanced extension
Computed property names let an expression determine a property key while an object literal is being created:
function addMetric(product, metricName, value) {
return {
product,
metrics: {
[metricName]: value,
},
};
}
console.log(addMetric(notebook, "views", 240));
// { product: { ... }, metrics: { views: 240 } }
Computed keys are useful for controlled dynamic data. They are not a reason to accept arbitrary untrusted keys into important objects.
Common mistakes and debugging
- Dot notation with a variable: use
object[key], notobject.key. - Misspelled/case-changed key:
product.Priceis different fromproduct.price. Inspect withObject.keys(product). - Reading too deeply:
order.delivery.citythrows ifdeliveryis missing. Validate or useorder.delivery?.citywhen absence is expected. - Assuming
constfreezes data: it fixes the binding only. Properties can still change. - Comparing by shape: distinct object literals are not strictly equal. Compare stable IDs when identity by record is intended.
- Unexpected alias mutation: test
a === band inspect every assignment that copied the object reference. - Detached
thismethod:const subtotal = order.getSubtotal; subtotal()loses its receiver. Prefer a standalone function when passing behavior around. - Arrow method with
this: use method shorthand for a receiver-based method.
Best practices
- Model one entity with one clear, consistently shaped object.
- Use stable IDs and compare
product.id, not entire object identity, for business matching. - Prefer dot notation for known keys and bracket notation for controlled dynamic keys.
- Treat
undefinedfrom a missing property deliberately. - Keep calculations pure and standalone unless method ownership adds clarity.
- Avoid deep or prototype-focused abstractions while a plain object solves the problem.
Checkpoint
Represent the order as boxes and arrows. Classify every value as a primitive, array reference, object reference, or function. Then assign const secondName = order.customer.name and const secondCustomer = order.customer; work out which later changes each variable can observe. The string is copied as a primitive value, while secondCustomer shares the nested object. Do this visual exercise before copying syntax so reference behavior is understood rather than merely memorized.
The boxes represent objects and arrays; the arrows represent references held by properties or variables. Primitive values such as strings sit in the boxes as values rather than as separately shared objects. Changing a property through one arrow therefore changes what every other arrow to that same object observes.
For debugging, draw the arrows at the moment a value is assigned. That small habit makes it easier to distinguish a stale copy from an intentional shared record.
Exercises
Core
Create a product object with id, name, price, and nested supplier.name. Print the name and supplier using dot notation.
const product = {
id: "p5",
name: "USB Cable",
price: 9,
supplier: {
name: "Wire Works",
},
};
console.log(product.name);
console.log(product.supplier.name);
Output:
USB Cable
Wire Works
Practice
Write getProductField(product, field) for only name, price, and stock. Return "Invalid field" otherwise, while preserving a valid 0 value.
function getProductField(product, field) {
const allowed = ["name", "price", "stock"];
if (!allowed.includes(field)) {
return "Invalid field";
}
return product[field] ?? "Missing value";
}
console.log(getProductField({ name: "Cable", stock: 0 }, "stock"));
console.log(getProductField({ name: "Cable" }, "rating"));
Output:
0
Invalid field
Professional Extension
Create an order object and a getOrderSummary(order) function that returns an object containing the customer name, item count, and subtotal. The function must not modify the order.
const sampleOrder = {
id: "ord-2",
customer: { name: "Ravi" },
items: [
{ name: "Notebook", price: 4, quantity: 2 },
{ name: "Backpack", price: 45, quantity: 1 },
],
};
function getOrderSummary(order) {
return {
customerName: order.customer.name,
itemCount: order.items.reduce(
(count, item) => count + item.quantity,
0,
),
subtotal: order.items.reduce(
(total, item) => total + item.price * item.quantity,
0,
),
};
}
console.log(getOrderSummary(sampleOrder));
Output:
{ customerName: "Ravi", itemCount: 3, subtotal: 53 }
Recap
When is bracket notation required? What does a missing property return? Why can properties on a const object change? Explain why changing alias.stock can change original.stock. Finally, sketch the data shape of an order that contains customer and item records.
If you can answer those questions and trace the prototype examples, you have the working model needed for the exercises: keys select properties, references preserve identity, and lookup can continue beyond the object itself.
Official references
- MDN: Working with objects
- MDN: Object initializer
- MDN: Property accessors
- MDN: Optional chaining
- ECMA-262: Object Initializer
- MDN: Inheritance and the prototype chain
- MDN: Classes
- MDN:
Object.freeze()
Prototype lookup in full
Every ordinary object has an internal [[Prototype]] link. When a property is requested, lookup checks the object itself first, then its prototype, then the next prototype in the chain. It stops when it finds the property or reaches null:
const animal = { eats: true };
const dog = Object.create(animal);
dog.name = "Ada";
console.log(dog.name); // own property: Ada
console.log(dog.eats); // inherited: true
console.log(dog.missing); // undefined after reaching null
console.log(Object.getPrototypeOf(dog) === animal); // true
console.log(Object.getPrototypeOf(animal) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype)); // null
An own property shadows one inherited from the prototype. Ordinary assignment writes an own property on the receiving object; it does not change the prototype's property:
Before the assignment, dog.eats succeeds because lookup continues from dog to animal. After the assignment, dog has its own eats property, so that own value wins and animal.eats remains unchanged. This is the same lookup rule behind many seemingly surprising inherited defaults.
dog.eats = false;
console.log(dog.eats, animal.eats); // false true
console.log(Object.hasOwn(dog, "eats")); // true
prototype and __proto__ refer to different parts of the model. A constructor function or class has a public prototype object used by its instances. An instance's __proto__ is a legacy accessor for its internal prototype link. Prefer Object.getPrototypeOf() and Object.setPrototypeOf() only when necessary.
function User(name) { this.name = name; }
User.prototype.greet = function () { return `Hi ${this.name}`; };
const user = new User("Maya");
console.log(user.__proto__ === User.prototype); // true, legacy spelling
console.log(Object.getPrototypeOf(user) === User.prototype); // true, preferred
console.log(user.greet()); // Hi Maya
Constructors initialize state owned by each instance; prototype methods are shared. A class expresses the same model with clearer syntax. In a derived class, super() initializes the parent portion of the instance and must run before the derived constructor uses this:
class Product {
constructor(name, price) {
this.name = name;
this.price = price;
}
label() { return `${this.name}: $${this.price}`; }
}
class SaleProduct extends Product {
constructor(name, price, percentOff) {
super(name, price);
this.percentOff = percentOff;
}
label() { return `${super.label()} (${this.percentOff}% off)`; }
}
const sale = new SaleProduct("Notebook", 4, 25);
console.log(sale.label()); // Notebook: $4 (25% off)
console.assert(Object.getPrototypeOf(sale) === SaleProduct.prototype);
console.assert(Object.getPrototypeOf(SaleProduct.prototype) === Product.prototype);
console.assert(sale instanceof Product && sale instanceof SaleProduct);
Check ownership deliberately. in walks the entire prototype chain. Object.hasOwn() checks only own properties. propertyIsEnumerable() goes one step further by rejecting own properties that are not enumerable:
const record = Object.create({ inherited: 1 });
record.own = 2;
Object.defineProperty(record, "hidden", { value: 3, enumerable: false });
console.assert("own" in record);
console.assert("inherited" in record);
console.assert(!Object.hasOwn(record, "inherited"));
console.assert(Object.hasOwn(record, "hidden"));
console.assert(!record.propertyIsEnumerable("hidden"));
Prototype pollution edge case
Prototype pollution is a security issue in which attacker-controlled keys modify a shared prototype. It commonly results from unsafe deep assignment or an unchecked merge. Do not merge arbitrary request keys into configuration, and do not use __proto__ as a path. Allow-list fields, use Object.hasOwn(), and choose Object.create(null) for a dictionary that does not need inherited behavior:
function applyPublicOptions(target, input) {
for (const key of ["theme", "pageSize"]) {
if (Object.hasOwn(input, key)) target[key] = input[key];
}
return target;
}
const options = applyPublicOptions({}, JSON.parse('{"__proto__":{"admin":true},"theme":"dark"}'));
console.assert(options.theme === "dark");
console.assert(({}).admin === undefined);
const counts = Object.create(null);
counts["toString"] = 1;
console.assert(counts.toString === 1);
Do not mistake an inherited default for a value supplied by the user. Security-sensitive input checks should usually use Object.hasOwn(input, key), not merely key in input.
The practical rule is to treat object keys from requests, files, or other external sources as untrusted data. A small explicit allow-list is easier to audit than a generic merge, and a null-prototype dictionary avoids inherited names when the data is meant to be a pure key-value table.
Interview questions and tests
- Trace
child.missing: which objects are checked, and where does lookup stop? - Why does
child.x = 2normally not changeparent.x? - Explain
User.prototype,Object.getPrototypeOf(user), anduser.__proto__. - Why must a derived constructor call
super()beforethis? - Which check detects inherited keys:
inorObject.hasOwn()? - How can an unchecked
__proto__key pollute future objects, and what input policy prevents it?
The answers should be explainable in terms of lookup order, ownership, and input boundaries. If a result surprises you, inspect the receiver first, then inspect its own keys and prototype. That sequence usually separates a missing property from an inherited value or a polluted object. It also gives you a concrete debugging path instead of relying on guesses about object contents.
