JavaScript8 min read

Object Copying Is Really a Property Operation

What spread, Object.assign, descriptors, prototypes, and enumeration order mean for everyday JavaScript objects.

  • objects
  • property descriptors
  • language semantics

JavaScript offers several ways to make one object resemble another: object spread, Object.assign, descriptor APIs, and prototype-based construction. Calling all of them “copying” conceals which properties are visited, whether accessors run, and how the destination is modified.

For plain data objects, the differences often disappear. They become important when objects carry getters, symbols, non-enumerable state, or a custom prototype—the exact cases that tend to appear in libraries and framework internals.

A property is more than a value

Every own property has a descriptor. A data descriptor contains value and writable; an accessor descriptor contains get and set. Both kinds also have enumerable and configurable flags.

const account = {};

Object.defineProperty(account, 'id', {
  value: 42,
  writable: false,
  enumerable: false,
  configurable: false,
});

Ordinary assignment syntax creates writable, enumerable, configurable data properties. defineProperty is the tool for expressing a different contract. Object.getOwnPropertyDescriptor(account, 'id') exposes that contract without reading the property through a getter.

Property flags are enforced by ordinary operations. In strict mode, assigning to a non-writable property throws; outside strict mode, the assignment typically fails silently. Deleting a non-configurable property follows a similar strict-versus-sloppy distinction.

Spread reads values into a new object

Object spread visits the source object’s own enumerable string-keyed properties. Symbol-keyed properties and non-enumerable properties are omitted. Each included property is read, so a getter on the source executes during the copy.

const source = {
  first: 'Ada',
  get displayName() {
    return `${this.first} Lovelace`;
  },
};

const copy = { ...source };

copy.displayName is a plain data property containing the string returned by the getter. The accessor function itself is not preserved. The new properties receive the normal writable, enumerable, configurable flags.

Spread defines properties directly on its fresh destination. This distinction matters if Object.prototype or another prototype in the destination chain has a setter for the same name: defining an own property does not invoke that inherited setter.

Object.assign changes an existing target

Object.assign(target, ...sources) also reads own enumerable properties from each source, but it writes with ordinary assignment semantics. An existing setter on the target can therefore run.

const events = [];
const target = {
  set status(value) {
    events.push(value);
  },
};

Object.assign(target, { status: 'ready' });

After this operation, events contains 'ready'. The target still has its accessor; assign did not replace it with a data property.

Unlike spread, Object.assign preserves complete property descriptors from the source. A non-writable source property remains non-writable on the target, and a getter is installed as a getter rather than being evaluated. This makes assign a good fit for mixing behavioural objects while spread is preferable for producing plain snapshots.

If descriptor preservation is the actual goal, it is clearer to state it directly:

const clone = Object.defineProperties(
  Object.create(Object.getPrototypeOf(source)),
  Object.getOwnPropertyDescriptors(source),
);

This copies all own string and symbol properties, including non-enumerable ones, without invoking source getters. It is still shallow: referenced objects remain shared.

Prototype is not an own property

Neither spread nor Object.assign copies the source’s prototype. { ...instance } creates an ordinary object containing selected own state, not another instance of the original class.

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  distance() {
    return Math.hypot(this.x, this.y);
  }
}

const point = new Point(3, 4);
const snapshot = { ...point };

snapshot has x and y, but no distance method because the method lives on Point.prototype. This is often exactly what serialization code wants. It is not a general-purpose clone.

Objects created with Object.create(null) have no Object.prototype in their chain. They can safely hold keys such as toString without inheriting a value under that name, though modern code frequently prefers Map when the operation is conceptually a key-value collection.

Enumeration order is specified

Modern JavaScript defines a stable order for own property keys. Integer-index keys come first in ascending numeric order. Other string keys follow in insertion order, and symbol keys come last in insertion order.

const values = { zebra: 1, 10: 'ten', 2: 'two', apple: 2 };
console.log(Object.keys(values));
// ['2', '10', 'zebra', 'apple']

APIs select different subsets of that ordered key list. Object.keys returns enumerable string keys. Object.getOwnPropertyNames includes non-enumerable strings. Object.getOwnPropertySymbols returns symbols, and Reflect.ownKeys returns every own string and symbol key.

The correct copy operation follows from the contract: decide whether you need values or descriptors, own properties or inherited behaviour, a plain snapshot or a same-prototype clone. Once those choices are explicit, “copy an object” stops being a single ambiguous instruction.