Skip to content

Repository files navigation

@coroboros/clone

@coroboros/clone

Deep clone and deep freeze for JavaScript — prototype-aware.

Deep clones objects while preserving the prototype chain, property descriptors, boxed primitives, and native types (Map, Set, Date, RegExp, Error subclasses, TypedArray, DataView, ArrayBuffer, Buffer). Cycle-safe via a WeakMap visited cache. Deep-freezes recursively, skipping ArrayBuffer views.

npmcilicensestarscoroboros.com

Contents

Why this exists

structuredClone ships in every modern runtime. It strips the prototype chain, so class instances come back as plain objects. It drops property descriptors — non-enumerable fields, accessors, and configurable: false flags vanish. Boxed primitives throw. ORM entities, builders, event emitters, frozen state objects, and any custom-constructed value lose information when round-tripped.

@coroboros/clone keeps all three. Three opt-out flags trade those guarantees for speed on plain JSON-shaped data, landing in rfdc-grade territory without switching libraries.

Requirements

  • Node.js >=22 LTS. Use fnm for version management — Rust-based, faster than nvm.
  • Any of the following package managers: pnpm, npm, yarn, bun.

Install

pnpm add @coroboros/clone
npm install @coroboros/clone
yarn add @coroboros/clone
bun add @coroboros/clone

Usage

// ESM (recommended)import{clone,freeze}from'@coroboros/clone';
// CommonJSconst{ clone, freeze }=require('@coroboros/clone');

A deep copy that survives mutation and keeps the prototype:

import{clone,freeze}from'@coroboros/clone';classAccount{constructor(publicid: string,publicbalance: number){}withdraw(amount: number): void{this.balance-=amount;}}constledger={account: newAccount('AC-1',1000),audit: newMap([['2026-01-01',true]]),};constcopy=clone(ledger);copy.account.withdraw(250);copy.account.balance;// 750ledger.account.balance;// 1000 — source untouchedcopy.accountinstanceofAccount;// true — method still callablecopy.audit.get('2026-01-01');// true — a real Map, not {}

lodash.cloneDeep copies enumerable values only. clone also carries accessors and non-enumerable keys:

importcloneDeepfrom'lodash.clonedeep';import{clone}from'@coroboros/clone';constcart={items: [{price: 10},{price: 5}],gettotal(){returnthis.items.reduce((sum,i)=>sum+i.price,0);},};constkept=clone(cart);kept.items.push({price: 100});kept.total;// 115 — total is still a live getterconstflat=cloneDeep(cart);flat.items.push({price: 100});flat.total;// 15 — the getter was frozen to its// clone-time value; the copy is now staleconstcfg={};Object.defineProperty(cfg,'secret',{value: 'k-1',enumerable: false});clone(cfg).secret;// 'k-1'cloneDeep(cfg).secret;// undefined — silently dropped

freeze locks the whole graph. Nested mutation throws in strict mode:

import{clone,freeze}from'@coroboros/clone';constsettled=freeze(clone(ledger));settled.account.withdraw(50);// TypeError in strict mode; no-op otherwisesettled.account.balance;// 1000Object.isFrozen(settled.account);// true — recursive

API

Types

CloneOptions

Per-call overrides for clone. Every field is optional with sensible defaults.

OptionTypeDefaultDescription
ignoreUndefinedPropertiesbooleanfalseWhen true, omit properties whose value is undefined. Recursive.
cyclesbooleantrueWhen false, skips the WeakMap visited cache. Caller asserts no cycles. Faster, infinite-recursion if wrong.
preservePrototypebooleantrueWhen false, custom objects flatten to plain {} (lose instanceof and method inheritance).
copyDescriptorsbooleantrueWhen false, plain objects skip Reflect.ownKeys + descriptor walk. Symbol keys and non-enumerable fields drop. Errors keep message + name only; boxed wrappers keep their value only.
CloneError

Thrown by clone for inputs it cannot reproduce. Inherits from Error, supports Error.cause for wrapping.

classCloneErrorextendsError{readonlycode: CloneErrorCode;constructor(code: CloneErrorCode,message: string,options?: {cause?: unknown});}

The code field is a stable string discriminant safe for runtime branching.

CloneErrorCode
typeCloneErrorCode='UNSUPPORTED_TYPE';

Cloning

clone(thing, options?)

Returns a deep copy of thing. The return type matches the input type via generic inference. The clone preserves the prototype chain, property descriptors (including non-enumerable, accessor, and configurable: false properties), boxed primitive wrappers, and symbol-keyed properties.

Parameters

