diff --git a/.changeset/smooth-nails-do.md b/.changeset/smooth-nails-do.md
new file mode 100644
index 0000000000..a845151cc8
--- /dev/null
+++ b/.changeset/smooth-nails-do.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx
index e2bdfb8e0e..82105dc375 100644
--- a/docs/content/docs/api-reference/index.mdx
+++ b/docs/content/docs/api-reference/index.mdx
@@ -17,6 +17,9 @@ All the functions and primitives that come with Workflow DevKit by package.
Next.js integration for Workflow DevKit that automatically configures bundling and runtime support.
+
+ Serialization symbols for custom class serialization in workflows.
+
Helpers for integrating AI SDK for building AI-powered workflows.
diff --git a/docs/content/docs/api-reference/meta.json b/docs/content/docs/api-reference/meta.json
index d046d7b20a..481a06207b 100644
--- a/docs/content/docs/api-reference/meta.json
+++ b/docs/content/docs/api-reference/meta.json
@@ -1,4 +1,4 @@
{
"title": "API Reference",
- "pages": ["...", "workflow-ai", "vitest"]
+ "pages": ["...", "workflow-serde", "workflow-ai", "vitest"]
}
diff --git a/docs/content/docs/api-reference/workflow-serde/index.mdx b/docs/content/docs/api-reference/workflow-serde/index.mdx
new file mode 100644
index 0000000000..80b377207f
--- /dev/null
+++ b/docs/content/docs/api-reference/workflow-serde/index.mdx
@@ -0,0 +1,52 @@
+---
+title: "@workflow/serde"
+---
+
+Serialization symbols for custom class serialization in Workflow DevKit.
+
+## Installation
+
+```package-install
+npm i @workflow/serde
+```
+
+## Overview
+
+By default, Workflow DevKit can serialize standard JavaScript types like primitives, objects, arrays, `Date`, `Map`, `Set`, and more. However, custom class instances are not serializable by default because the serialization system doesn't know how to reconstruct them.
+
+The `@workflow/serde` package provides two symbols that allow you to define custom serialization and deserialization logic for your classes, enabling them to be passed between workflow and step functions.
+
+## Symbols
+
+
+
+ Symbol for defining how to serialize a class instance to plain data.
+
+
+ Symbol for defining how to reconstruct a class instance from plain data.
+
+
+
+## Quick Example
+
+{/* @expect-error:2351 */}
+
+```typescript lineNumbers
+import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
+
+class Point {
+ constructor(public x: number, public y: number) {}
+
+ static [WORKFLOW_SERIALIZE](instance: Point) {
+ return { x: instance.x, y: instance.y };
+ }
+
+ static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
+ return new Point(data.x, data.y);
+ }
+}
+```
+
+
+For a complete guide on custom class serialization, see the [Serialization documentation](/docs/foundations/serialization#custom-class-serialization).
+
diff --git a/docs/content/docs/api-reference/workflow-serde/meta.json b/docs/content/docs/api-reference/workflow-serde/meta.json
new file mode 100644
index 0000000000..856b0fc93e
--- /dev/null
+++ b/docs/content/docs/api-reference/workflow-serde/meta.json
@@ -0,0 +1,3 @@
+{
+ "pages": ["...", "workflow-serialize", "workflow-deserialize"]
+}
diff --git a/docs/content/docs/api-reference/workflow-serde/workflow-deserialize.mdx b/docs/content/docs/api-reference/workflow-serde/workflow-deserialize.mdx
new file mode 100644
index 0000000000..6b2e84846c
--- /dev/null
+++ b/docs/content/docs/api-reference/workflow-serde/workflow-deserialize.mdx
@@ -0,0 +1,70 @@
+---
+title: WORKFLOW_DESERIALIZE
+---
+
+A symbol used to define custom deserialization for user-defined class instances. The static method should accept serialized data and return a new class instance.
+
+## Usage
+
+{/* @expect-error:2351 */}
+
+```typescript lineNumbers
+import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
+
+class Point {
+ constructor(public x: number, public y: number) {}
+
+ static [WORKFLOW_SERIALIZE](instance: Point) {
+ return { x: instance.x, y: instance.y };
+ }
+
+ static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
+ return new Point(data.x, data.y);
+ }
+}
+```
+
+## API Signature
+
+{/* @skip-typecheck */}
+
+```typescript
+static [WORKFLOW_DESERIALIZE](data: SerializableData): T
+```
+
+### Parameters
+
+
+
+### Returns
+
+The method should return a new instance of the class, reconstructed from the serialized data.
+
+## Requirements
+
+
+The method must be implemented as a **static** method on the class. Instance methods are not supported.
+
+
+- Both `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented together
+- The method receives the exact data that was returned by `WORKFLOW_SERIALIZE`
+- If `WORKFLOW_SERIALIZE` returns complex types (like `Map` or `Date`), they will be properly deserialized before being passed to this method
+
+
+This method runs inside the workflow context and is subject to the same constraints as `"use workflow"` functions:
+- No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.)
+- No non-deterministic operations (like `Math.random()` or `Date.now()`)
+- No external network calls
+
+Keep this method simple and focused on reconstructing the instance from the provided data.
+
diff --git a/docs/content/docs/api-reference/workflow-serde/workflow-serialize.mdx b/docs/content/docs/api-reference/workflow-serde/workflow-serialize.mdx
new file mode 100644
index 0000000000..5c70c95e1d
--- /dev/null
+++ b/docs/content/docs/api-reference/workflow-serde/workflow-serialize.mdx
@@ -0,0 +1,75 @@
+---
+title: WORKFLOW_SERIALIZE
+---
+
+A symbol used to define custom serialization for user-defined class instances. The static method should accept an instance and return serializable data.
+
+## Usage
+
+{/* @expect-error:2351 */}
+
+```typescript lineNumbers
+import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
+
+class Point {
+ constructor(public x: number, public y: number) {}
+
+ static [WORKFLOW_SERIALIZE](instance: Point) {
+ return { x: instance.x, y: instance.y };
+ }
+
+ static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
+ return new Point(data.x, data.y);
+ }
+}
+```
+
+## API Signature
+
+{/* @skip-typecheck */}
+
+```typescript
+static [WORKFLOW_SERIALIZE](instance: T): SerializableData
+```
+
+### Parameters
+
+
+
+### Returns
+
+The method should return serializable data. This can be:
+
+- Primitives (`string`, `number`, `boolean`, `null`, `undefined`, `bigint`)
+- Plain objects with serializable values
+- Arrays of serializable values
+- Built-in serializable types (`Date`, `Map`, `Set`, `RegExp`, `URL`, etc.)
+- Other custom classes that implement serialization
+
+## Requirements
+
+
+The method must be implemented as a **static** method on the class. Instance methods are not supported.
+
+
+- Both `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented together
+- The returned data must itself be serializable
+- The SWC compiler plugin automatically detects and registers classes that implement these symbols
+
+
+This method runs inside the workflow context and is subject to the same constraints as `"use workflow"` functions:
+- No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.)
+- No non-deterministic operations (like `Math.random()` or `Date.now()`)
+- No external network calls
+
+Keep this method simple and focused on extracting data from the instance.
+
diff --git a/docs/content/docs/foundations/serialization.mdx b/docs/content/docs/foundations/serialization.mdx
index d94ee6ea73..5b9ef0962d 100644
--- a/docs/content/docs/foundations/serialization.mdx
+++ b/docs/content/docs/foundations/serialization.mdx
@@ -56,6 +56,10 @@ These types have special handling and are explained in detail in the sections be
- `ReadableStream`
- `WritableStream`
+**Custom Classes:**
+
+- Class instances that implement [`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`](#custom-class-serialization)
+
## Streaming
`ReadableStream` and `WritableStream` are supported as serializable types with special handling. These streams can be passed between workflow and step functions while maintaining their streaming capabilities.
@@ -121,6 +125,213 @@ export async function fetch(...args: Parameters) {
This allows you to make HTTP requests directly in workflow functions while maintaining deterministic replay behavior through automatic caching.
+## Custom Class Serialization
+
+By default, custom class instances cannot be serialized because the serialization system doesn't know how to reconstruct them. You can make your classes serializable by implementing two static methods using special symbols from the `@workflow/serde` package.
+
+### Basic Example
+
+{/* @expect-error:2351 */}
+
+```typescript title="workflows/custom-class.ts" lineNumbers
+import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; // [!code highlight]
+
+class Point {
+ constructor(
+ public x: number,
+ public y: number
+ ) {}
+
+ // Define how to serialize an instance to plain data
+ static [WORKFLOW_SERIALIZE](instance: Point) { // [!code highlight]
+ return { x: instance.x, y: instance.y }; // [!code highlight]
+ } // [!code highlight]
+
+ // Define how to reconstruct an instance from plain data
+ static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) { // [!code highlight]
+ return new Point(data.x, data.y); // [!code highlight]
+ } // [!code highlight]
+}
+```
+
+Once you've implemented these methods, instances of your class can be passed between workflow and step functions:
+
+{/* @expect-error:2351 */}
+
+```typescript title="workflows/geometry.ts" lineNumbers
+import { Point } from "./custom-class";
+
+export async function geometryWorkflow() {
+ "use workflow";
+
+ const point = new Point(10, 20);
+ // Point is serialized automatically
+ const doubled = await doublePoint(point); // [!code highlight]
+
+ console.log(doubled.x, doubled.y); // 20, 40
+ return doubled;
+}
+
+async function doublePoint(point: Point) {
+ "use step";
+ // Returns a new Point instance
+ return new Point(point.x * 2, point.y * 2); // [!code highlight]
+}
+```
+
+### How It Works
+
+1. **`WORKFLOW_SERIALIZE`**: A static method that receives a class instance and returns serializable data (primitives, plain objects, arrays, etc.)
+
+2. **`WORKFLOW_DESERIALIZE`**: A static method that receives the serialized data and returns a new class instance
+
+3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization
+
+### Requirements
+
+
+Both methods must be implemented as **static** methods on the class. Instance methods are not supported.
+
+
+- The data returned by `WORKFLOW_SERIALIZE` must itself be serializable (see [Supported Serializable Types](#supported-serializable-types))
+- Both symbols must be implemented together - a class with only one will not be serializable
+
+
+The `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` methods run inside the workflow context and are subject to the same constraints as `"use workflow"` functions. This means:
+- No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.)
+- No non-deterministic operations (like `Math.random()` or `Date.now()`)
+- No external network calls
+
+Keep these methods simple and focused on data transformation only.
+
+
+### Complex Example
+
+A class that uses Node.js APIs or other non-deterministic operations cannot be used directly inside a workflow function. The recommended approach is to make the class workflow-compatible by adding `"use step"` to its instance methods. The SWC compiler will strip the method bodies from the workflow bundle and replace them with proxy functions that invoke the method as a step — with full Node.js runtime access. The `this` context (the class instance) is automatically serialized and deserialized across the workflow/step boundary.
+
+This requires the class to implement `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`, so that the instance can be passed to the step execution context.
+
+{/* @expect-error:2351 */}
+
+```typescript title="workflows/order.ts" lineNumbers
+import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; // [!code highlight]
+import { db } from "../lib/db";
+
+class Order {
+ constructor(
+ public id: string,
+ public items: Map,
+ public createdAt: Date
+ ) {}
+
+ // Custom serialization — data must be serializable types
+ static [WORKFLOW_SERIALIZE](instance: Order) { // [!code highlight]
+ return { // [!code highlight]
+ id: instance.id, // [!code highlight]
+ items: instance.items, // Map is serializable // [!code highlight]
+ createdAt: instance.createdAt, // Date is serializable // [!code highlight]
+ }; // [!code highlight]
+ } // [!code highlight]
+
+ static [WORKFLOW_DESERIALIZE](data: { // [!code highlight]
+ id: string; // [!code highlight]
+ items: Map; // [!code highlight]
+ createdAt: Date; // [!code highlight]
+ }) { // [!code highlight]
+ return new Order(data.id, data.items, data.createdAt); // [!code highlight]
+ } // [!code highlight]
+
+ // Methods without "use step" run in the workflow context
+ // and must follow the same constraints as workflow functions
+ total(): number {
+ let sum = 0;
+ for (const quantity of this.items.values()) {
+ sum += quantity;
+ }
+ return sum;
+ }
+
+ // Instance methods with "use step" run as step functions
+ // with full Node.js access — `this` is automatically serialized
+ async save(): Promise {
+ "use step"; // [!code highlight]
+ await db.orders.insert({ // [!code highlight]
+ id: this.id, // [!code highlight]
+ items: Object.fromEntries(this.items), // [!code highlight]
+ createdAt: this.createdAt, // [!code highlight]
+ }); // [!code highlight]
+ }
+
+ async sendConfirmation(email: string): Promise {
+ "use step"; // [!code highlight]
+ const res = await fetch("https://api.example.com/email", { // [!code highlight]
+ method: "POST", // [!code highlight]
+ body: JSON.stringify({ // [!code highlight]
+ to: email, // [!code highlight]
+ orderId: this.id, // [!code highlight]
+ itemCount: this.items.size, // [!code highlight]
+ }), // [!code highlight]
+ }); // [!code highlight]
+ const { messageId } = await res.json();
+ return messageId;
+ }
+}
+```
+
+The class can then be used naturally inside a workflow function. Instance methods marked with `"use step"` are each executed as a step — with automatic caching, retry semantics, and full Node.js runtime access. Methods _without_ `"use step"` run directly in the workflow context, so they must follow the same constraints as workflow functions:
+
+{/* @expect-error:2693 */}
+
+```typescript title="workflows/process-order.ts" lineNumbers
+export async function processOrderWorkflow(
+ orderId: string,
+ items: Map,
+ email: string
+) {
+ "use workflow";
+
+ const order = new Order(orderId, items, new Date()); // [!code highlight]
+
+ // Runs in the workflow context — no "use step" needed
+ const itemCount = order.total(); // [!code highlight]
+
+ // Each "use step" instance method call runs as a separate step
+ await order.save(); // [!code highlight]
+ const messageId = await order.sendConfirmation(email); // [!code highlight]
+
+ return { orderId, itemCount, messageId };
+}
+```
+
+Note that [pass-by-value semantics](#pass-by-value-semantics) also apply to the `this` context of `"use step"` instance methods. Modifying instance properties inside a step method will not affect the original instance in the workflow. If you need to update instance state, return `this` from the step method and re-assign the variable in the workflow:
+
+{/* @expect-error:2351 */}
+
+```typescript title="workflows/order.ts" lineNumbers
+export class Order {
+ // ...
+
+ async addItem(name: string, quantity: number): Promise {
+ "use step";
+ this.items.set(name, quantity);
+ return this; // [!code highlight]
+ }
+}
+```
+
+{/* @expect-error:2693,2552,2304 */}
+
+```typescript title="workflows/process-order.ts" lineNumbers
+export async function processOrderWorkflow() {
+ "use workflow";
+
+ let order = new Order(orderId, items, new Date());
+
+ // Re-assign to capture the updated instance
+ order = await order.addItem("Widget", 3); // [!code highlight]
+}
+```
+
## Pass-by-Value Semantics
**Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow.
diff --git a/packages/docs-typecheck/src/extractor.ts b/packages/docs-typecheck/src/extractor.ts
index 2be7b306a3..729b9f69c7 100644
--- a/packages/docs-typecheck/src/extractor.ts
+++ b/packages/docs-typecheck/src/extractor.ts
@@ -2,7 +2,9 @@ import type { CodeSample } from './types.js';
const CODE_BLOCK_REGEX =
/```(typescript|ts|javascript|js)(?:[^\S\n]+[^\n]*)?\n([\s\S]*?)```/g;
-const EXPECT_ERROR_REGEX = //;
+// Support both HTML comments () and MDX comments ({/* @expect-error:2351 */})
+const EXPECT_ERROR_REGEX =
+ /(?:|\{\/\*\s*@expect-error:([0-9,\s]+)\s*\*\/\})/;
// Match entire line comments with [!code ...] including any trailing text
const HIGHLIGHT_COMMENT_REGEX = /\s*\/\/\s*\[!code[^\]]*\].*$/gm;
// Match ellipsis patterns indicating incomplete code: standalone "...", "// ...", or "/* ... */"
@@ -78,7 +80,9 @@ function getMarkersBeforeBlock(
const textBetween = lookbackText.substring(lastIndex);
// Only apply if no other code block between marker and this block
if (!textBetween.includes('```')) {
- expectedErrors = expectMatch[1]
+ // Use whichever capture group matched (HTML or MDX format)
+ const errorCodes = expectMatch[1] || expectMatch[2];
+ expectedErrors = errorCodes
.split(',')
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));