Learn JS Series (#45) - Object Methods: assign, keys, values, entries, fromEntries

Words
3032
Reading
14 min
Listen
Play
8h

Learn JS Series (#45) - Object Methods: assign, keys, values, entries, fromEntries

js-banner.png

What will I learn

  • You will learn the essential static Object methods for treating objects as data you can inspect and transform;
  • how Object.keys, values, and entries turn an object into arrays you can iterate, map, filter, and reduce;
  • how Object.fromEntries turns arrays of pairs back into objects -- the exact inverse of entries;
  • how Object.assign copies and merges properties, and where it quietly bites you;
  • the single most useful object-transformation pattern in JavaScript: round-tripping through entries and back;
  • why every one of these copies is SHALLOW, and why that is the classic surprise waiting for you.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-44 read, especially objects (ep12), array methods (ep11), destructuring (ep21), and mixins (ep43).

Difficulty

  • Intermediate

Curriculum (of the Learn JS Series):

Learn JS Series (#45) - Object Methods: assign, keys, values, entries, fromEntries

For the last dozen episodes we have been elbow-deep in the machinery of objects -- prototypes, the chain, constructors, classes, private fields, mixins, and last time the whole saga of this binding. That was all about objects as behaviour: things with methods, identity, inheritance. Today we swing the camera around and look at objects as plain data -- bags of key-value pairs you want to inspect, count, filter, rename, merge, and transform. And here is the thing that trips quite some people up: JavaScript keeps the tools for that job as static methods on the Object constructor, not as methods on the objects themselves. You do not write myObject.keys(); you write Object.keys(myObject). Once that clicks, a small handful of these statics -- keys, values, entries, fromEntries, and assign -- become the everyday toolkit you reach for constantly. Having said that, they also hide a trap (shallow copying) that has cost me quit some debugging hours over the years, so we will meet that head-on too. Let's dig in. ;-)

Solutions to Episode 44 Exercises

Exercise 1 - binding in the constructor:

class Timer {
  constructor() {
    this.seconds = 0;
    this.tick = this.tick.bind(this); // lock 'this' to the instance, forever
  }
  tick() { this.seconds += 1; return this.seconds; }
}
const t = new Timer();
const tick = t.tick;                  // detach it on purpose
console.log(tick(), tick());          // 1 2 - 'this' survived detachment

The insight: bind produces a new function whose this is permanently the instance, so the detached tick still finds this.seconds. Without that constructor line, the bare call would throw because this would be undefined.

Exercise 2 - an arrow-function class field:

class Timer2 {
  seconds = 0;
  tick = () => { this.seconds += 1; return this.seconds; };
}
const t2 = new Timer2();
const tick2 = t2.tick;
console.log(tick2(), tick2());        // 1 2 - arrow uses the lexical (instance) 'this'

The insight: the arrow field captured the instance's this at construction time (episode 16), so it can never be lost no matter how the function is later called -- no explicit bind needed.

Exercise 3 - fixing at the call site:

class Greeter {
  constructor(name) { this.name = name; }
  greet() { return `hi ${this.name}`; }
}
const g = new Greeter("scipio");
setTimeout(() => console.log(g.greet()), 10); // wrap: still called as g.greet()

The insight: wrapping the call in an arrow keeps greet a shared prototype method (no per-instance copy, still overridable and super-reachable), which converting it to an arrow field would have sacrificed. You changed the call site, not the class.

Right -- with the this saga behind us, let's treat objects as pure data.

The three that unlock iteration

Recall from episode 8 the slightly annoying fact that plain objects are NOT directly iterable with for...of. You cannot just loop an object the way you loop an array; the iteration protocol simply is not there on Object.prototype. Three static methods bridge that gap by converting an object into an array, which of course you can iterate and run all of Phase 2's array machinery over. We met them very briefly back in episode 12; now let's use them for real. Each one takes an object and returns an array built from its own enumerable properties:

const scores = { math: 90, science: 85, art: 78 };

console.log(Object.keys(scores));    // ["math", "science", "art"]
console.log(Object.values(scores));  // [90, 85, 78]
console.log(Object.entries(scores)); // [["math", 90], ["science", 85], ["art", 78]]

keys hands you the property names, values hands you the values, and entries hands you [key, value] pairs -- little two-element arrays. That word "enumerable" matters, by the way (episode 32): properties you define with Object.defineProperty and mark non-enumerable will be skipped by all three, exactly as they are skipped by for...in. For ordinary objects built with literals or assignment, everything is enumerable, so you get all of it.

The moment your object is an array, the whole toolbox from episode 11 opens up -- map, filter, reduce, sort, the lot. Want the sum of all the values? The highest-scoring subject? You do not need a manual loop with a running accumulator; you reach for Object.values or Object.entries and then reduce:

const total = Object.values(scores).reduce((a, b) => a + b, 0);
console.log(total); // 253

const best = Object.entries(scores).reduce((top, [subject, score]) =>
  score > top[1] ? [subject, score] : top
);
console.log(best);  // ["math", 90]

Notice the destructuring in that second reduce -- [subject, score] pulls the pair apart right in the parameter list (episode 21), so the callback reads almost like English: "if this score beats the current top, this pair becomes the new top". Object.entries plus a for...of loop with destructuring is also, honestly, the cleanest way to iterate an object's key-value pairs directly:

for (const [subject, score] of Object.entries(scores)) {
  console.log(`${subject}: ${score}`);
}
// math: 90
// science: 85
// art: 78

That is far nicer than the old for...in plus obj[key] dance, and (nota bene) it only ever touches the object's OWN enumerable keys -- it never wanders up the prototype chain the way for...in does, which was one of for...in's classic footguns we flagged in episode 8.

fromEntries: the inverse

Now for the piece that makes the whole picture symmetrical. Object.fromEntries does the exact opposite of Object.entries: it takes an array of [key, value] pairs and builds a fresh object out of them. This is the missing half of the round-trip, because now you can go all the way there and back -- object into array, transform the array, array back into object:

const pairs = [["a", 1], ["b", 2], ["c", 3]];
console.log(Object.fromEntries(pairs)); // { a: 1, b: 2, c: 3 }

// it accepts ANY iterable of pairs, including a Map:
const map = new Map([["x", 10], ["y", 20]]);
console.log(Object.fromEntries(map));   // { x: 10, y: 20 }

That second example is worth pausing on. A Map (which we will cover in its own right much later) already iterates as [key, value] pairs, so Object.fromEntries(someMap) is the standard one-liner to convert a Map into a plain object. And it works the other way too: new Map(Object.entries(obj)) turns a plain object into a Map. entries and fromEntries are genuine inverses of each other, and together they unlock the single most useful object-transformation pattern in all of JavaScript -- which is exactly where we are headed next.

The round-trip transform pattern

Here is the pattern to burn into memory, because you will use it constantly once you know it exists. To transform an object -- filter its properties, transform its values, rename its keys, whatever -- you convert it to entries, use ordinary array methods on those entries, then convert back with fromEntries. It is, in effect, "map and filter for objects", built entirely from pieces you already have:

const prices = { apple: 1.5, pear: 2.0, plum: 0.8 };

// transform all VALUES (apply a 10% discount):
const discounted = Object.fromEntries(
  Object.entries(prices).map(([item, price]) => [item, price * 0.9])
);
console.log(discounted); // { apple: 1.35, pear: 1.8, plum: 0.72 }

// FILTER properties (keep only items priced over 1.0):
const expensive = Object.fromEntries(
  Object.entries(prices).filter(([, price]) => price > 1.0)
);
console.log(expensive);  // { apple: 1.5, pear: 2 }

Read each one inside-out, in three moves: Object.entries to get the pairs, .map or .filter to transform them (destructuring the [key, value] right in the callback -- and note the neat [, price] trick in the filter, an empty first slot meaning "I do not care about the key here"), then Object.fromEntries to rebuild an object. You can transform both halves of the pair at once, of course:

const loud = Object.fromEntries(
  Object.entries(prices).map(([item, price]) => [item.toUpperCase(), price * 2])
);
console.log(loud); // { APPLE: 3, PEAR: 4, PLUM: 1.6 }

Here we uppercased every key AND doubled every value in one pass -- key transformation and value transformation together. And crucially, this whole approach stays pure (episode 28): the original prices object is never mutated; each step produces a new array or object and hands it to the next. That purity is why this pattern shows up everywhere in modern, functional-leaning JavaScript -- it is the idiomatic way to do to an object what map and filter do to an array, without a single line of imperative mutation.

Object.assign: copy and merge

Next up, the general-purpose copy-and-merge tool. Object.assign(target, ...sources) copies all enumerable own properties from one or more source objects into a target object, left to right, and returns that same target. We already met it wearing its "object mixin" hat back in episode 43; here it is doing its other main job, merging configuration:

const defaults = { theme: "dark", fontSize: 14, showGrid: true };
const overrides = { fontSize: 16 };

const merged = Object.assign({}, defaults, overrides);
console.log(merged);   // { theme: 'dark', fontSize: 16, showGrid: true } - later sources win
console.log(defaults); // { theme: 'dark', fontSize: 14, showGrid: true } - untouched!

Two habits here will save you real pain. First, notice that I passed a fresh {} as the very first argument. That empty object is the target -- the thing that gets mutated and returned. By making it a brand-new empty object, defaults and overrides are both used only as sources and neither is modified. If instead you had written Object.assign(defaults, overrides), you would have mutated defaults in place (and merged would just be the same object as defaults) -- which is a genuine bug that people hit accidentically all the time when a shared defaults object silently changes under them. Second, the "later sources win" rule: because sources are applied left to right, a key appearing in a later source overwrites the same key from an earlier one. That is precisely why config-merging works -- your defaults go first, the user overrides go last and take precedence.

Now, an honest aside. In most modern code you will actually reach for the spread syntax (episode 20 and 31) rather than Object.assign, because it reads a bit cleaner for the common case:

const mergedSpread = { ...defaults, ...overrides };
console.log(mergedSpread); // { theme: 'dark', fontSize: 16, showGrid: true } - same result

The two are near-equivalent for plain merging, and I use spread by default. So when does Object.assign still earn its place? Mainly when you deliberately want to mutate an existing target -- for instance copying a batch of new properties onto a class instance, or updating an object that other code already holds a reference to. Spread always makes a new object; Object.assign lets you write into an existing one. Pick the tool that matches your intent, but be honest with yourself about whether you actually want mutation or not.

A caveat: assign, spread, and every Object copy are SHALLOW

Now the trap I promised you, and please read this section twice, because it is the single most common surprise with object copying in JavaScript and it applies to all of the above -- Object.assign, spread, the entries/fromEntries round-trip, every last one of them. These copies are shallow. They copy the top-level property values. But when a value is itself an object or an array, what gets copied is only the reference to that nested thing, not a deep, independent clone of it. So the copy and the original end up pointing at the same nested object:

const original = { name: "scipio", settings: { theme: "dark" } };
const copy = { ...original };          // shallow copy

copy.name = "alice";                   // fine: 'name' is a top-level string
copy.settings.theme = "light";         // OOPS: 'settings' is SHARED between the two!

console.log(copy.name);                // "alice"
console.log(original.name);            // "scipio" - unaffected, good
console.log(original.settings.theme);  // "light"  - the original changed too?!

Look at what happened. Reassigning copy.name was totally safe, because name holds a primitive string sitting directly on the top level -- reassigning it just repointed copy's own name slot, leaving original's alone. But copy.settings and original.settings are two references to the one and only settings object. So when we reached into that shared object and mutated .theme, both copy and original saw the change, because there is only one settings object in the whole program. This is the classic value-semantics-versus-reference-semantics distinction (which, funnily enough, is the exact subject of an upcoming episode), and it burns everyone at least once.

The fix -- a genuine, deep, independent clone where nested objects are also copied -- is precisely the topic of the very next episode, so I will not spoil the tool here. For now, just carve this into your brain: all the copies in this episode are one level deep. If your object is flat (only primitive values), a shallow copy is a perfectly real, fully independent copy and you have nothing to fear. The instant it contains nested objects or arrays, a shallow copy shares that nested data, and mutating it will reach back and bite the original.

A quick look sideways: Python's dict methods

Quite a few of you came to this series from the Learn Python Series, so let's put the two side by side for a moment -- it genuinely sharpens what JavaScript is doing. Python's dictionaries expose almost the same trio, but as instance methods on the dict itself, not as statics on some constructor:

scores = {"math": 90, "science": 85, "art": 78}

print(list(scores.keys()))    # ['math', 'science', 'art']
print(list(scores.values()))  # [90, 85, 78]
print(list(scores.items()))   # [('math', 90), ('science', 85), ('art', 78)]

# merge (Python 3.9+): the | operator, like JS spread
merged = {"theme": "dark"} | {"theme": "light"}
print(merged)                 # {'theme': 'light'} - later wins, same as JS

Three differences worth clocking. One, Python calls it .items() where JavaScript says Object.entries() -- same idea, [key, value] pairs, different name. Two, Python's are instance methods (scores.keys()), whereas JavaScript deliberately keeps them off the object as Object.keys(scores) -- a design choice so that a property literally named keys on your object cannot shadow the method. Three, and this is the big one: Python dicts do not have JavaScript's exact fromEntries need, because you can build a dict straight from pairs with dict(pairs) or a dict comprehension ({k: v*2 for k, v in scores.items()}) -- Python's comprehension IS its round-trip transform. In JavaScript we assemble the same effect out of entries + map + fromEntries. Neither is better, they are just different dialects of the same idea, but if you hop between the languages, the naming and the static-vs-method split are the gotchas to remember.

Related helpers worth knowing

A few more Object statics round out the daily toolkit, and while you do NOT need to memorise the whole Object namespace, these three come up often enough to name:

const obj = { a: 1, b: 2 };

console.log(Object.hasOwn(obj, "a"));            // true  - modern own-property check (episode 34)
console.log(Object.getOwnPropertyNames(obj));    // ["a", "b"] - ALL own keys, even non-enumerable
console.log(Object.freeze(obj) === obj);         // true  - freeze returns the same object
obj.a = 999;                                      // silently ignored (strict mode would throw)
console.log(obj.a);                              // 1 - frozen, so the write did nothing

Object.hasOwn(obj, key) is the modern, recommended way to ask "does this object have its OWN property with this name?" -- it replaces the clunky old Object.prototype.hasOwnProperty.call(obj, key) incantation and does not get fooled by the prototype chain. Object.getOwnPropertyNames is like Object.keys but it also includes non-enumerable own keys, which keys deliberately skips. And Object.freeze (which gets its own full episode very soon) makes an object immutable -- any attempt to change, add, or remove a property is ignored (or throws in strict mode). Between the core five (keys, values, entries, fromEntries, assign) and these three helpers, you can iterate, transform, filter, merge, inspect, and lock down objects fluently -- treating them as first-class data instead of opaque, mysterious bags of properties.

Try it yourself

  1. Given const inventory = { apples: 5, pears: 0, plums: 3 };, use Object.entries, filter, and Object.fromEntries to build a NEW object containing only the items with a count greater than zero. Then log the original inventory and confirm it is unchanged.
  2. Given an object mapping names to prices, use the round-trip pattern to produce a new object where every key is uppercased AND every value is doubled -- both transformations in a single map. (Hint: return a two-element array from the callback, transforming each half of the incoming [key, value] pair.)
  3. Demonstrate the shallow-copy trap deliberately: make an object with a nested object property, shallow-copy it with spread or Object.assign, mutate the nested property THROUGH the copy, and show that the original changed too. Then, in one sentence, explain exactly why this happens in terms of references.

So what did we actually cover?

  • Object.keys, Object.values, and Object.entries convert an object's own enumerable properties into arrays (of keys, of values, or of [key, value] pairs), which unlocks every array method -- map, filter, reduce -- for object data.
  • Object.fromEntries is the exact inverse of entries: it rebuilds an object from an array (or any iterable, including a Map) of pairs.
  • The round-trip pattern -- entries then map/filter then fromEntries -- is "map/filter for objects": the idiomatic, PURE way to transform values, rename keys, or drop properties without ever mutating the original.
  • Object.assign(target, ...sources) copies enumerable own properties left to right (later sources win); pass a fresh {} as the target to avoid mutating an existing object. Spread ({ ...a, ...b }) usually reads cleaner; Object.assign is for when you deliberately want to write into an existing target.
  • Every one of these copies is SHALLOW -- nested objects and arrays are shared by reference, so mutating nested data through a copy also changes the original. This is the classic copy surprise.
  • Also handy: Object.hasOwn, Object.getOwnPropertyNames, and Object.freeze.

Next episode we confront that shallow-copy problem directly: shallow versus deep copy, why nested data ends up shared, the old hacks people used for years, and the modern built-in that finally makes a genuine, independent, deep clone in one call.

Thanks for following along, and see you next time!

scipio@scipio

Learn JS Series (#45) - Object Methods: assign, keys, values, entri... | Ecency