OptionTypeDefaultDescription
thingT(required)Value to clone. Any JavaScript value or object.
options?CloneOptions{}Per-call overrides. See the type for each flag.

ReturnsT. A deep copy of thing, typed as T.

ThrowsCloneError with code: 'UNSUPPORTED_TYPE' for functions (sync, async, generator), Promise, Intl.Collator / Intl.DateTimeFormat / Intl.NumberFormat / Intl.PluralRules, WeakMap, WeakSet, and bare constructor references (e.g. clone(Array)). undefined, null, and NaN clone to themselves.

Supported types

Native types clone with type-specific semantics:

  • Array — element-by-element deep clone.
  • Map, Set — keys and values cloned independently.
  • Date — by valueOf().
  • RegExpsource and flags preserved.
  • TypedArray (Int8Array through Float64Array) — cloned via the constructor.
  • DataView — buffer copied; byteOffset and byteLength preserved.
  • Buffer — bytes copied via Buffer.allocUnsafe and Buffer#copy. Browser bundles skip this branch via a runtime guard; the type is Node-only.
  • ArrayBuffer — sliced.
  • Error and subclasses (EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError) — own properties copied with full descriptors.
  • Boxed primitives (new String(), new Number(), new Boolean()) — wrapper recreated with attached properties.
  • Custom objects — created via Object.create(getPrototypeOf(source)), then own descriptors applied.
  • Null-prototype objects (Object.create(null)) — preserved with the null prototype.

Cycle handling

A WeakMap visited cache preserves circular and shared references. Cyclic inputs round-trip correctly. Shared references stay shared — a diamond input produces a diamond output, with each shared subtree cloned exactly once.

Fast clone for plain JSON-shaped data

Composing all three opt-out flags (cycles: false, preservePrototype: false, copyDescriptors: false) gives a rfdc-grade fast path for callers who know their data is plain and acyclic. See bench/baseline.md for head-to-head numbers vs structuredClone, lodash.cloneDeep, rfdc, and fast-copy.

Examples

clone(newDate());// → new Date with the same valueOfclone(newMap([['k',1]]));// → new Map with the same entriesclone({gettotal(){return0;}}).total;// → 0 — getter preservedconsto: Record<string,unknown>={name: 'cyclic'};o.self=o;clone(o).self===clone(o);// false — fresh copy per callclone(largeJsonConfig,{// → ~rfdc speed on plain datacycles: false,preservePrototype: false,copyDescriptors: false,});
freeze(thing)

Recursive deep freeze. Walks own properties, freezes each value, then freezes the container. A WeakSet visited cache makes cyclic inputs safe.

Parameters

OptionTypeDefaultDescription
thingT(required)Value to freeze.

ReturnsT. The same value, frozen, typed as T.

Skipped types

Object.freeze throws on ArrayBufferView instances with elements. freeze leaves the following unfrozen:

  • All TypedArray subclasses (Int8Array through Float64Array, plus BigInt64Array / BigUint64Array).
  • DataView.
  • Buffer (a Uint8Array subclass).

Detection uses ArrayBuffer.isView(thing).

Examples

constsettled=freeze(clone(ledger));Object.isFrozen(settled);// trueObject.isFrozen(settled.account);// true — recursivesettled.account.balance=0;// TypeError in strict modefreeze(newInt8Array([1,2]));// → returned unchanged (would throw otherwise)

Compared to alternatives

FeaturestructuredClonelodash.cloneDeeprfdcfast-copy@coroboros/clone
Native types (Map, Set, Date, TypedArray, ArrayBuffer, DataView)yesyespartialyesyes
Cyclesyesyesoptionalyesyes
Prototype chain preservednopartialnonoyes
Property descriptors (non-enumerable, accessor, configurable: false)nonononoyes
Boxed primitives (new String(), new Number(), new Boolean())throwspartialnonoyes
Error subclasses with descriptorspartialnononoyes
Functions, Promises, WeakMap, WeakSetnonononono (by design)

The market gap is the prototype chain plus property descriptors. structuredClone strips the prototype from class instances; they return as plain objects. lodash.cloneDeep drops descriptor flags. ORM entities, builders, event emitters, and any custom-constructed state object stay intact through clone.

Contributing

Bug reports and PRs welcome.

  • Open an issue before submitting non-trivial PRs.
  • Commits follow Conventional Commits.
  • Run pnpm lint && pnpm typecheck && pnpm test before pushing.
  • Run pnpm bench against bench/baseline.md when touching src/clone.ts — no regression > 10 % at fixed feature set.
  • Target the main branch.

License

MIT

Releases

Packages

Used by

Contributors

Languages