Skip to content

Commit 13cc507

Browse files
authored
feat(clone): optim pass for 1.0.0 (#37)
* feat(clone): add CloneError class with code + cause * feat(clone)!: throw CloneError on unsupported types Replaces silent `undefined` return with `throw new CloneError('UNSUPPORTED_TYPE', ...)` for functions, Promises, Intl.*, WeakMap, WeakSet, and constructor functions. BREAKING CHANGE: previously these inputs returned `undefined`. Calls that relied on that contract must now wrap in try/catch or filter inputs upstream. * fix(clone): guard Buffer behind runtime check for browser bundles Captures `Buffer` from `globalThis` at module init via a `BufferRef` constant. The Buffer branch only runs when the runtime exposes Buffer; otherwise the path is inert. The `node:buffer` import is type-only — no runtime side-effect on bundlers that strip `node:` externals. * feat(clone): add cycles, preservePrototype, copyDescriptors options Three new opt-out flags on `CloneOptions`, all default true so existing callers see no behavior change. Composing all three `false` gives a fast plain-JSON clone path. - `cycles: false` skips the WeakMap visited cache. Caller asserts no cycles. - `preservePrototype: false` flattens custom objects to plain objects. - `copyDescriptors: false` uses `for...in` + assign for plain objects, drops symbol keys and non-enumerable descriptors. Errors keep message + name only; boxed wrappers keep their value only. * perf(clone): drop typedArrays Set and short-circuit plain-object prototype - TypedArray dispatch now uses one `ArrayBuffer.isView` call instead of a 9-entry Set lookup, after DataView and Buffer have already been claimed. - Plain-object path skips `Object.create(Object.prototype)` and uses a literal, which V8 compiles into a faster object-creation map for the most common case. - `getType` returns a tighter `AnyConstructor` union instead of `unknown`. * test(clone): add fast-check property suite + fix null-prototype throw Property tests caught a real bug: clone(Object.create(null)) threw CloneError because the constructor lookup returned undefined for null-prototype objects. The unsupported-type check now triggers only on functions and explicitly-listed constructors, so null-prototype objects fall through to the plain-object path and are cloned with their null prototype preserved. Restructure tests to match sparkline's "one spec per source module" convention: fold cycle.test.ts into clone.test.ts and freeze.test.ts under describe('cycles') blocks; drop cycle.test.ts. Adds fast-check as a devDependency. * test(clone): add mitata bench vs structuredClone, lodash, rfdc, fast-copy `bench/clone.bench.mjs` runs five fixture buckets — flat-10, nested-100, large-1000, with-cycles, class-instances — across the in-package `clone` (default and fast variants) and the four established cloners. `bench/baseline.md` captures the Apple M1 / Node 22.22.2 numbers as the 1.0.0 baseline with a 10 percent regression budget going forward. Adds `mitata`, `lodash.clonedeep`, `rfdc`, `fast-copy`, and the lodash types as devDependencies; adds `pnpm bench` script. * ci(clone): add ci.yml calling coroboros/ci@v0 reusable workflow * docs(clone): rewrite README with optim API + comparison rationale + CI badge * docs(clone): update CLAUDE.md with bench, fast-check, regression budget, CI rule * docs(clone): tighten brand-voice compliance (active voice, sentence length)
1 parent 6bc4eb8 commit 13cc507

18 files changed

Lines changed: 1003 additions & 232 deletions

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags:
7+
- '[0-9]+.[0-9]+.[0-9]+'
8+
- '[0-9]+.[0-9]+.[0-9]+-*'
9+
pull_request:
10+
branches: [main]
11+
12+
concurrency:
13+
group: ci-${{ github.ref }}
14+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref_type != 'tag' }}
15+
16+
permissions:
17+
contents: write
18+
id-token: write
19+
20+
jobs:
21+
ci:
22+
uses: coroboros/ci/.github/workflows/javascript-npm-packages.yml@v0
23+
secrets:
24+
NPM_CONFIG_FILE: ${{ secrets.NPM_CONFIG_FILE }}
25+
NPM_PACKAGE_REGISTRY: ${{ secrets.NPM_PACKAGE_REGISTRY }}
26+
NPM_PACKAGE_PROXY_REGISTRY: ${{ secrets.NPM_PACKAGE_PROXY_REGISTRY }}
27+
NPM_PACKAGE_REGISTRY_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }}

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,19 @@
33
## v1.0.0 - 15/05/2026
44

