
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.
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.
- Node.js
>=22LTS. Use fnm for version management — Rust-based, faster than nvm. - Any of the following package managers:
pnpm,npm,yarn,bun.
pnpm add @coroboros/clonenpm install @coroboros/cloneyarn add @coroboros/clonebun add @coroboros/clone// 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 droppedfreeze 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 — recursiveCloneOptions
Per-call overrides for clone. Every field is optional with sensible defaults.
| Option | Type | Default | Description |
|---|---|---|---|
ignoreUndefinedProperties | boolean | false | When true, omit properties whose value is undefined. Recursive. |
cycles | boolean | true | When false, skips the WeakMap visited cache. Caller asserts no cycles. Faster, infinite-recursion if wrong. |
preservePrototype | boolean | true | When false, custom objects flatten to plain {} (lose instanceof and method inheritance). |
copyDescriptors | boolean | true | When 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';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
| Option | Type | Default | Description |
|---|---|---|---|
thing | T | (required) | Value to clone. Any JavaScript value or object. |
options? | CloneOptions | {} | Per-call overrides. See the type for each flag. |
Returns — T. A deep copy of thing, typed as T.
Throws — CloneError 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— byvalueOf().RegExp—sourceandflagspreserved.TypedArray(Int8ArraythroughFloat64Array) — cloned via the constructor.DataView— buffer copied;byteOffsetandbyteLengthpreserved.Buffer— bytes copied viaBuffer.allocUnsafeandBuffer#copy. Browser bundles skip this branch via a runtime guard; the type is Node-only.ArrayBuffer— sliced.Errorand 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
| Option | Type | Default | Description |
|---|---|---|---|
thing | T | (required) | Value to freeze. |
Returns — T. The same value, frozen, typed as T.
Skipped types
Object.freeze throws on ArrayBufferView instances with elements. freeze leaves the following unfrozen:
- All
TypedArraysubclasses (Int8ArraythroughFloat64Array, plusBigInt64Array/BigUint64Array). DataView.Buffer(aUint8Arraysubclass).
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)| Feature | structuredClone | lodash.cloneDeep | rfdc | fast-copy | @coroboros/clone |
|---|---|---|---|---|---|
Native types (Map, Set, Date, TypedArray, ArrayBuffer, DataView) | yes | yes | partial | yes | yes |
| Cycles | yes | yes | optional | yes | yes |
| Prototype chain preserved | no | partial | no | no | yes |
Property descriptors (non-enumerable, accessor, configurable: false) | no | no | no | no | yes |
Boxed primitives (new String(), new Number(), new Boolean()) | throws | partial | no | no | yes |
Error subclasses with descriptors | partial | no | no | no | yes |
Functions, Promises, WeakMap, WeakSet | no | no | no | no | no (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.
Bug reports and PRs welcome.
- Open an issue before submitting non-trivial PRs.
- Commits follow Conventional Commits.
- Run
pnpm lint && pnpm typecheck && pnpm testbefore pushing. - Run
pnpm benchagainstbench/baseline.mdwhen touchingsrc/clone.ts— no regression > 10 % at fixed feature set. - Target the
mainbranch.