@aedge-io/typed-clone
v1.0.0
Published
Type-safe, performant and extensible clone implementation
Maintainers
Readme
typed-clone
Type-safe, performant, and extensible clone implementation.
Motivation
This library was initially developed to provide a type-safe alternative to the naive structuredClone-based implementation in grugway.
- Type-safe: Clearly encodes types that can and cannot be meaningfully cloned through the type system. Not-cloneable types get returned as
Ref<T>explicitly. - Performant: Fast enough for the 20% of data types that make up 80% of real-world usage.
structuredClonefallback for the rest (e.g. typed arrays). - Extensible: Simple, symbol-based clone protocol for custom types.
Use Cases
This library particularly shines when referential transparency and infallibility are desired, or when dealing with heterogeneous and complex data that usually requires hand-rolled copy/clone implementations. typed-clone offers a good baseline implementation in those cases.
The custom clone protocol also allows for a seamless interaction of standard data types with your custom types or domain model.
Quick Start
Runtime Requirements
- Bun: ≥1.0.0
- Deno: ≥1.14
- Node.js: ≥17.0.0
- Browsers: Support
structuredClone
Installation
Node.js / Bun:
(bun | (p)npm) add @aedge-io/typed-cloneDeno:
deno add jsr:@aedge-io/typed-cloneUsage
Simple
import { clone } from "@aedge-io/typed-clone";
const clonedRec = clone({ msg: "hello there!" }); // { msg: string }
const clonedFn = clone(() => "hello there again!"); // Ref<() => string>Complex
import { Clone, clone, Cloneable, CloneOptions } from "@aedge-io/typed-clone";
class NotCloneable {
constructor(readonly name: string, private age: number) {}
greet() {
return `Hi, I am ${this.name} and ${this.age} years old.`;
}
}
class Point { // implements Cloneable<Point>
constructor(private x: number, private y: number) {}
[Clone](opts?: CloneOptions) {
return new Point(this.x, this.y);
}
}
const randInt = () => Math.floor(Math.random() * 100);
const uintArray = new Uint8Array(new ArrayBuffer(4));
uintArray.set([0, 1, 2, 3]);
const meta = {
createdAt: new Date(),
};
const original = {
metadata: meta,
handlers: new Map([["rand", randInt]]),
primitives: ["string", 42, true, BigInt(9001), Symbol("foo")] as const,
ref: new NotCloneable("Bob", 71),
points: {
unique: new Set([new Point(0, 1), new Point(1, 2)]),
metadata: meta,
},
buf: uintArray,
circularRef: {},
};
original.circularRef = original;
const cloned = clone(original, { transfer: [original.buf.buffer] });
// cloned = {
// metadata: {
// createdAt: Date;
// };
// handlers: Map<string, Ref<() => number>>;
// primitives: readonly [string, number, boolean, bigInt, Ref<unique symbol>];
// ref: Ref<NotCloneable>;
// points: {
// unique: Set<Point>; /* `Point` supports clone protocol */
// metadata: {
// createdAt: Date;
// };
// };
// buf: Uint8Array<ArrayBuffer>;
// circularRef: { ... };
// };
console.log("deep clone:", cloned !== original);
console.log("metadata cloned:", cloned.metadata !== original.metadata);
console.log("date cloned:", +cloned.metadata.createdAt === +meta.createdAt);
console.log("map cloned:", cloned.handlers !== original.handlers);
console.log("fn is ref:", cloned.handlers.get("rand") === randInt);
console.log("array cloned:", cloned.primitives !== original.primitives);
console.log("symbol is ref:", cloned.primitives[4] === original.primitives[4]);
console.log("class is ref:", cloned.ref === original.ref);
console.log("set cloned:", cloned.points.unique !== original.points.unique);
console.log("buf transferred:", original.buf.buffer.byteLength === 0);
console.log("circular ref preserved:", cloned.circularRef === cloned);
console.log(
"shared refs preserved:",
cloned.metadata === cloned.points.metadata,
);Performance
clone =
clone(value)(default, shared-ref cache)
clone (nc) =
clone(value, { preserveRefs: false })
| Benchmark | clone | ops/s | clone (nc) | ops/s | | ------------------------------------- | -------: | --------: | ---------: | --------: | | Plain record (8 keys) | 257.1 ns | 3,890,000 | 203.8 ns | 4,908,000 | | Plain record (64 keys) | 2.3 µs | 440,400 | 2.2 µs | 463,400 | | Plain record (256 keys) | 23.0 µs | 43,520 | 21.7 µs | 46,020 | | Nested records (d=4, 16 leaves) | 4.3 µs | 232,000 | 2.8 µs | 351,900 | | Nested records (d=8, 256 leaves) | 77.3 µs | 12,940 | 47.6 µs | 21,030 | | Nested records (d=12, 4096 leaves) | 1.3 ms | 745 | 790.3 µs | 1,265 | | Array<primitive> (n=256) | 317.2 ns | 3,152,000 | 333.8 ns | 2,996,000 | | Array<primitive> (n=8192) | 9.0 µs | 111,300 | 8.9 µs | 112,900 | | Array<record> (n=256) | 39.4 µs | 25,370 | 24.5 µs | 40,830 | | Array<record> (n=8192) | 1.4 ms | 720 | 757.0 µs | 1,321 | | Map<string,record> (n=256) | 47.4 µs | 21,100 | 35.4 µs | 28,290 | | Set<record> (n=256) | 47.1 µs | 21,230 | 35.2 µs | 28,380 | | Real: Frontend state slice | 1.3 µs | 758,900 | 941.4 ns | 1,062,000 | | Real: JSON Schema (32 props) | 19.5 µs | 51,230 | 12.7 µs | 78,500 | | Real: API collection (32 items) | 96.4 µs | 10,370 | 75.4 µs | 13,260 | | Real: Agent session (32 turns) | 50.2 µs | 19,930 | 33.7 µs | 29,690 | | Real: Normalized store (256 entities) | 111.2 µs | 8,992 | 103.0 µs | 9,708 | | Real: Dashboard data (8K rows) | 349.9 µs | 2,858 | 322.3 µs | 3,103 |
By default, typed-clone keeps track of object references to support shared and circular references. The overhead is most pronounced for small data structures. By disabling it, clone operations can be up to ~50% faster.
For a comprehensive write-up including memory overhead and comparison to rfdc and structuredClone, see docs.
Your mileage may vary though! Run the full benchmark suite with deno bench.
Security
Unlike similar packages, typed-clone guards against primitive prototype poisoning. However, this protection does not extend to prototype pollution in general, since the mitigations are quite runtime-dependent.
Caveats
Given the structural nature of TypeScript's type system, certain edge-case subclasses currently don't get inferred correctly. Check out the docs for a comprehensive overview.
License
MIT License — see LICENSE.md
