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 logicconst 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 numberThe 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.
Reading a field: dot access or brackets
Section titled “Reading a field: dot access or brackets”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:
- The key isn’t a valid identifier.
style['background-color']orpayload['user.email']— anything carrying a hyphen, dot, or other character an identifier can’t. - The key is held in a variable, as in a helper that walks a record by a key passed in:
obj[fieldName]. - 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.
const fieldName = 'amountCents';const value = 4900;
const patch = { [fieldName]: value };Bracket on the left for a dynamic key. Most keys are known as you write the literal, so this is rare outside factory helpers and reducers. The three triggers for bracket reads apply here too, now at the point of construction.
const base = { id: 'cus_001', name: 'Lina Park', plan: 'free' };const updated = { ...base, plan: 'pro' };Shallow merge, right-most key wins. State setters and update payloads lean on this form. Because plan: 'pro' sits after ...base, it overrides the base plan; reverse the order and the override loses.
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.prototypeWalks 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.
const invoice = { amountCents: 4900, status: 'pending' };
Object.hasOwn(invoice, 'amountCents'); // trueObject.hasOwn(invoice, 'toString'); // false — inherited, not ownOwn keys only. The right check when you ask “is this key on the object itself, ignoring the prototype chain.” That comes up reading user-controlled keys from parsed JSON, where an attacker could plant toString in the input.
const invoice = { amountCents: 4900, status: 'pending' };
const amount = invoice.amountCents ?? 0;const note = invoice.note ?? 'no note';Is there a value here? Usually what application code wants: not “own versus inherited,” but “did I get something usable, or should I fall back?” Use it for optional fields on a typed shape. ?? falls back only on null or undefined, never on 0 or ''.
'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.* methods worth knowing
Section titled “The Object.* methods worth knowing”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.
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 leakThe reflective tools (Object.getPrototypeOf, Object.defineProperty, __proto__) are out of scope.
Check your understanding
Section titled “Check your understanding”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.
amountCents field of an Invoice you just queried.fieldName variable in a generic helper.true for inherited keys like toString).'background-color' off a style object.'en' if it’s missing.Record<string, number> and handle the missing case.Object.prototype method.customer.email to send a receipt.