Skip to main content

Introduction

Zero-dependency serialization with full-fidelity type support for complex JavaScript types including cycles, Errors, Request/Response, special numbers, and more.

Installation

npm install @lumenize/structured-clone

Supported Types

While this package was originally optimized for the Cloudflare edge compute platform, nothing in it is Cloudflare-specific. All tests run in three environments: Node.js, headless Chromium browser, and simulated Cloudflare Workers. Our primary use case includes browser clients, and the full test suite passes in all environments.

Not a Cloudflare user? Feel free to ignore the first three columns in the comparison table below.

TypeWorkers RPCDO StorageCap'n WebLumenizeNotes
Cycles & Aliases✅→❌✅→❌Cap'n Web throws error
Cloudflare plans to remove
Primitives
undefined
null
Special Numbers
NaN
Infinity
-Infinity
Built-in Types
BigInt
Date
RegExp
Map✅⚠️✅⚠️✅⚠️Object keys lose identity
Set✅⚠️✅⚠️✅⚠️Object values lose identity
ArrayBuffer
Uint8Array
Errors
Error (thrown)N/A⚠️Cap'n Web loses name and remote stack
Error (value)⚠️⚠️Cap'n Web loses name and remote stack
Web API Types
Request
Response
HeadersCap'n Web: type annotation only, no serialization
URL
Streams
ReadableStreamCap'n Web: "may be added"
WritableStreamLumenize: "just use WebSockets"

Notes:

  • ⚠️ Map/Set with Object Keys/Values: While Map and Set are fully supported, object identity behavior depends on serialization boundaries:

    • Identity preserved: Within a single RPC call (Workers RPC, @lumenize/rpc) or single stringify()/parse() cycle
    • Identity lost: Across separate storage operations (DO Storage put()/get())

    See Map and Set Behavior for detailed explanation and patterns.

Key Features

  • Platform Independent: Works in Node.js, browsers, Deno, Bun, and Cloudflare Workers
  • Full Type Support: All primitives, special numbers (NaN, Infinity, -Infinity), built-in types (Date, RegExp, Map, Set), typed arrays, and Web API objects (via RequestSync/ResponseSync)
  • Error Fidelity: Preserves stack traces, cause chains, custom properties, and subclass types (including custom Error classes!)
  • Circular References: Automatically handled with $lmz reference markers
  • Zero Dependencies: No external packages required
  • Synchronous API: No async/await needed for serialization

Basic Usage

The core API is simple: stringify() to serialize, parse() to deserialize.

Both functions are synchronous - no await needed!

import { stringify, parse } from '@lumenize/structured-clone';

const user = {
name: 'Alice',
age: 30,
active: true
};

const serialized = stringify(user);
const restored = parse(serialized);

expect(restored).toEqual(user);

This works with complex nested structures, arrays, and all types shown in the table above.

Key Features with Examples

Error Chains with cause

Error chaining is fully preserved, including nested causes:

const networkError = new Error('Connection timeout');
const appError = new Error('Failed to fetch user data', {
cause: networkError
});

const restored = parse(stringify(appError));

expect(restored.message).toBe('Failed to fetch user data');
expect(restored.cause).toBeInstanceOf(Error);
expect(restored.cause.message).toBe('Connection timeout');

Custom Error Properties

Add arbitrary properties to Errors—they're all preserved:

const apiError: any = new Error('API request failed');
apiError.statusCode = 500;
apiError.endpoint = '/api/users';

const restored: any = parse(stringify(apiError));

expect(restored.message).toBe('API request failed');
expect(restored.statusCode).toBe(500);

Error Subclass Preservation

All standard JavaScript Error types are automatically preserved with correct instanceof behavior:

Standard Error Types (Built-in):

  • Error - Base error type
  • TypeError - Type-related errors
  • RangeError - Value out of range
  • ReferenceError - Invalid references
  • SyntaxError - Syntax parsing errors
  • URIError - URI encoding/decoding errors
  • EvalError - Eval-related errors
  • AggregateError - Multiple errors combined

Example:

const typeError = new TypeError('Expected string, got number');
const restored = parse(stringify(typeError));

expect(restored).toBeInstanceOf(TypeError); // ✅ Type preserved!
expect(restored).toBeInstanceOf(Error); // ✅ Also an Error
expect(restored.name).toBe('TypeError');
expect(restored.message).toBe('Expected string, got number');

How it works:

We use dynamic constructor lookup via globalThis[error.name]:

  1. Error is serialized with its name property (e.g., "TypeError")
  2. During deserialization, we look up globalThis["TypeError"]
  3. If found, we create a new instance of that constructor
  4. If not found, we fall back to base Error (but preserve the name property)

Custom Error Classes:

Custom Error classes work too, but require explicit global registration since module imports are not in global scope:

// Your custom Error class (in a module)
export class ValidationError extends Error {
name = 'ValidationError';
constructor(message: string, public field: string) {
super(message);
}
}

To enable type preservation, register it globally:

// Register on globalThis so deserializer can find it
(globalThis as any).ValidationError = ValidationError;

Now serialization preserves the type:

const error = new ValidationError('Invalid email', 'email');
const restored = parse(stringify(error));

// ...
expect(restored instanceof ValidationError).toBe(true); // ✅ true!
expect((restored as any).field).toBe('email'); // ✅ 'email' (custom property preserved)
expect(restored.name).toBe('ValidationError'); // ✅ 'ValidationError'

Without global registration:

If you don't register the class globally, deserialization falls back to base Error but still preserves custom properties and the name:

Web API Objects Example

Serialize RequestSync/ResponseSync objects for storage, queues, or RPC:

import { RequestSync, ResponseSync } from '@lumenize/structured-clone';

const apiData = {
incomingRequest: new RequestSync('https://api.example.com/endpoint', {
method: 'POST',
body: 'request data'
}),
outgoingResponse: new ResponseSync('response data', {
status: 200
}),
timestamp: Date.now()
};

const restored = parse(stringify(apiData));

expect(restored.incomingRequest).toBeInstanceOf(RequestSync);
// ...

Note: Use RequestSync/ResponseSync instead of native Request/Response for synchronous body access and serialization support.

Advanced API

The package provides a three-tier API for different use cases:

Tier 1: stringify() / parse() - JSON String I/O

The main API for most use cases. Serializes to/from JSON strings:

const jsonString = stringify(complexObject);  // Returns JSON string
const restored = parse(jsonString); // Reconstructs from JSON string

Tier 2: preprocess() / postprocess() - Intermediate Format

For scenarios where you want a simple object format without stringification:

const intermediate = preprocess(complexObject);  // Returns { root, objects }
const restored = postprocess(intermediate); // Reconstructs from object

Use for: MessagePort, BroadcastChannel, BJSON, Cloudflare: Workers RPC, Durable Object KV Storage, Queues, etc.

Performance

Binary formats are generally smaller, but this implementation is generally faster at serialization for similar type support. We're particularly proud of our optimized one-pass algorithm for alias and cycle detection.

Limitations

  • Symbols: Cannot be serialized (throws TypeError)
  • Functions: Converted to internal markers (preserved structure, not executable)
  • Signed Zero: -0 becomes +0 (What is -0 anyway?)
  • Streams: ReadableStream/WritableStream not supported

Attribution

Inspired by:

See ATTRIBUTIONS.md for full attribution details.