The object as workhorse record
The JavaScript object is the workhorse record of a SaaS codebase, and this lesson teaches the senior reflexes for building, reading, and reshaping it safely.
Here are two real bugs that share one root cause.
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 bug reached into Object.prototype because the code used bracket access on a key the user controls. The second overwrote a field because the spread follows the rule “right-most key wins,” and the patch happened to carry an id. You avoid both by choosing the form whose meaning matches what you intend. This lesson builds your default choice for the three jobs you do with every record-shaped value in a SaaS codebase: building it, reading it, and reshaping it.
Dot by default, brackets on trigger
Section titled “Dot by default, brackets on trigger”Dot access is the default form for reading a field off an object. It is static, type-checked against the known shape, and the form every reader expects to see.
const invoice = { amountCents: 4900, status: 'pending' };const amount = invoice.amountCents;
const fields: Record<string, number> = { amountCents: 4900 };const value = fields['amountCents'];Bracket access is the conditional form. Only three situations call for it:
- The key isn’t a valid identifier.
style['background-color'],payload['user.email'], or anything else with a hyphen, dot, or other character a JavaScript identifier can’t carry. - The key is held in a variable.
obj[fieldName], as in a generic helper that walks a record by a key passed in. - The key is computed at runtime. Factory helpers and schema mappings, where the key is built up from data rather than written at the call site.
If none of these apply, use the dot. Brackets can feel more dynamic, so it is tempting to default to them, but that is a habit worth dropping. The dot reads as intent: “this field, by name.” The bracket reads as an escape hatch: “this key, computed somehow.” The two tell the next reader different things about your code.
The type difference is the other half of the story. Under noUncheckedIndexedAccess , bracket access on a Record<string, T> returns T | undefined, while dot access on a known shape returns T. That strictness is correct here. When you read by a variable key, you genuinely do not know whether that key is present, so the type forces you to handle the case where it is not.
Building records: shorthand, computed keys, spread
Section titled “Building records: shorthand, computed keys, spread”Three pieces of syntactic sugar build object literals. Each one corresponds to a specific kind of operation, and each shows up in production code every day.
const name = 'Lina Park';const email = 'lina@acme.test';
const customer = { name, email };Same-name fields collapse. When the local variable’s name matches the field name, you write the name once. This is the default for every object literal built from local variables.
const fieldName = 'amountCents';const value = 4900;
const patch = { [fieldName]: value };Bracket on the left for a dynamic key. This is rare in 2026 app code, since most keys are known when you write the literal, but factory helpers and reducer patterns use it. The same three triggers as bracket reads apply here, at the point where you build the object.
const base = { id: 'cus_001', name: 'Lina Park', plan: 'free' };const updated = { ...base, plan: 'pro' };Shallow merge, right-most key wins. React state setters, Server Action input merging, and Drizzle update payloads all use this form. Putting plan: 'pro' after ...base overrides the base’s plan; if the order were reversed, the override would lose.
That “right-most key wins” rule is what caused the second bug at the top of the lesson. Spread is shallow, as the value-model lesson covered, and a key claims its slot if it appears later in the merge, no matter which side it came from. Whoever wrote { ...invoice, ...patch } was thinking “apply the patch,” but the patch carried an id field the writer did not expect, and the merge honored it. The fix is not to memorize the rule. Instead, spread only the fields you mean to override, or validate the patch shape at the boundary so a stray id never reaches the merge.
Is the key there, or the value?
Section titled “Is the key there, or the value?”Three checks sit close enough together that they are easy to confuse, but they ask three different questions: is the key present at all, is it present as an own key rather than an inherited one, and is there actually a usable value behind it. The rest of this section is about telling those three apart.
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 choice in 2026 app code. Recognize it in older codebases, but 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. This is the right choice in 2026 whenever the question really is “is this key present on the object itself, ignoring the prototype chain.” That comes up when you read user-controlled keys from a parsed JSON payload and need to defend against an attacker who put toString in their input.
const invoice = { amountCents: 4900, status: 'pending' };
const amount = invoice.amountCents ?? 0;const note = invoice.note ?? 'no note';Is there a value here? This is what application code usually wants. It asks neither “own versus inherited” nor “key present”; it asks “did I get something usable, or should I fall back?” Use it when reading optional fields off a typed shape. ?? falls back only on null or undefined, never on 0 or ''.
'toString' in invoice returning true is the same family of bug as the chapter opener’s config[userInput] returning Object.prototype.toString. Both reach into the prototype chain through code that meant to ask about own properties. Object.hasOwn is what 'foo' in obj should have been from the start: own properties only, no walk up the inheritance chain. Whenever the input to a presence check could be controlled by a user, reach for Object.hasOwn.
The Object.* methods worth knowing
Section titled “The Object.* methods worth knowing”The Object constructor carries a handful of static methods that handle the daily reshape jobs: iterating, building from pairs, merging, freezing, and grouping. Together they cover the ground that used to send people reaching for a utility library like lodash.
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) returns the corresponding values, and Object.entries(obj) returns [key, value] pairs. All three return arrays in insertion order. Because they are arrays, you can run for...of over an object or .map over its key-value pairs. You met for...of in the flat-control-flow lesson as the replacement for for...in, and it gets its own treatment later in this chapter.
const invoice = { amountCents: 4900, status: 'pending' };const keys = Object.keys(invoice);The string[] widening surprises people who expected keyof T. The reason is structural typing: any object that has at least amountCents and status is assignable to that type, including objects with extra keys. TypeScript cannot prove the runtime shape is exactly the declared shape, so it returns the safe answer. When you genuinely need the narrower type at a trusted boundary, typically right after parsing input you control, an assertion is the explicit escape hatch. Using it means you take on the responsibility of proving the narrower type actually holds.
Object.fromEntries: the inverse
Section titled “Object.fromEntries: the inverse”Object.fromEntries takes an iterable of [key, value] pairs and builds an object. The most common use is the round trip with Object.entries: break the object into pairs, transform them, build a new object back.
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. That gives you an array you can iterate, map over, or filter.
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. Any array operation is now available. Here we uppercase the key and pass the value through unchanged.
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 from the transformed pairs. This round trip, entries → transform → fromEntries, is the clean shape for any object-level reshape that would otherwise be a hand-rolled .reduce over the keys.
This round trip replaces the old arr.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}) form, for the same reason Object.hasOwn replaced hasOwnProperty.call: it reads as the operation it is doing, in two named steps instead of one long chained line. The reduce form has a second cost. It rebuilds the accumulator object on every iteration, which makes it O(n²), while Object.fromEntries is linear.
The other place Object.fromEntries shows up is the boundary where pair-shaped data crosses into object shape: converting a Map to a plain object for a JSON response, or parsing a URLSearchParams instance into a flat object of query values. The Map case comes up later in this chapter, and the query-string case comes up when Server Actions land.
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 target. The return value is 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. Most of the time you wanted a non-mutating merge, and the spread form ({ ...config, locale: 'es' }) is the right choice. 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. When in doubt, reach for the spread.
Object.freeze, Object.isFrozen: runtime immutability
Section titled “Object.freeze, Object.isFrozen: runtime immutability”Object.freeze(obj) makes the object read-only at runtime: writes silently fail in non-strict mode and throw in strict mode. Object.isFrozen(obj) is the matching check. TypeScript’s readonly modifier, which arrives when type annotations get their own chapter next, enforces immutability at design time, and that is almost always what you want. Object.freeze is reserved for the rare case where you need the guarantee to hold at runtime, such as a shared module-level constant that some caller might try to mutate. The value-model lesson introduced this idea; it is named here for completeness.
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 that returns a string key, and produces an object that groups the items by that key. It replaces the .reduce pattern people used to hand-write for this exact job.
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 is now an object whose keys are the status values ('paid', 'pending') and whose values are arrays of the matching invoices. That is one line and one operation, reading as exactly what it does. This is the form to reach for when you want to group rows by a categorical field.
const byStatus = Object.groupBy(invoices, (invoice) => invoice.status);That | undefined on every group is the catch. TypeScript does not track which keys the callback actually returned for the input, so even when you can see by reading the data that 'paid' shows up, the type still admits undefined. The ?? fallback handles it cleanly: const paid = byStatus.paid ?? [];. The same pattern returns when you group by a non-string key, which calls for Map.groupBy, covered in the lesson on Set and Map later in this chapter.
The prototype chain in one paragraph
Section titled “The prototype chain in one paragraph”Every object literal you write inherits from Object.prototype, which is why .toString, .hasOwnProperty, and .constructor are available on it without you having defined them. Those inherited members are what made config[userInput] return [Function: toString] at the top of the lesson: the prototype chain leaked into application logic the moment user input touched bracket access. The rule to internalize is to never trust the chain through user-controlled keys, which is the entire reason Object.hasOwn exists. Occasionally you need an object with no inherited members at all, such as a strict lookup table keyed by user input, where any accidental prototype access would be a bug. For that case, there is Object.create(null):
const safeMap = Object.create(null);safeMap[userInput] = 'value';// safeMap.toString is undefined — no prototype chain to leakThat is all you need for now. The deeper reflective tools, such as Object.getPrototypeOf, Object.defineProperty, and the legacy __proto__ accessor, are not part of daily work and stay out of scope here.
Where this lands later
Section titled “Where this lands later”The object as record shows up in every later unit:
- Drizzle returns row objects.
selectqueries hand back arrays of records shaped exactly like the table you read from. - React props are an object shape. Every component receives
{ children, className, ... }and destructures from it. - Server Actions consume an options-object input.
createInvoice({ customerId, amountCents, dueDate })is the shape. - Zod’s
.parse()returns a typed object. The validated shape is what the rest of the system reads from.
It is the same record each time; only the surface around it changes.
Check yourself
Section titled “Check yourself”Below are eight intents and four forms to choose from. For each intent, pick the form that says what the code means, not just one that happens to work.
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.