Before Workflow v4.0.1-beta.49, building durable functions meant making a painful choice: either flatten your domain objects to plain JSON, or manually reconstruct them in every step.
class Order {
constructor(
public orderId: string,
public items: Array<{ sku: string; price: number; quantity: number }>
) {}
calculateTotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
applyDiscount(percent: number): number {
return this.calculateTotal() * (1 - percent / 100);
}
}
// Your workflow code looked like this:
async function processOrder() {
"use workflow";
const order = new Order("ord_123", items);
// Serialize to plain object before suspension
const orderData = {
orderId: order.orderId,
items: order.items,
// Lost all methods!
};
await sleep("1d");
// Manual reconstruction after suspension
const reconstructed = new Order(orderData.orderId, orderData.items);
console.log(reconstructed.calculateTotal()); // Had to rebuild the class
}Problems:
- 🔴 Lost all methods during serialization
- 🔴 Manual conversion to/from plain objects
- 🔴 Type casts everywhere
- 🔴 Duplicated reconstruction logic in every step
- 🔴 Easy to forget to restore state correctly
With the new @workflow/serde package, your domain classes just work across workflow suspensions.
class Order {
constructor(
public orderId: string,
public customerId: string,
public items: Array<{ sku: string; price: number; quantity: number }> = [],
public discountPercent: number = 0,
public shippingZone: 'domestic' | 'international' = 'domestic'
) {}
calculateSubtotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
applyDiscount(percent: number): void {
this.discountPercent = percent;
}
getDiscountAmount(): number {
return this.calculateSubtotal() * (this.discountPercent / 100);
}
calculateShipping(): number {
const subtotal = this.calculateSubtotal();
if (this.shippingZone === 'domestic') {
return subtotal > 5000 ? 0 : 500; // Free shipping over $50
}
return 1500; // $15 international shipping
}
calculateTotal(): number {
const subtotal = this.calculateSubtotal();
const discount = this.getDiscountAmount();
const shipping = this.calculateShipping();
return subtotal - discount + shipping;
}
getSummary(): string {
return `Order ${this.orderId}: ${this.items.length} items, Total: $${(this.calculateTotal() / 100).toFixed(2)}`;
}
hasFreeShipping(): boolean {
return this.shippingZone === 'domestic' && this.calculateSubtotal() > 5000;
}
}
// Your workflow code now looks like this:
async function processOrder() {
"use workflow";
const order = new Order("ord_123", "cus_456", [
{ sku: "LAPTOP", price: 120000, quantity: 1 },
{ sku: "MOUSE", price: 3000, quantity: 2 }
]);
order.applyDiscount(15); // 15% off
await sleep("1d"); // Wait for payment confirmation
// Methods just work! ✨
console.log(order.calculateTotal()); // Works!
console.log(order.getSummary()); // Works!
console.log(order.hasFreeShipping()); // Works!
}Benefits:
- ✅ Methods work after
sleep()and workflow suspensions - ✅ No manual serialization/deserialization
- ✅ Full type safety maintained
- ✅ Business logic stays in domain classes where it belongs
- ✅ Zero boilerplate
Here's a concrete example showing how custom serialization makes workflows cleaner:
async function abandonedCartWorkflow(userId: string) {
"use workflow";
// User adds items to cart
const cart = new Order("ord_cart_123", userId, [
{ sku: "SHIRT", price: 3000, quantity: 2 },
{ sku: "PANTS", price: 5000, quantity: 1 }
]);
// Wait 24 hours
await sleep("24h");
// Send reminder email (cart object still has all methods!)
await sendEmail(userId, {
subject: "You left items in your cart!",
items: cart.items,
total: cart.calculateTotal() // Method works!
});
// Wait for user to return
const { discountCode } = await createHook<{ discountCode?: string }>();
// Apply discount if provided
if (discountCode === "COMEBACK20") {
cart.applyDiscount(20);
}
// Process payment
const finalTotal = cart.calculateTotal(); // All methods still work!
await chargeCustomer(userId, finalTotal);
console.log(cart.getSummary()); // "Order ord_cart_123: 2 items, Total: $88.00"
}Before custom serialization, you'd need to:
- Extract cart data to plain object before each
await - Manually reconstruct Order instance after each suspension
- Duplicate calculation logic or risk errors
- Add type casts everywhere
After custom serialization, your code is:
- 60% shorter
- Type-safe throughout
- Easier to maintain
- Less error-prone
The new @workflow/serde package handles serialization automatically:
// 1. Before suspension: serialize
const order = new Order("ord_123", "cus_456", items);
// Workflow internally serializes the Order instance
// 2. During suspension: stored as JSON with class metadata
// {
// __className: "Order",
// orderId: "ord_123",
// customerId: "cus_456",
// items: [...],
// ...
// }
// 3. After resumption: deserialize + restore prototype
// Workflow automatically reconstructs the Order instance
// Methods are restored via Object.setPrototypeOf()You don't have to think about any of this—it just works.
Here's a test demonstrating that serialization preserves methods across suspensions:
import { describe, it, expect } from 'bun:test';
describe('Custom Class Serialization', () => {
function simulateWorkflowSerialization(order: Order): Order {
// Simulate what Workflow does internally
const serialized = JSON.stringify(order);
const deserialized = JSON.parse(serialized);
return Object.setPrototypeOf(deserialized, Order.prototype);
}
it('should preserve Order methods after serialization', () => {
const order = new Order('ord_456', 'cus_789', [
{ sku: 'LAPTOP', price: 120000, quantity: 1 },
{ sku: 'MOUSE', price: 3000, quantity: 2 }
]);
order.applyDiscount(15);
const totalBefore = order.calculateTotal();
// Simulate workflow suspension and resumption
const resumed = simulateWorkflowSerialization(order);
// All methods still work!
expect(resumed.calculateTotal()).toBe(totalBefore);
expect(resumed.calculateSubtotal()).toBe(126000);
expect(resumed.getDiscountAmount()).toBe(18900);
expect(resumed.hasFreeShipping()).toBe(true);
});
it('should handle complex workflow across multiple suspensions', () => {
let order = new Order('ord_123', 'cus_456', [
{ sku: 'WIDGET', price: 8000, quantity: 3 }
]);
// Suspension 1
order = simulateWorkflowSerialization(order);
order.applyDiscount(10);
// Suspension 2
order = simulateWorkflowSerialization(order);
order.items.push({ sku: 'ADDON', price: 1000, quantity: 2 });
// Suspension 3
order = simulateWorkflowSerialization(order);
// Everything still works!
expect(order.calculateTotal()).toBe(23400);
expect(order.hasFreeShipping()).toBe(true);
});
});Custom serialization works with:
- ✅ Domain classes (Order, User, Product, etc.)
- ✅ Date objects - No more
.toISOString()conversions - ✅ Maps and Sets - Native data structures just work
- ✅ BigInt - For financial calculations without precision loss
- ✅ Custom collections - Your own data structures
- ✅ API clients - Clients with config and auth persist across steps
The Workflow dashboard now shows custom class instances with proper class names:
Run Details
├─ Step 1: createOrder
│ └─ Result: Order { orderId: "ord_123", items: [...], ... }
├─ Step 2: applyDiscount (after 24h sleep)
│ └─ Input: Order { discountPercent: 20, ... }
└─ Step 3: chargeCustomer
└─ Input: 23400 (from order.calculateTotal())
Previously, everything showed as generic Object - now you see actual class names.
npm install workflow@latest
# or
bun add workflow@latest// Before: Manual serialization
const orderData = { id: order.id, items: order.items };
await sleep("1d");
const order = new Order(orderData.id, orderData.items);
// After: Just use the class
const order = new Order("ord_123", items);
await sleep("1d");
order.calculateTotal(); // Just works!Delete all your manual serialization/deserialization code. Let Workflow handle it.
Great fit:
- ✅ E-commerce orders with pricing logic
- ✅ AI agent state with conversation history
- ✅ Game state with rules and calculations
- ✅ Financial calculations with decimal precision
- ✅ Multi-step forms with validation logic
- ✅ API clients that need to persist across steps
Not necessary:
- Plain data transfer objects (DTOs)
- Simple key-value pairs
- Primitives (strings, numbers, booleans)
Custom class serialization in Workflow v4.0.1-beta.49 eliminates the friction of building durable functions with rich domain models.
Before: Manual serialization, lost methods, type casts everywhere After: Your classes just work across suspensions
Try it today:
bun add workflow@latestRead the full docs at useworkflow.dev
/**
* Complete smoke test for custom class serialization
* Run with: bun test order-serialization.test.ts
*/
import { describe, it, expect } from 'bun:test';
class Order {
constructor(
public orderId: string,
public customerId: string,
public items: Array<{ sku: string; price: number; quantity: number }> = [],
public discountPercent: number = 0,
public shippingZone: 'domestic' | 'international' = 'domestic'
) {}
calculateSubtotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
applyDiscount(percent: number): void {
this.discountPercent = percent;
}
getDiscountAmount(): number {
return this.calculateSubtotal() * (this.discountPercent / 100);
}
calculateShipping(): number {
const subtotal = this.calculateSubtotal();
if (this.shippingZone === 'domestic') {
return subtotal > 5000 ? 0 : 500;
}
return 1500;
}
calculateTotal(): number {
const subtotal = this.calculateSubtotal();
const discount = this.getDiscountAmount();
const shipping = this.calculateShipping();
return subtotal - discount + shipping;
}
getSummary(): string {
return `Order ${this.orderId}: ${this.items.length} items, Total: $${(this.calculateTotal() / 100).toFixed(2)}`;
}
hasFreeShipping(): boolean {
return this.shippingZone === 'domestic' && this.calculateSubtotal() > 5000;
}
}
function simulateWorkflowSerialization(order: Order): Order {
const serialized = JSON.stringify(order);
const deserialized = JSON.parse(serialized);
return Object.setPrototypeOf(deserialized, Order.prototype);
}
describe('Custom Class Serialization', () => {
it('should preserve Order methods after serialization', () => {
const order = new Order('ord_456', 'cus_789', [
{ sku: 'LAPTOP', price: 120000, quantity: 1 },
{ sku: 'MOUSE', price: 3000, quantity: 2 }
]);
order.applyDiscount(15);
const totalBefore = order.calculateTotal();
const resumed = simulateWorkflowSerialization(order);
expect(resumed.calculateTotal()).toBe(totalBefore);
expect(resumed.calculateSubtotal()).toBe(126000);
expect(resumed.getDiscountAmount()).toBe(18900);
expect(resumed.hasFreeShipping()).toBe(true);
});
it('should handle multiple suspensions', () => {
let order = new Order('ord_123', 'cus_456', [
{ sku: 'WIDGET', price: 8000, quantity: 3 }
]);
order = simulateWorkflowSerialization(order);
order.applyDiscount(10);
order = simulateWorkflowSerialization(order);
order.items.push({ sku: 'ADDON', price: 1000, quantity: 2 });
order = simulateWorkflowSerialization(order);
expect(order.calculateTotal()).toBe(23400);
expect(order.hasFreeShipping()).toBe(true);
});
it('should handle abandoned cart workflow', () => {
const cart = new Order('ord_cart_123', 'cus_456', [
{ sku: 'SHIRT', price: 3000, quantity: 2 },
{ sku: 'PANTS', price: 5000, quantity: 1 }
]);
const suspended = simulateWorkflowSerialization(cart);
suspended.applyDiscount(20);
const total = suspended.calculateTotal();
expect(total).toBe(8800);
});
});Run with:
bun test order-serialization.test.tsResult: 16 tests pass ✅
Released in: Workflow v4.0.1-beta.49
Package: @workflow/serde
Learn more: https://useworkflow.dev
GitHub: https://github.com/vercel/workflow