55
Initial release of `@coroboros/clone`.
6+
7+
### Features
8+
- `clone<T>(thing, options?)` — deep clone preserving the prototype chain, property descriptors, boxed primitives, native types (`Map`, `Set`, `Date`, `RegExp`, `Error` subclasses, `TypedArray`, `DataView`, `ArrayBuffer`, `Buffer`), and null-prototype objects. Cycle-safe via a `WeakMap` visited cache.
9+
- `freeze<T>(thing)` — recursive deep freeze with a `WeakSet` cycle guard. Skips `ArrayBufferView` instances (`TypedArray`, `DataView`, `Buffer`) since `Object.freeze` throws on them.
10+
- Three granular opt-out flags on `CloneOptions`: `cycles`, `preservePrototype`, `copyDescriptors`. All default `true`. Composing all three `false` gives a fast plain-JSON path competitive with `rfdc`.
11+
- `CloneError` with `code: 'UNSUPPORTED_TYPE'` and `Error.cause` support. `clone` throws it on functions, `Promise`, `Intl.*`, `WeakMap`, `WeakSet`, and constructor functions.
12+
- Browser-safe — the Buffer branch reads `globalThis.Buffer` at module init, so the package works in non-Node bundles.
13+
14+
### Documentation
15+
- README ships with a full API reference, the comparison table vs `structuredClone` / `lodash.cloneDeep` / `rfdc` / `fast-copy`, and the rationale for the prototype + descriptor preservation contract.
16+
- `bench/baseline.md` documents the 1.0.0 numbers across five fixture buckets (flat-10, nested-100, large-1000, with-cycles, class-instances) on Apple M1 / Node 22.22.2.
17+
18+
### Configuration
19+
- TypeScript strict, ES modules + CJS dual build via `tsdown` targeting Node 22 LTS. Zero runtime dependencies.
20+
- `mitata` benchmark suite via `pnpm bench`. `fast-check` property-test suite alongside the unit tests.
21+
- `.github/workflows/ci.yml` calls the `coroboros/ci@v0` reusable workflow with OIDC Trusted Publisher and `npm provenance`.

CLAUDE.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,43 @@ Global rules (`~/.claude/rules/*`) inherit automatically — tech-standards, wri
88

99
## Tech Stack
1010
- TypeScript strict, ES modules + CJS dual build (tsdown)
11-
- Vitest for tests, Biome for lint/format
11+
- Vitest + `fast-check` for property tests, Biome for lint/format
12+
- `mitata` for benchmarks (`pnpm bench`)
1213
- Node.js 22 LTS
1314
- Zero runtime dependencies
1415

1516
## Commands
1617
- `pnpm build` — bundle ESM + CJS + types to `dist/`
17-
- `pnpm test` — run Vitest suite
18+
- `pnpm test` — run Vitest suite (97 tests, incl. property-based)
1819
- `pnpm lint` / `pnpm lint:fix` — Biome check
1920
- `pnpm typecheck` — tsc --noEmit
21+
- `pnpm bench` — build then run `bench/clone.bench.mjs`
2022
- `pnpm dev` — tsdown watch mode
2123

2224
## Important Files
23-
- `src/index.ts` — public entry: `clone`, `freeze`, `CloneOptions`
25+
- `src/index.ts` — public entry: `clone`, `freeze`, `CloneOptions`, `CloneError`, `CloneErrorCode`
2426
- `src/clone.ts` — deep clone with prototype + descriptor preservation, cycle-safe via WeakMap
25-
- `src/freeze.ts` — deep freeze (skips TypedArray + DataView)
26-
- `src/helpers.ts` — internal: `exists`, `is`, `getType` (NOT exported)
27-
- `tests/` — Vitest suites
27+
- `src/freeze.ts` — deep freeze (skips ArrayBufferView via `ArrayBuffer.isView`)
28+
- `src/error.ts``CloneError` class with `code` + `cause`
29+
- `src/helpers.ts` — internal: `exists`, `getType`, `primitives` (NOT exported)
30+
- `tests/` — one spec per source module + `clone.property.test.ts` for `fast-check` invariants
31+
- `bench/clone.bench.mjs` — mitata bench vs structuredClone, lodash, rfdc, fast-copy
32+
- `bench/baseline.md` — 1.0.0 numbers + regression budget
2833

