Skip to content
Chapter 3Lesson 1

The object as a record

Build, read, and reshape JavaScript objects, the record-shaped value at the center of a web app codebase.

Two bugs, one fix.

const config: Record<string, string> = { theme: 'dark', locale: 'en' };
const userInput = 'toString';
const value = config[userInput];
// value is [Function: toString] — the prototype chain leaked
// into application logic
const invoice = { id: 'inv_001', amountCents: 4900, status: 'pending' };
const patch = { id: 'inv_TYPO', status: 'paid' };
const updated = { ...invoice, ...patch };
// updated.id is 'inv_TYPO' — the patch silently overwrote the ID
// and the customer just got a new invoice number

The first reads a user-controlled key with bracket access, which reaches into Object.prototype. The second spreads a patch, and spread keeps the right-most value when keys collide, so the stray id wins. Choose the form whose behavior matches your intent and both disappear. This lesson sets a default for each of the three jobs every record needs: building it, reading it, and reshaping it.

Dot access is the default for reading a field: TypeScript checks it at compile time against the object’s known shape.

const invoice = { amountCents: 4900, status: 'pending' };
const amount = invoice.amountCents;
const fields: Record<string, number> = { amountCents: 4900 };
const value = fields['amountCents'];

Reach for bracket access only when one of three things is true:

  1. The key isn’t a valid identifier. style['background-color'] or payload['user.email'] — anything carrying a hyphen, dot, or other character an identifier can’t.
  2. The key is held in a variable, as in a helper that walks a record by a key passed in: obj[fieldName].
  3. The key is computed at runtime, built from data rather than written at the call site.

Otherwise, use the dot. It reads as intent, “this field, by name,” where the bracket signals an escape hatch, “this key, computed somehow.”

The two forms also type differently. Under noUncheckedIndexedAccess , dot access on a known shape returns T, but bracket access on a Record<string, T> returns T | undefined. That is correct: reading by a variable key, you can’t know it’s present, so the type forces you to handle its absence.

Building records: shorthand, computed keys, spread

Section titled “Building records: shorthand, computed keys, spread”

Three sugars cover how you build object literals: shorthand, computed keys, and spread.

const name = 'Lina Park';
const email = 'lina@acme.test';
const customer = { name, email };

Same-name fields collapse. When a variable’s name matches the field name, write it once. This builds most literals from local variables.

That “right-most key wins” rule is what caused the second bug in the opener. Spread copies one level deep, and the last key to appear claims the slot, whichever side it came from. The writer of { ...invoice, ...patch } meant “apply the patch,” but patch carried an unexpected id, and the merge honored it. The fix is not the rule: spread only the fields you mean to override, or validate the patch shape at the boundary so a stray id never reaches the merge.

Three presence checks: in, Object.hasOwn, ??

Section titled “Three presence checks: in, Object.hasOwn, ??”

Three checks look alike but ask different questions: is the key present at all, is it the object’s own key rather than an inherited one, and is there a usable value behind it?

const invoice = { amountCents: 4900, status: 'pending' };
'amountCents' in invoice; // true — own key
'toString' in invoice; // true — inherited from Object.prototype

Walks the prototype chain. It returns true for inherited keys too, so it is almost always the wrong check. Recognize it in older code; do not write it fresh.

'toString' in invoice returning true is the opener’s config[userInput] bug again: a check meant for own properties reaches into the prototype chain. Object.hasOwn is what in should have been. Whenever the key could be user-controlled, reach for it.

The Object constructor has static methods for everyday reshape jobs: iterating, building from pairs, merging, freezing, and grouping. Together they cover what people once pulled in lodash for.

Object.keys, Object.values, Object.entries: the iteration triad

Section titled “Object.keys, Object.values, Object.entries: the iteration triad”

Object.keys(obj) returns the object’s own enumerable string keys, Object.values(obj) the values, and Object.entries(obj) the [key, value] pairs. All three return arrays in insertion order, so you can for...of over an object or .map over its pairs.

const invoice = { amountCents: 4900, status: 'pending' };
const keys = Object.keys(invoice);

The string[] result surprises people who expected keyof T. The cause is structural typing : any object with at least amountCents and status fits the type, extra keys included, so TypeScript can’t prove the runtime keys match the declared ones and returns the safe answer. When you need the narrower type at a trusted boundary, usually right after parsing input you control, an assertion is the escape hatch, and using it makes proving that the type holds your job.

Object.fromEntries: build an object from pairs

Section titled “Object.fromEntries: build an object from pairs”

Object.fromEntries takes an iterable of [key, value] pairs and builds an object. Its most common use is the round trip with Object.entries.

