#Objects#Polyfill

Object.assign Polyfill

Implement Object.assign from scratch: copy own enumerable properties (including Symbols) from sources onto the target.

By Pratik RaiMedium

Object.assign copies enumerable own properties from one or more source objects onto a target, mutating and returning that target. It is the method behind most "merge these options" code, and its two most important properties — that the copy is shallow and that it triggers setters — are the source of a steady stream of bugs.

What it actually copies

JSfile.javascript
1Object.assign({ a: 1 }, { b: 2 }, { a: 3 }); // { a: 3, b: 2 }

Sources are applied left to right, so later sources win. The target is mutated in place and returned — it is not a pure function, which is why Object.assign({}, defaults, overrides) with a fresh literal target is the safe idiom.

It copies:

  • Own properties only — nothing from the prototype chain.
  • Enumerable properties only — Object.defineProperty with enumerable: false is skipped.
  • String and symbol keys both. Symbols are frequently forgotten in hand-rolled versions.

The shallow copy problem

This is the single most consequential thing to understand:

JSfile.javascript
1const original = { user: { name: 'Ada' }, tags: ['a'] }; 2const copy = Object.assign({}, original); 3 4copy.user.name = 'Grace'; 5original.user.name; // 'Grace' — same object reference

Only the top level is copied. Nested objects and arrays are shared by reference between source and target. Code that "clones" config or state with Object.assign({}, obj) and then mutates a nested field is modifying the original too.

For a genuine deep copy, use structuredClone(obj) — built into every modern runtime, and it handles Date, Map, Set, ArrayBuffer and circular references. The old JSON.parse(JSON.stringify(obj)) trick silently destroys undefined, functions, Date objects (they become strings) and throws on cycles.

Spread syntax has identical semantics: {...obj} is also shallow.

Setters, not raw assignment

Object.assign uses [[Set]], not [[DefineOwnProperty]]. That means it invokes setters on the target and reads getters on the source:

JSfile.javascript
1const target = { 2 set value(v) { console.log('setter ran with', v); }, 3}; 4Object.assign(target, { value: 42 }); // logs "setter ran with 42" 5target.value; // undefined — nothing was stored

Spread behaves differently: {...target, value: 42} defines a plain data property and never calls a setter. That divergence surprises people who assume the two are interchangeable.

It also means getters on the source are evaluated, and their returned values copied. You get the computed result, not the getter itself. Copying an object while preserving its accessors requires Object.getOwnPropertyDescriptors:

JSfile.javascript
1Object.defineProperties({}, Object.getOwnPropertyDescriptors(source));

The implementation

JSfile.javascript
1Object.myAssign = function (target, ...sources) { 2 if (target === null || target === undefined) { 3 throw new TypeError('Cannot convert undefined or null to object'); 4 } 5 6 const to = Object(target); 7 8 for (const source of sources) { 9 // null and undefined sources are skipped, not an error. 10 if (source === null || source === undefined) continue; 11 12 const from = Object(source); 13 14 for (const key of Reflect.ownKeys(from)) { 15 const descriptor = Object.getOwnPropertyDescriptor(from, key); 16 if (descriptor && descriptor.enumerable) { 17 to[key] = from[key]; // [[Set]] — triggers setters by design 18 } 19 } 20 } 21 22 return to; 23};

Reflect.ownKeys returns both string and symbol keys, which Object.keys does not. Checking enumerable via the descriptor is what correctly skips non-enumerable properties while still including symbols.

Note the asymmetry in error handling: a null target throws, but null sources are silently skipped. That is deliberate in the spec and makes Object.assign(base, maybeNull, maybeUndefined) safe.

Edge cases worth knowing

Assignment can throw mid-way. If a target property is read-only, [[Set]] throws in strict mode — after earlier properties have already been copied. Object.assign is not atomic, and a failed call leaves a partially mutated target.

Primitives get boxed. Object.assign({}, 'abc') yields {0:'a', 1:'b', 2:'c'}, since the string is wrapped and its indices are enumerable own properties.

Arrays are objects. Object.assign([1,2,3], [4]) gives [4,2,3] — index 0 overwritten, the rest untouched. Almost never what anyone wants.

Prototypes are not copied. The result is a plain object; class instances lose their identity. Object.assign({}, instance) instanceof MyClass is false.

Object.assign versus spread

Prefer spread for creating new objects — it is clearer and avoids the setter surprise. Reach for Object.assign when you specifically need to mutate an existing object, such as updating a store in place or copying onto a class instance in a constructor. When copying symbol-keyed or accessor properties matters, neither is right; use descriptors.

Key takeaways

  • The copy is shallow — nested objects stay shared. Use structuredClone for depth.
  • It mutates and returns the target; use a fresh {} when you want a copy.
  • It uses [[Set]], so target setters fire and source getters are evaluated.
  • Symbol keys are copied; non-enumerable and inherited properties are not.
  • A null target throws; null sources are skipped.

Goal: Implement Object.myAssign with correct symbol support and null-source handling.

Frequently asked questions

What does Object.assign actually copy?
Own enumerable properties from one or more sources onto a target, which it mutates and returns. Inherited properties are not copied, and non-enumerable ones are not copied. It is a shallow copy — nested objects are shared by reference between the source and the target.
Does it copy symbol keys?
Yes, which is why `Object.keys` is the wrong basis for an implementation. `Reflect.ownKeys` returns both string and symbol keys, and combining `Object.getOwnPropertyNames` with `Object.getOwnPropertySymbols` does the same job. Skipping symbols is the most common way a working-looking implementation diverges from the real one.
How does it handle null and undefined?
A `null` or `undefined` *source* is skipped silently rather than throwing, so `Object.assign({}, null, {a: 1})` works fine. A `null` or `undefined` *target* throws a `TypeError`. Reproducing that asymmetry is a good signal that you read the spec.
What is the trap involving getters?
`Object.assign` reads each source property, so a getter is invoked and its returned value is copied — the getter itself is not carried over. If you need to preserve accessors, `Object.getOwnPropertyDescriptors` with `Object.defineProperties` is the tool, and that is usually the follow-up question.

Related Challenges

Continue learning with these related challenges

View All
JavaScript

Object.create Polyfill

Implement Object.create using the classic empty-constructor trick to set an object's prototype chain.

JavaScript · Objects · PrototypesPratik Rai ·

JavaScript

Array.prototype.groupBy

Group an array into buckets by a key function — now a real language feature as Object.groupBy.

JavaScript · ES6Pratik Rai ·

JavaScript

Recursive Object Evaluation

Implement a function that recursively traverses an object, evaluates functions with given arguments, and transforms keys to lowercase. Master recursive object traversal and type checking.

JavaScript · Recursion · ObjectsPratik Rai ·