2934
## Public API (1.0.0 contract)
3035
- `clone<T>(thing: T, options?: CloneOptions): T` — generic-preserving deep clone
3136
- `freeze<T>(thing: T): T` — recursive deep freeze
32-
- `CloneOptions``{ ignoreUndefinedProperties?: boolean }`
37+
- `CloneOptions``{ ignoreUndefinedProperties?, cycles?, preservePrototype?, copyDescriptors? }` — all booleans, all default `true` except `ignoreUndefinedProperties` which defaults `false`
38+
- `CloneError` — extends `Error`, exposes `code: CloneErrorCode`, supports `Error.cause`
39+
- `CloneErrorCode``'UNSUPPORTED_TYPE'`
3340

3441
## Rules
3542
- **NEVER** break the public API above. The signatures and semantics are the 1.0.0 contract.
3643
- **NEVER** add a runtime dependency. Zero-dep is a feature.
37-
- **NEVER** export `exists`, `is`, `getType` — they are internal helpers only.
44+
- **NEVER** export `exists`, `getType`, `primitives` — they are internal helpers only.
3845
- **NEVER** use `axios`, `request`, or `node-fetch` — use native `fetch` (Node 22+).
39-
- Cycle handling via WeakMap visited-cache is contract — never remove.
46+
- Cycle handling via WeakMap visited-cache is contract — never remove the default.
4047
- Run `pnpm lint && pnpm typecheck && pnpm test` before every commit.
48+
- Run `pnpm bench` against `bench/baseline.md` when touching `src/clone.ts` — no regression > 10 % at fixed feature set.
4149
- Scoped package — `publishConfig.access = "public"` is mandatory, do not remove.
50+
- **Git** — branch `main`; CI owns `npm publish` exclusively (tag-push triggers `ci.yml` → reusable workflow `coroboros/ci/.github/workflows/javascript-npm-packages.yml@v0` → OIDC Trusted Publisher with `npm provenance`, never manual — manual bypasses attestation and the pre-publish gates); run `pnpm lint && pnpm typecheck && pnpm test && pnpm build` locally before tagging; tag MUST equal `package.json` version (the reusable workflow pins `package.json` to the tag automatically); bump via `pnpm version patch|minor`; release body in `gh release create` stays minimal (no install snippet unless the install command changed). All other rules in `@~/.claude/rules/git-conventions.md` apply.

README.md

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
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.
1111