const customer = { name: 'Lina Park', email: 'lina@acme.test' };
const pairs = Object.entries(customer);
const upperPairs = pairs.map(([key, value]) => [key.toUpperCase(), value]);
const upperCustomer = Object.fromEntries(upperPairs);

Break the object into [key, value] pairs.

const customer = { name: 'Lina Park', email: 'lina@acme.test' };
const pairs = Object.entries(customer);
const upperPairs = pairs.map(([key, value]) => [key.toUpperCase(), value]);
const upperCustomer = Object.fromEntries(upperPairs);

Transform each pair with any array operation.

const customer = { name: 'Lina Park', email: 'lina@acme.test' };
const pairs = Object.entries(customer);
const upperPairs = pairs.map(([key, value]) => [key.toUpperCase(), value]);
const upperCustomer = Object.fromEntries(upperPairs);

Build the object back. This entries → transform → fromEntries round trip is the clean shape for any reshape you would otherwise hand-roll with .reduce.

1 / 1

It replaces the old arr.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}) form: it reads as named steps, and it is linear, where the reduce rebuilds the accumulator every iteration and runs in O(n²).

Object.fromEntries also turns pair-shaped data into an object: a Map into a plain object for a JSON response, or a URLSearchParams into a flat object of query values.

Object.assign: mutates, returns the target

Section titled “Object.assign: mutates, returns the target”

Object.assign(target, ...sources) copies the source objects’ own enumerable properties onto target and returns that same object, not a copy.

const config = { theme: 'dark', locale: 'en' };
Object.assign(config, { locale: 'es' });
// config is now { theme: 'dark', locale: 'es' }

That mutation is the catch, so for a non-mutating merge use the spread form ({ ...config, locale: 'es' }). Object.assign earns its place only when the mutation is the point: patching a config object the caller already owns, or applying defaults to an options argument inside a function body.

Object.freeze, Object.isFrozen: runtime immutability

Section titled “Object.freeze, Object.isFrozen: runtime immutability”

Object.freeze(obj) makes an object read-only at runtime: writes silently fail in non-strict mode and throw in strict mode, and Object.isFrozen(obj) is the matching check. Prefer TypeScript’s readonly modifier, which enforces immutability at design time; reach for Object.freeze only when the guarantee must hold at runtime, such as a shared constant a caller might try to mutate.

Object.groupBy: bucket rows by a string key

Section titled “Object.groupBy: bucket rows by a string key”

Object.groupBy (ES2024) takes an array and a callback returning a string key, and returns an object that buckets the items by that key.

const invoices = [
{ id: 'inv_001', amountCents: 4900, status: 'paid' },
{ id: 'inv_002', amountCents: 1200, status: 'pending' },
{ id: 'inv_003', amountCents: 9900, status: 'paid' },
{ id: 'inv_004', amountCents: 3300, status: 'pending' },
];
const byStatus = Object.groupBy(invoices, (invoice) => invoice.status);

byStatus keys are the status values ('paid', 'pending'); each value is the array of matching invoices. Reach for it whenever you group rows by a categorical field.

const byStatus = Object.groupBy(invoices, (invoice) => invoice.status);

The catch is the | undefined on every group, so read each one through a fallback: const paid = byStatus.paid ?? [];. To group by a non-string key, reach for Map.groupBy instead.

The prototype chain and Object.create(null)

Section titled “The prototype chain and Object.create(null)”

Every object literal inherits from Object.prototype, so .toString, .hasOwnProperty, and .constructor resolve through it even though you never defined them. That chain is what made config[userInput] return [Function: toString] in the opener: a user-controlled key reached an inherited member through bracket access. So never trust the chain through user-controlled keys. When any accidental prototype hit would be a bug, such as a lookup table keyed by user input, reach for Object.create(null) to get an object with no chain at all:

const safeMap = Object.create(null);
safeMap[userInput] = 'value';
// safeMap.toString is undefined — no prototype chain to leak

The reflective tools (Object.getPrototypeOf, Object.defineProperty, __proto__) are out of scope.

Match each intent to the access form that says what the code means. Drag each item into the bucket it belongs to, then press Check.

Dot access Known field of a typed shape
Bracket access Dynamic or non-identifier key
Object.hasOwn Exclude inherited keys
?? fallback Is there a value here
Read the amountCents field of an Invoice you just queried.
Read a field whose name comes from a fieldName variable in a generic helper.
Check whether a key is present in a parsed JSON payload (must not return true for inherited keys like toString).
Read a CSS property name like 'background-color' off a style object.
Return the user’s locale or 'en' if it’s missing.
Look up a value in a Record<string, number> and handle the missing case.
Confirm a user-supplied key isn’t shadowing an Object.prototype method.
Read customer.email to send a receipt.