JavaScript Objects - Exercise 3
Every JavaScript object has an internal link to another object called its prototype. When you access a property, JavaScript first looks at the object itself, and if it doesn't find it there, it walks up the prototype chain - checking each prototype in turn - until it finds the property or reaches null. This is how all objects share methods from Object.prototype (like toString and hasOwnProperty) without each object carrying its own copy.
const animal = {
breathe() { return 'breathing'; },
eat() { return 'eating'; }
};
const dog = Object.create(animal); // dog's prototype is animal
dog.bark = function() { return 'woof!'; };
console.log(dog.bark()); // 'woof!' -- own property
console.log(dog.breathe()); // 'breathing' -- from prototype
console.log(dog.eat()); // 'eating' -- from prototype
// Walk the chain
console.log(Object.getPrototypeOf(dog) === animal); // true
// hasOwnProperty distinguishes own vs inherited
console.log(dog.hasOwnProperty('bark')); // true
console.log(dog.hasOwnProperty('breathe')); // false -- inherited
Object.create(proto) creates a new object whose prototype is set to whatever you pass in. Unlike {} which always inherits from Object.prototype, you have full control over what sits in the prototype chain. You can pass null to create a completely bare object with no prototype at all - useful when you need a pure dictionary with no inherited methods. An optional second argument lets you define property descriptors at creation time.
const vehicleProto = {
describe() {
return this.brand + ' going at ' + this.speed + ' km/h';
},
accelerate(amount) {
this.speed += amount;
}
};
const car = Object.create(vehicleProto);
car.brand = 'Tesla';
car.speed = 0;
car.accelerate(100);
console.log(car.describe()); // 'Tesla going at 100 km/h'
// Null prototype -- no inherited Object methods
const pureDict = Object.create(null);
pureDict.key = 'value';
console.log(pureDict.toString); // undefined -- no Object.prototype
// Second argument: property descriptors
const point = Object.create(Object.prototype, {
x: { value: 10, writable: true, enumerable: true, configurable: true },
y: { value: 20, writable: true, enumerable: true, configurable: true }
});
console.log(point.x, point.y); // 10 20
Every property in a JavaScript object has a property descriptor - a set of attributes that control how that property behaves. The three key flags are: writable (can the value be changed?), enumerable (does it show up in loops and Object.keys()?), and configurable (can the descriptor itself be changed or the property deleted?). Use Object.defineProperty() to set these explicitly and gain fine-grained control over your object's properties.
const config = {};
Object.defineProperty(config, 'MAX_RETRIES', {
value: 3,
writable: false, // cannot be changed
enumerable: true, // shows up in Object.keys()
configurable: false // cannot be deleted or redefined
});
console.log(config.MAX_RETRIES); // 3
config.MAX_RETRIES = 99; // silently fails (or throws in strict mode)
console.log(config.MAX_RETRIES); // still 3
// Read the descriptor
const desc = Object.getOwnPropertyDescriptor(config, 'MAX_RETRIES');
console.log(desc);
// { value: 3, writable: false, enumerable: true, configurable: false }
// Non-enumerable property -- hidden from loops
Object.defineProperty(config, '_secret', {
value: 'hidden',
enumerable: false
});
console.log(Object.keys(config)); // ['MAX_RETRIES'] -- _secret not shown
console.log(config._secret); // 'hidden' -- still readable directly
Method chaining lets you call multiple methods on an object in one expression, stringing them together with dots. The trick is simple: every method in the chain must return this - returning the object itself so the next call has something to work on. It makes code read like a sentence and avoids storing intermediate results in variables. You'll see this pattern in query builders, formatters, and fluent APIs all over the place.
const query = {
_table: '',
_conditions: [],
_limit: null,
from(table) {
this._table = table;
return this; // return this to enable chaining
},
where(condition) {
this._conditions.push(condition);
return this;
},
limit(n) {
this._limit = n;
return this;
},
build() {
let sql = 'SELECT * FROM ' + this._table;
if (this._conditions.length) {
sql += ' WHERE ' + this._conditions.join(' AND ');
}
if (this._limit) sql += ' LIMIT ' + this._limit;
return sql;
}
};
const sql = query
.from('users')
.where('age > 18')
.where('active = true')
.limit(10)
.build();
console.log(sql);
// SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10
A shallow copy creates a new object with the same top-level properties, but nested objects are still shared by reference - both the original and the copy point to the same nested object. A deep copy recursively copies everything, so the original and the copy are completely independent. The simplest deep copy technique is JSON.parse(JSON.stringify(obj)), though it drops functions and undefined. The modern, recommended solution is structuredClone().
const original = {
name: 'Leo',
scores: [10, 20, 30],
address: { city: 'Paris' }
};
// Shallow copy -- nested objects are shared
const shallow = { ...original };
shallow.scores.push(40); // mutates original.scores too!
shallow.address.city = 'Berlin'; // mutates original.address.city too!
console.log(original.scores); // [10, 20, 30, 40] -- changed!
console.log(original.address.city); // 'Berlin' -- changed!
// Deep copy with structuredClone (modern, recommended)
const deep = structuredClone(original);
deep.scores.push(99);
deep.address.city = 'Rome';
console.log(original.scores); // [10, 20, 30, 40] -- unchanged
console.log(original.address.city); // 'Berlin' -- unchanged
// JSON method -- simpler but lossy (drops functions, undefined, Date)
const jsonCopy = JSON.parse(JSON.stringify({ a: 1, b: { c: 2 } }));
console.log(jsonCopy); // { a: 1, b: { c: 2 } } -- independent copy
JavaScript plain objects don't have built-in private properties, but a few patterns approximate the concept. The classic approach is a closure - keep the private data as a variable in the enclosing function scope, accessible only to the returned object's methods. Another modern option is using a WeakMap to store private data keyed by the object instance. The underscore prefix (_name) is just a naming convention - it doesn't enforce anything technically.
// Pattern 1: Closure -- data lives in function scope
function createBankAccount(initialBalance) {
let balance = initialBalance; // private -- not on the object
return {
deposit(amount) {
if (amount > 0) balance += amount;
return this;
},
withdraw(amount) {
if (amount > balance) throw new Error('Insufficient funds');
balance -= amount;
return this;
},
getBalance() { return balance; }
};
}
const account = createBankAccount(100);
account.deposit(50).withdraw(30);
console.log(account.getBalance()); // 120
console.log(account.balance); // undefined -- truly private
// Pattern 2: WeakMap
const _data = new WeakMap();
function createPerson(name, age) {
const person = {};
_data.set(person, { name, age });
person.greet = function() {
const d = _data.get(this);
return 'Hi, I am ' + d.name + ' and I am ' + d.age + '.';
};
return person;
}
const p = createPerson('Nina', 25);
console.log(p.greet()); // 'Hi, I am Nina and I am 25.'
console.log(p.name); // undefined -- private via WeakMap
Symbol is a primitive type that produces a guaranteed unique value every time you call Symbol(). When used as an object key, Symbol properties are invisible to for...in, Object.keys(), and JSON.stringify(). You need Object.getOwnPropertySymbols() to retrieve them. This makes Symbols handy for attaching metadata or internal state to objects without risking collisions with string keys or accidentally exposing them.
const id = Symbol('id');
const token = Symbol('token');
const user = {
name: 'Oscar',
[id]: 42,
[token]: 'abc-xyz-secret'
};
// Normal access works fine
console.log(user[id]); // 42
console.log(user[token]); // 'abc-xyz-secret'
// Symbol keys are hidden from standard enumeration
console.log(Object.keys(user)); // ['name'] -- no symbols
console.log(JSON.stringify(user)); // '{"name":"Oscar"}' -- symbols dropped
// Retrieve Symbol keys specifically
const syms = Object.getOwnPropertySymbols(user);
console.log(syms); // [Symbol(id), Symbol(token)]
console.log(user[syms[0]]); // 42
// Every Symbol() call produces a unique key
const a = Symbol('key');
const b = Symbol('key');
console.log(a === b); // false -- always unique
A Proxy wraps an object and lets you intercept and customise fundamental operations - reading properties, writing values, calling functions, checking property existence, and more. You define a handler object with traps (methods like get, set, has) that run whenever those operations happen on the proxy. This is the foundation for reactive systems, validation layers, logging, and even some ORM query builders.
const handler = {
get(target, key) {
console.log('Reading: ' + String(key));
return key in target ? target[key] : 'Property not found';
},
set(target, key, value) {
if (typeof value !== 'number') {
throw new TypeError('Only numbers allowed for: ' + String(key));
}
target[key] = value;
return true; // must return true to indicate success
}
};
const stats = new Proxy({}, handler);
stats.score = 95; // set trap fires
console.log(stats.score); // get trap fires -- 95
console.log(stats.rank); // get trap fires -- 'Property not found'
try {
stats.name = 'Alice'; // TypeError: Only numbers allowed for: name
} catch (e) {
console.log(e.message);
}
// Validation example
function createValidator(target, rules) {
return new Proxy(target, {
set(obj, key, value) {
if (rules[key] && !rules[key](value)) {
throw new RangeError('Invalid value for ' + String(key));
}
obj[key] = value;
return true;
}
});
}
const person = createValidator({}, { age: v => v >= 0 && v <= 120 });
person.age = 25; // fine
person.name = 'Bob'; // no rule -- fine
try {
person.age = -5; // RangeError!
} catch (e) {
console.log(e.message); // 'Invalid value for age'
}
A common real-world task is turning a flat object with dot-separated keys (like those you get from environment configs or form data) into a proper nested object. The approach is to split each key on the dot, then walk the target object creating any intermediate objects that don't exist yet, and finally assign the value at the last segment. Object.entries() and reduce make this clean to implement.
function unflatten(flat) {
return Object.entries(flat).reduce((result, [path, value]) => {
const keys = path.split('.');
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {};
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
return result;
}, {});
}
const flat = {
'server.host': 'localhost',
'server.port': 3000,
'db.name': 'mydb',
'db.credentials.user': 'admin',
'db.credentials.pass': 'secret'
};
const nested = unflatten(flat);
console.log(nested.server.host); // 'localhost'
console.log(nested.server.port); // 3000
console.log(nested.db.credentials.user); // 'admin'
// Reverse: flatten a nested object back down
function flatten(obj, prefix = '') {
return Object.entries(obj).reduce((acc, [key, val]) => {
const fullKey = prefix ? prefix + '.' + key : key;
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
Object.assign(acc, flatten(val, fullKey));
} else {
acc[fullKey] = val;
}
return acc;
}, {});
}
console.log(flatten(nested));
// { 'server.host': 'localhost', 'server.port': 3000, ... }
Memoization is an optimisation technique where you cache the result of an expensive function call so that repeated calls with the same arguments skip the computation and return the cached result instantly. A plain object (or a Map for complex keys) works perfectly as the cache store. You wrap the original function, check if the result for the given arguments already exists in the cache, and only call the original function if it doesn't.
function memoize(fn) {
const cache = {};
return function(...args) {
const key = JSON.stringify(args);
if (key in cache) {
console.log('Cache hit for:', key);
return cache[key];
}
console.log('Computing for:', key);
const result = fn.apply(this, args);
cache[key] = result;
return result;
};
}
// Expensive Fibonacci without memoization would be O(2^n)
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
const fastFib = memoize(function(n) {
if (n <= 1) return n;
return fastFib(n - 1) + fastFib(n - 2);
});
console.log(fastFib(10)); // Computing each value once, then cache hits
console.log(fastFib(10)); // Cache hit -- instant
// Works for any pure function
const memoSquare = memoize(x => x * x);
console.log(memoSquare(5)); // 25 -- computed
console.log(memoSquare(5)); // 25 -- from cache
console.log(memoSquare(6)); // 36 -- computed