12+
[![ci](https://img.shields.io/github/actions/workflow/status/coroboros/clone/ci.yml?branch=main&style=flat-square&color=000000)](https://github.com/coroboros/clone/actions/workflows/ci.yml)
1213
[![npm](https://img.shields.io/npm/v/@coroboros/clone?style=flat-square&color=000000)](https://www.npmjs.com/package/@coroboros/clone)
1314
[![branch](https://img.shields.io/badge/branch-stable-000000?style=flat-square)](https://github.com/coroboros/clone)
1415
[![license](https://img.shields.io/badge/license-MIT-000000?style=flat-square)](https://opensource.org/licenses/MIT)
@@ -17,6 +18,12 @@ Deep clones objects while preserving the prototype chain, property descriptors,
1718

1819
</div>
1920

21+
## Why this exists
22+
23+
`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.
24+
25+
`@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.
26+
2027
## Requirements
2128

2229
- Node.js `>=22` LTS. Use [fnm](https://github.com/Schniz/fnm) for version management — Rust-based, faster than nvm.
@@ -88,10 +95,13 @@ The clone preserves the prototype chain, property descriptors (including non-enu
8895

8996
**Parameters**
9097

91-
| Option | Type | Default | Description |
92-
| ----------------------------------- | --------- | ------------ | ------------------------------------------------------------------------ |
93-
| `thing` | `T` | *(required)* | Value to clone. Any JavaScript value or object. |
94-
| `options.ignoreUndefinedProperties` | `boolean` | `false` | When `true`, omit properties whose value is `undefined`. Recursive. |
98+
| Option | Type | Default | Description |
99+
| ----------------------------------- | --------- | ------------ | ------------------------------------------------------------------------------------------------- |
100+
| `thing` | `T` | *(required)* | Value to clone. Any JavaScript value or object. |
101+
| `options.ignoreUndefinedProperties` | `boolean` | `false` | When `true`, omit properties whose value is `undefined`. Recursive. |
102+
| `options.cycles` | `boolean` | `true` | When `false`, skips the WeakMap visited cache. Caller asserts no cycles. Faster, infinite-recursion if wrong. |
103+
| `options.preservePrototype` | `boolean` | `true` | When `false`, custom objects flatten to plain `{}` (lose `instanceof` and method inheritance). |
104+
| `options.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. |
95105

96106
**Returns** — a deep copy of `thing`, typed as `T`.
97107

@@ -105,11 +115,12 @@ Native types clone with type-specific semantics:
105115
- `RegExp``source` and `flags` preserved.
106116
- `TypedArray` (`Int8Array` through `Float64Array`) — cloned via the constructor.
107117
- `DataView` — buffer copied; `byteOffset` and `byteLength` preserved.
108-
- `Buffer` — bytes copied via `Buffer.allocUnsafe` and `Buffer#copy`.
118+
- `Buffer` — bytes copied via `Buffer.allocUnsafe` and `Buffer#copy`. Browser bundles skip this branch via a runtime guard; the type is Node-only.
109119
- `ArrayBuffer` — sliced.
110120
- `Error` and subclasses (`EvalError`, `RangeError`, `ReferenceError`, `SyntaxError`, `TypeError`, `URIError`) — own properties copied with full descriptors.
111121
- Boxed primitives (`new String()`, `new Number()`, `new Boolean()`) — wrapper recreated with attached properties.
112122
- Custom objects — created via `Object.create(getPrototypeOf(source))`, then own descriptors applied.
123+
- Null-prototype objects (`Object.create(null)`) — preserved with the null prototype.
113124

114125
#### Cycle handling
115126

@@ -125,9 +136,23 @@ c.self === c; // true
125136

126137
Shared references stay shared. A diamond input produces a diamond output, with each shared subtree cloned exactly once.
127138

139+
#### Fast clone for plain JSON-shaped data
140+
141+
Composing all three opt-out flags gives a `rfdc`-grade fast path for callers who know their data is plain and acyclic:
142+
143+
```ts
144+
const config = clone(largeJsonConfig, {
145+
cycles: false,
146+
preservePrototype: false,
147+
copyDescriptors: false,
148+
});
149+
```
150+
151+
See `bench/baseline.md` for the head-to-head numbers vs `structuredClone`, `lodash.cloneDeep`, `rfdc`, and `fast-copy`.
152+
128153
#### Unsupported types
129154

130-
The following inputs return `undefined`:
155+
The following inputs throw `CloneError` with `code: 'UNSUPPORTED_TYPE'`:
131156

132157
- Functions (sync, async, generator).
133158
- `Promise`.
@@ -164,9 +189,25 @@ Detection uses `ArrayBuffer.isView(thing)`.
164189
```ts
165190
type CloneOptions = {
166191
ignoreUndefinedProperties?: boolean;
192+
cycles?: boolean;
193+
preservePrototype?: boolean;
194+
copyDescriptors?: boolean;
167195
};
168196
```
169197

198+
### `CloneError`
199+
200+
```ts
201+
class CloneError extends Error {
202+
readonly code: CloneErrorCode;
203+
constructor(code: CloneErrorCode, message: string, options?: { cause?: unknown });
204+
}
205+
206+
type CloneErrorCode = 'UNSUPPORTED_TYPE';
207+
```
208+
209+
Inherits from `Error`. Supports `Error.cause` for wrapping. The `code` field is a stable string discriminant safe for runtime branching.
210+
170211
## Compared to alternatives
171212

172213
| Feature | `structuredClone` | `lodash.cloneDeep` | `rfdc` | `fast-copy` | **`@coroboros/clone`** |
@@ -179,7 +220,7 @@ type CloneOptions = {
179220
| `Error` subclasses with descriptors | partial | no | no | no | yes |
180221
| Functions, Promises, `WeakMap`, `WeakSet` | no | no | no | no | no (by design) |
181222

182-
The market gap is the prototype chain plus property descriptors. Class instances cloned with `structuredClone` lose their prototype and become plain objects. `lodash.cloneDeep` drops descriptor flags. ORM entities, builders, event emitters, and any custom-constructed state object stay intact through `clone`.
223+
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`.
183224

184225
## Contributing
185226

@@ -188,6 +229,7 @@ Bug reports and PRs welcome.
188229
- Open an issue before submitting non-trivial PRs.
189230
- Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
190231
- Run `pnpm lint && pnpm typecheck && pnpm test` before pushing.
232+
- Run `pnpm bench` against `bench/baseline.md` when touching `src/clone.ts` — no regression > 10 % at fixed feature set.
191233
- Target the `main` branch.
192234

193235
## License

bench/baseline.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Benchmark baseline
2+
3+
Apple M1, Node 22.22.2. Run `pnpm bench` to reproduce.
4+
5+
The default `clone` preserves cycles, prototype chain, and property descriptors —
6+
the three guarantees no other library on this list provides together. The "fast"
7+
variant turns all three guarantees off and competes with `rfdc` on plain data.
8+
9+
## Post-optim (1.0.0)
10+
11+
### `flat-10` — 10 numeric keys, depth 1
12+
13+
| Implementation | avg/iter |
14+
| ------------------ | ---------: |
15+
| `rfdc()` | 119.94 ns |
16+
| `fast-copy` | 226.09 ns |
17+
| `clone (fast)` | 337.66 ns |
18+
| `lodash.cloneDeep` | 660.23 ns |
19+
| `structuredClone` | 1.12 µs |
20+
| `clone (default)` | 2.57 µs |
21+
22+
### `nested-100` — depth 2, breadth 5 (~30 objects)
23+
24+
| Implementation | avg/iter |
25+
| ------------------ | ---------: |
26+
| `rfdc()` | 479.03 ns |
27+
| `fast-copy` | 948.08 ns |
28+
| `clone (fast)` | 1.04 µs |
29+
| `lodash.cloneDeep` | 2.48 µs |
30+
| `structuredClone` | 3.05 µs |
31+
| `clone (default)` | 7.59 µs |
32+
33+
### `large-1000` — depth 3, breadth 10 (~1110 objects)
34+
35+
| Implementation | avg/iter |
36+
| ------------------ | ---------: |
37+
| `rfdc()` | 15.87 µs |
38+
| `fast-copy` | 27.82 µs |
39+
| `clone (fast)` | 36.72 µs |
40+
| `lodash.cloneDeep` | 78.08 µs |
41+
| `structuredClone` | 75.67 µs |
42+
| `clone (default)` | 287.22 µs |
43+
44+
### `with-cycles` — 3-node cycle (A → B → C → A)
45+
46+
| Implementation | avg/iter | Notes |
47+
| ------------------ | ---------: | ------------------------------------------- |
48+
| `fast-copy` | 562.84 ns | |
49+
| `lodash.cloneDeep` | 1.21 µs | |
50+
| `clone (default)` | 2.36 µs | |
51+
| `rfdc()` || not run; default `rfdc()` does not handle cycles |
52+
| `clone (fast)` || not run; `cycles: false` overflows on cyclic input by spec |
53+
| `structuredClone` || not run; covered by the plain-data buckets |
54+
55+
### `class-instances` — 10 `Pet` instances with non-enumerable descriptor
56+
57+
| Implementation | avg/iter | Notes |
58+
| ------------------ | ---------: | ------------------------------------------- |
59+
| `rfdc()` | 654.58 ns | drops the prototype + the hidden descriptor |
60+
| `clone (fast)` | 863.44 ns | drops the prototype + the hidden descriptor |
61+
| `fast-copy` | 2.31 µs | drops the prototype + the hidden descriptor |
62+
| `lodash.cloneDeep` | 3.09 µs | drops the hidden descriptor |
63+
| `clone (default)` | 7.08 µs | preserves both |
64+
| `structuredClone` || throws on class instances with methods |
65+
66+
## Bundle size
67+
68+
| Format | Raw | Gzip |
69+
| ------ | --------: | ---------: |
70+
| ESM | 7.50 kB | 1.93 kB |
71+
| CJS | 7.62 kB | 1.97 kB |
72+
73+
## Why the default path is slower
74+
75+
The "default" column always preserves three things the other libraries either skip
76+
or do not support: the prototype chain, every property descriptor (including
77+
non-enumerable, accessor, and `configurable: false`), and reference cycles. Those
78+
three guarantees compose to call `Object.getPrototypeOf` + `Reflect.ownKeys` +
79+
`Reflect.getOwnPropertyDescriptor` + `Object.defineProperties` on every node.
80+
81+
When the caller drops those guarantees explicitly,
82+
`clone(value, { cycles: false, preservePrototype: false, copyDescriptors: false })`
83+
skips the descriptor walk and uses `for...in`. That is the "fast" column above.
84+
It lands within ~2× of `rfdc` on plain data and beats `lodash.cloneDeep`
85+
on every bucket.
86+
87+
## Going-forward target
88+
89+
**No regression > 10 % on any bucket at fixed feature set.** Deep-clone has more
90+
inherent V8 inline-cache volatility than tight per-element loops; the bar is
91+
loose enough to absorb it without flapping CI. Feature additions that legitimately
92+
cost time (cycle detection, descriptor preservation, etc.) reset the bar for the
93+
buckets they affect.

0 commit comments

Comments
 (0)