InterviewsVector
intermediateRare5 min read · Updated Jul 18, 2026

Implement Object.assign from Scratch

Implement Object.assign in JavaScript: enumerable own properties, symbol keys, getter/setter semantics, and how it differs from object spread.


The problem

Implement assign(target, ...sources) matching Object.assign: copy every enumerable own property (string and symbol keys) from each source onto the target, mutate and return the target, and throw if the target is null/undefined.

The words in bold are the question. Interviewers use this one to check whether you know the property-model vocabulary — own vs inherited, enumerable vs not, string vs symbol keys — and the getter/setter semantics hiding inside an innocent-looking to[key] = from[key].

Where you see it in production

  • Merging options with defaultsObject.assign({}, defaults, userOptions) (largely replaced by spread, but ubiquitous in older code).
  • Mutating an existing object in place — the one thing spread can't do: Object.assign(state, patch) keeps the reference stable.
  • Redux-era immutable updatesObject.assign({}, state, { count: 1 }) was the pre-spread idiom.

Implementation

function assign(target, ...sources) {
  if (target == null) {
    throw new TypeError("Cannot convert undefined or null to object");
  }
 
  // Primitives are boxed: assign("abc", ...) works on a String wrapper.
  const to = Object(target);
 
  for (const source of sources) {
    if (source == null) continue; // null/undefined sources are skipped
 
    // Reflect.ownKeys = string keys + symbol keys, own only
    for (const key of Reflect.ownKeys(source)) {
      // Copy only enumerable properties (skips e.g. class methods’ nature
      // is irrelevant here — what matters is the enumerable flag)
      if (Object.prototype.propertyIsEnumerable.call(source, key)) {
        to[key] = source[key]; // Get on source, Set on target — see below
      }
    }
  }
 
  return to;
}

Without Reflect.ownKeys, the equivalent is Object.keys(source) plus Object.getOwnPropertySymbols(source) filtered by propertyIsEnumerable — knowing both spellings is useful.

The line that carries the semantics

to[key] = source[key] is not a "copy" at the spec level — it's a Get followed by a Set, and both can run user code:

const source = {
  get computed() { return Date.now(); }, // getter is EVALUATED...
};
const copy = Object.assign({}, source);
// ...so copy.computed is a plain data property holding a number.
// The getter itself is NOT copied.
 
const target = {
  set value(v) { console.log("setter ran:", v); },
};
Object.assign(target, { value: 42 }); // logs "setter ran: 42"
// target still has the setter; no data property was created.

This is the #1 discriminating follow-up: Object.assign copies values, not property descriptors. If you need to clone getters/setters as getters/setters, the tool is Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) — naming that API is a senior tell.

Verified behavior

const target = { a: 1 };
const result = assign(target, { b: 2 }, { c: 3, a: 4 });
 
result;             // { a: 4, b: 2, c: 3 } — later sources win
result === target;  // true — assign MUTATES and returns the target
 
assign({}, { a: 1 }, null, undefined, { b: 2 }); // { a: 1, b: 2 } — nullish sources skipped
assign(null, { a: 1 });   // TypeError — only a nullish TARGET throws
 
const s = assign("ab", { x: 1 });
// String wrapper { 0: 'a', 1: 'b', x: 1 } — primitives box, they don't throw.
// (In strict mode, writing to the read-only indices of a frozen-ish wrapper
// throws — assign onto string primitives is a trivia edge, not a real use.)

Shallow means shallow

const original = { nested: { count: 1 } };
const copy = Object.assign({}, original);
copy.nested.count = 99;
original.nested.count; // 99 — the nested OBJECT REFERENCE was copied, not the object

A common interview trap is "extend your assign to deep-merge." Treat that as a different question with real design decisions — how to merge arrays, what to do with class instances, cycles — not a recursive one-liner. See deep clone and structuredClone vs deep clone for the honest treatment.

Object.assign vs spread

Object.assign(t, s){ ...t, ...s }
Mutates an existing objectyes — that's its pointno — always a new object
Triggers setters on the targetyesno (spread defines properties directly)
Evaluates getters on sourcesyesyes
Copies symbol keysyesyes
Throws on nullish targetyes{ ...null } is {} — no throw

The setter row is the one that changes behavior in real code — spreading onto a fresh object can't run target-side setters, Object.assign onto a live object can.

Edge cases interviewers probe

  • Symbol keysObject.keys misses them; Reflect.ownKeys or getOwnPropertySymbols is required.
  • Non-enumerable properties are skipped — e.g. properties created with Object.defineProperty(obj, k, { enumerable: false }) don't copy.
  • A throwing getterassign copies left-to-right and stops at the throw; earlier properties are already applied. Object.assign is not transactional. Worth stating unprompted.
  • Frozen target — in strict mode, assigning to a frozen target throws TypeError at the first write (again: partial application, not a rollback).
  • Inherited properties — never copied: own keys only. for...in without a hasOwnProperty guard would get this wrong, which is why the modern iteration APIs are the better tool.

Common mistakes

  • for...in without an own-property check (walks the prototype chain).
  • Forgetting symbols entirely.
  • Claiming a nullish source throws — only a nullish target does; sources are skipped.
  • Presenting a recursive assign({}, source[key]) as "deep merge" — it replaces nested objects rather than merging them, and turns arrays into plain objects. If you claim deep behavior, you must handle arrays, cycles, and non-plain objects.
  • Saying "it copies properties" without being able to explain the getter-evaluation and setter-triggering behavior.

Follow-up questions

  • "How would you copy getters as getters?"Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); this pair exists precisely because Object.assign can't.
  • "Why does Object.assign mutate instead of returning a fresh object?" — it's a merge primitive from 2015 designed for the mutation use case; spread covers the immutable case syntactically.
  • "What happens with Object.assign(obj, obj)?" — a self-assign: every enumerable own property is read and rewritten; getters run, setters run. Harmless for data objects, observable for accessors.
  • "Implement a pick(obj, keys) using these primitives." — a natural warm-down that reuses the enumerable/own vocabulary.
  • "How does this relate to deep cloning?"assign is the canonical shallow copy; the deep version is its own question: custom deep clone.

  • Object.is

    beginner

    The equality that fixes === for NaN and -0: tiny implementation, classic trivia follow-ups.

  • Custom instanceof

    intermediate

    Walk the prototype chain by hand, then meet Symbol.hasInstance — and learn where instanceof lies to you.

  • Reimplement typeof with Object.prototype.toString and learn why typeof null === 'object'.

  • The new Operator

    intermediate

    Implement myNew(Constructor, ...args): prototype linking, this binding, and the return-object override rule.