Result Pattern
Learn how to use explicit error handling with the Result/Future pattern.
Overview
temporal-contract uses the Result/Future pattern for explicit error handling. The implementation differs between activities and workflows:
- Activities and Clients: Use @swan-io/boxed - a battle-tested library with excellent performance
- Workflows: Use @temporal-contract/boxed - a Temporal-compatible implementation required for deterministic execution
Both packages provide the same API, making it easy to work with both.
Installation
# For activities and clients
pnpm add @swan-io/boxed
# For workflows (Temporal-compatible)
pnpm add @temporal-contract/boxedBasic Usage
Activities (using @swan-io/boxed)
Activities use @swan-io/boxed for excellent performance and ecosystem compatibility:
import { declareActivitiesHandler, ActivityError } from '@temporal-contract/worker/activity';
import { Future, Result } from '@swan-io/boxed';
import { orderContract } from './contract';
export const activities = declareActivitiesHandler({
contract: orderContract,
activities: {
processPayment: ({ amount }) => {
return Future.fromPromise(paymentGateway.charge(amount))
.mapError(error =>
new ActivityError(
'PAYMENT_FAILED',
error instanceof Error ? error.message : 'Payment failed',
error
)
)
.mapOk(txId => ({ transactionId: txId, success: true }));
},
sendEmail: ({ to, body }) => {
return Future.fromPromise(emailService.send({ to, body }))
.mapError(error =>
new ActivityError(
'EMAIL_FAILED',
error instanceof Error ? error.message : 'Email failed',
error
)
)
.mapOk(() => ({ sent: true }));
}
}
});Workflows (using @temporal-contract/boxed)
Workflows require @temporal-contract/boxed for Temporal's deterministic execution:
import { declareWorkflow } from '@temporal-contract/worker/workflow';
import { Result } from '@temporal-contract/boxed';
import { orderContract } from './contract';
export const processOrder = declareWorkflow({
workflowName: 'processOrder',
contract: orderContract,
implementation: async ({ activities }, { orderId, amount }) => {
// Process payment - activities return plain values
const payment = await activities.processPayment({ amount });
// Send confirmation email
await activities.sendEmail({
to: 'customer@example.com',
body: `Order ${orderId} confirmed`
});
return {
success: true,
transactionId: payment.transactionId
};
}
});Clients (using @swan-io/boxed)
Clients use @swan-io/boxed to handle workflow results:
import { TypedClient } from '@temporal-contract/client';
import { Result } from '@swan-io/boxed';
import { Client } from '@temporalio/client';
import { orderContract } from './contract';
const temporalClient = new Client({ connection });
const client = TypedClient.create(orderContract, temporalClient);
const result = await client.executeWorkflow('processOrder', {
workflowId: 'order-123',
args: { orderId: 'ORD-123', amount: 100 },
});
// Handle result with pattern matching
result.match({
Ok: (value) => {
console.log('Order processed:', value.transactionId);
},
Error: (error) => {
console.error('Order failed:', error);
},
});Why Two Packages?
@swan-io/boxed (Activities & Clients)
- ✅ Battle-tested with extensive usage
- ✅ Excellent performance optimizations
- ✅ Large ecosystem support
- ✅ Works perfectly outside of Temporal workflows
@temporal-contract/boxed (Workflows)
- ✅ Temporal deterministic execution compatible
- ✅ Same API as @swan-io/boxed
- ✅ Designed specifically for workflow constraints
- ✅ Seamless interoperability with @swan-io/boxed
Interoperability
Both packages share the same API surface, so code is portable:
// Same API for both packages!
const result = Result.Ok(42);
const future = Future.value(42);
result.match({
Ok: (value) => console.log(value),
Error: (error) => console.error(error),
});For explicit conversion between the two (rarely needed), see the @temporal-contract/boxed interop documentation.
Pattern Matching
Activities return plain values when called from workflows. If an activity fails, it will throw an error:
export const processOrder = declareWorkflow({
workflowName: 'processOrder',
contract: orderContract,
implementation: async ({ activities }, input) => {
try {
// Activity returns plain value (Result is unwrapped internally)
const payment = await activities.processPayment({ amount: 100 });
console.log('Payment succeeded:', payment.transactionId);
return { success: true, transactionId: payment.transactionId };
} catch (error) {
// Activity errors are thrown
console.error('Payment failed:', error);
return { success: false, transactionId: '' };
}
}
});NOTE
For child workflows, you do get Result objects. See the Child Workflows section below.
Chaining Activities
When calling multiple activities, use standard async/await with try/catch:
export const processOrder = declareWorkflow({
workflowName: 'processOrder',
contract: orderContract,
implementation: async ({ activities }, input) => {
try {
// Activities return plain values
const payment = await activities.processPayment({ amount: 100 });
// Next activity only runs if payment succeeded
await activities.sendEmail({
to: 'customer@example.com',
body: `Payment ${payment.transactionId} processed`
});
// Update database
await activities.updateDatabase({
status: 'completed'
});
return { success: true };
} catch (error) {
console.error('Workflow failed:', error);
return { success: false };
}
}
});Error Types
Define typed errors in your activities:
type PaymentError =
| { type: 'InsufficientFunds' }
| { type: 'CardDeclined' }
| { type: 'NetworkError', message: string };
type EmailError =
| { type: 'InvalidEmail' }
| { type: 'ServiceUnavailable' };
// Activities return Future with typed errors
processPayment: ({ amount }) => {
return Future.fromPromise(paymentGateway.charge(amount))
.mapError<ActivityError>(error => {
// Wrap domain errors in ActivityError for Temporal retry policies
return new ActivityError(
'PAYMENT_FAILED',
error instanceof Error ? error.message : 'Payment failed',
error
);
})
.mapOk((txId) => ({ transactionId: txId }));
}Benefits
1. Explicit Error Handling
Activities use the Result pattern internally, while workflows use try/catch:
// Activity implementation (uses Result pattern)
const processPayment = ({ amount }) => {
return Future.fromPromise(paymentGateway.charge(amount))
.mapError((error) =>
new ActivityError('PAYMENT_FAILED', 'Payment failed', error)
)
.mapOk((txId) => ({ transactionId: txId }));
};
// Workflow (uses standard try/catch for activities)
export const processOrder = declareWorkflow({
workflowName: 'processOrder',
contract: myContract,
implementation: async ({ activities }, input) => {
try {
// Activity returns plain value
const payment = await activities.processPayment({ amount: 100 });
return { success: true, transactionId: payment.transactionId };
} catch (error) {
// Handle activity error
return { success: false };
}
}
});2. No Hidden Exceptions in Activities
Activities explicitly return Results instead of throwing:
// ✅ Clear - activity returns Future<Result>
const processPayment = ({ amount }) => {
return Future.fromPromise(paymentGateway.charge(amount))
.mapError((error) =>
new ActivityError('PAYMENT_FAILED', 'Payment failed', error)
)
.mapOk((txId) => ({ transactionId: txId }));
};
// ❌ Unclear - might throw anything
async function processPayment({ amount }) {
const txId = await paymentGateway.charge(amount);
return { transactionId: txId };
}3. Railway-Oriented Programming (Activities)
Activity implementations can chain operations that short-circuit on error:
// Activity implementation with chaining
const processOrder = ({ orderId }) => {
return validateOrderId(orderId)
.flatMap((validId) => fetchOrder(validId))
.flatMap((order) => processPayment(order))
.flatMap((payment) => updateDatabase(payment))
.mapError((error) =>
new ActivityError('ORDER_FAILED', 'Order processing failed', error)
);
// Stops at first error
};4. Partial Success Handling
Track partial success in complex workflows using try/catch blocks:
export const processOrder = declareWorkflow({
workflowName: 'processOrder',
contract: orderContract,
implementation: async ({ activities }, input) => {
let paymentTransactionId: string | undefined;
try {
// Step 1: Process payment
const payment = await activities.processPayment({ amount: input.amount });
paymentTransactionId = payment.transactionId;
// Step 2: Schedule shipment
await activities.scheduleShipment({ orderId: input.orderId });
return { success: true, transactionId: paymentTransactionId };
} catch (error) {
// Payment succeeded but shipment failed - can handle specially
if (paymentTransactionId) {
// Rollback payment
await activities.refundPayment({ transactionId: paymentTransactionId });
return {
success: false,
message: 'Shipment failed, payment refunded',
completedSteps: { payment: paymentTransactionId }
};
}
return { success: false, message: 'Payment failed' };
}
}
});Child Workflows
Child workflows use the same Future/Result pattern for consistent error handling:
Execute and Wait
import { declareWorkflow } from '@temporal-contract/worker/workflow';
export const parentWorkflow = declareWorkflow({
workflowName: 'parentWorkflow',
contract: myContract,
implementation: async ({ executeChildWorkflow }, input) => {
// Execute child workflow and wait for result
const result = await executeChildWorkflow(myContract, 'processPayment', {
workflowId: `payment-${input.orderId}`,
args: { amount: input.totalAmount }
});
return result.match({
Ok: (output) => Result.Ok({
success: true,
transactionId: output.transactionId
}),
Error: (error) => Result.Error({
type: 'ChildWorkflowFailed',
error
}),
});
},
});Start Without Waiting
export const parentWorkflow = declareWorkflow({
workflowName: 'parentWorkflow',
contract: myContract,
implementation: async ({ startChildWorkflow }, input) => {
// Start child workflow without waiting
const handleResult = await startChildWorkflow(myContract, 'sendNotification', {
workflowId: `notification-${input.orderId}`,
args: { message: 'Order received' }
});
handleResult.match({
Ok: async (handle) => {
// Child started successfully
// Can wait for result later if needed
const result = await handle.result();
},
Error: (error) => {
console.error('Failed to start child:', error);
},
});
return Result.Ok({ success: true });
},
});Cross-Contract Child Workflows
Invoke workflows from different contracts/workers:
import { orderContract, notificationContract } from './contracts';
export const orderWorkflow = declareWorkflow({
workflowName: 'processOrder',
contract: orderContract,
implementation: async ({ executeChildWorkflow }, input) => {
// Child workflow from another contract
const notifyResult = await executeChildWorkflow(
notificationContract,
'sendOrderConfirmation',
{
workflowId: `notify-${input.orderId}`,
args: { orderId: input.orderId }
}
);
return notifyResult.match({
Ok: () => Result.Ok({ status: 'completed' }),
Error: (error) => Result.Error({
type: 'NotificationFailed',
error
}),
});
},
});When to Use
Use Result/Future Pattern When:
- In Activity Implementations: Always use Future/Result pattern for explicit error handling
- For Child Workflows: Child workflows return Results for explicit error handling
- For Type-Safe Errors: When you need ActivityError with proper retry policies
Use Standard async/await When:
- In Workflow Logic: Use try/catch when calling activities from workflows
- For Simple Error Handling: When standard exception handling is sufficient
- For Deterministic Code: Workflows must remain deterministic