If you've worked with Temporal.io for building durable workflows in TypeScript, you know how powerful it is for orchestrating complex business processes. However, maintaining type safety across workflows, activities, and clients can be challenging.
The Problem with Traditional Temporal Development
Temporal is an incredible platform for building reliable, fault-tolerant distributed systems. But the traditional TypeScript approach has some pain points:
// ❌ Traditional approach: Manual type coordination
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';
const { processPayment, sendEmail } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
});
export async function orderWorkflow(orderId: string): Promise<void> {
// What's the expected structure? No validation!
await processPayment(orderId);
await sendEmail(orderId);
}
// Activities file - easy to drift from workflow expectations
export async function processPayment(orderId: string): Promise<void> {
// Implementation
}
Problems:
- 🚫 No runtime validation of inputs/outputs
- 🚫 Manual type coordination between files
- 🚫 Easy to drift when refactoring
- 🚫 No centralized contract definition
- 🚫 Complex error handling without Result types
- 🚫 Difficult to ensure consistency across teams
Introducing temporal-contract
temporal-contract solves these problems with a contract-first approach. You define your workflows, activities, and their schemas once using Zod, and type safety is guaranteed everywhere.
Key Features
✅ End-to-end type safety — Full TypeScript inference from contract to client, workflows, and activities
✅ Automatic validation — Zod schema validation at all network boundaries
✅ Compile-time checks — Catch errors before runtime
✅ Result/Future pattern — Explicit error handling for activities and child workflows with @swan-io/boxed
✅ Child workflows — Type-safe child workflow execution
✅ NestJS integration — First-class support for dependency injection
✅ Better DX — Full autocomplete, inline documentation, and refactoring support
How It Works
Step 1: Define Your Contract
First, define your Temporal contract with workflows and activities in one place:
import { defineContract } from '@temporal-contract/contract';
import { z } from 'zod';
// Define contract once with full type safety
export const orderContract = defineContract({
taskQueue: 'orders',
workflows: {
processOrder: {
input: z.object({
orderId: z.string(),
customerId: z. string(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().int().positive(),
price: z. number().positive(),
})
),
}),
output: z.object({
success: z.boolean(),
orderId: z.string(),
totalAmount: z.number(),
}),
activities: {
validateInventory: {
input: z.object({
items: z.array(
z.object({
productId: z.string(),
quantity: z.number(),
})
),
}),
output: z.object({
available: z.boolean(),
unavailableItems: z.array(z.string()),
}),
},
processPayment: {
input: z.object({
customerId: z.string(),
amount: z.number().positive(),
}),
output: z. object({
transactionId: z.string(),
status: z.enum(['success', 'failed', 'pending']),
}),
},
sendConfirmationEmail: {
input: z.object({
customerId: z.string(),
orderId: z.string(),
}),
output: z. object({
sent: z. boolean(),
}),
},
},
},
},
});
Step 2: Implement Type-Safe Activities
Activities use the declareActivitiesHandler function and return Future objects from @swan-io/boxed for explicit error handling:
import { declareActivitiesHandler, ActivityError } from '@temporal-contract/worker/activity';
import { orderContract } from './contract';
import { Future } from '@swan-io/boxed';
// ✅ Activities are fully typed! TypeScript knows the input/output types
export const activities = declareActivitiesHandler({
contract: orderContract,
activities: {
validateInventory: ({ items }) => {
// input.items is typed!
return Future.fromPromise(
(async () => {
const unavailable: string[] = [];
for (const item of items) {
const inStock = await checkInventory(item.productId, item.quantity);
if (!inStock) {
unavailable.push(item.productId);
}
}
return {
available: unavailable.length === 0,
unavailableItems: unavailable,
};
})()
).mapError((error) =>
new ActivityError(
'INVENTORY_CHECK_FAILED',
error instanceof Error ? error.message : 'Failed to check inventory',
error
)
);
},
processPayment: ({ customerId, amount }) => {
return Future.fromPromise(
paymentGateway.charge({
customerId,
amount,
})
)
.mapError((error) =>
new ActivityError(
'PAYMENT_FAILED',
error instanceof Error ? error. message : 'Payment failed',
error
)
)
.mapOk((transaction) => ({
transactionId: transaction.id,
status: 'success' as const,
}));
},
sendConfirmationEmail: ({ customerId, orderId }) => {
return Future. fromPromise(
emailService.send({
to: customerId,
template: 'order-confirmation',
data: { orderId },
})
)
.mapError((error) =>
new ActivityError(
'EMAIL_FAILED',
error instanceof Error ? error.message : 'Failed to send email',
error
)
)
.mapOk(() => ({ sent: true }));
},
},
});
Key Points:
- Activities return
Future<Output, ActivityError>objects - Use
Future.fromPromise()to wrap async operations - Use
mapError()to convert errors toActivityErrorwith error codes - Use
mapOk()to transform success values if needed
Step 3: Write Type-Safe Workflows
Workflows use declareWorkflow and receive unwrapped plain values from activities:
import { declareWorkflow } from '@temporal-contract/worker/workflow';
import { orderContract } from './contract';
export const processOrder = declareWorkflow({
workflowName: 'processOrder',
contract: orderContract,
implementation: async ({ activities }, input) => {
// ✅ input is fully typed from contract!
console.log(`Processing order ${input.orderId} for customer ${input.customerId}`);
// ✅ Activities return plain values (Futures are unwrapped automatically)
const inventory = await activities.validateInventory({
items: input.items,
});
if (!inventory.available) {
throw new Error(
`Items unavailable: ${inventory.unavailableItems.join(', ')}`
);
}
// Calculate total
const totalAmount = input.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
// Process payment - activity returns plain value
const payment = await activities.processPayment({
customerId: input.customerId,
amount: totalAmount,
});
if (payment.status !== 'success') {
throw new Error('Payment was not successful');
}
// Send confirmation
await activities.sendConfirmationEmail({
customerId: input.customerId,
orderId: input.orderId,
});
// ✅ Return type validated against contract schema
return {
success: true,
orderId: input.orderId,
totalAmount,
};
},
});
Important: Within workflows, activities return plain values. The Future unwrapping happens automatically by the framework. If an activity's Future contains an error, it throws an exception in the workflow.
Step 4: Start the Worker
Create a worker using the standard Temporal Worker with your activities:
import { Worker } from '@temporalio/worker';
import { activities } from './activities';
import { orderContract } from './contract';
async function startWorker() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: orderContract.taskQueue,
});
console.log('Worker started, listening for tasks.. .');
await worker.run();
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('Shutting down worker...');
await worker.shutdown();
process.exit(0);
});
}
startWorker().catch(console.error);
Step 5: Execute Workflows from Client
Use the TypedClient to execute workflows with full type safety:
import { TypedClient } from '@temporal-contract/client';
import { Connection, Client } from '@temporalio/client';
import { orderContract } from './contract';
async function createOrder() {
const connection = await Connection.connect({ address: 'localhost:7233' });
const temporalClient = new Client({ connection });
const client = TypedClient.create(orderContract, temporalClient);
// ✅ Fully typed! TypeScript knows exactly what fields are required
const result = await client. executeWorkflow('processOrder', {
workflowId: 'order-12345',
args: {
orderId: 'ORD-12345',
customerId: 'CUST-789',
items: [
{ productId: 'PROD-A', quantity: 2, price: 29.99 },
{ productId: 'PROD-B', quantity: 1, price: 49.99 },
],
},
});
console.log('Order processed successfully! ');
console.log(`Order ID: ${result.orderId}`);
console.log(`Total: $${result.totalAmount}`);
await connection.close();
}
Result/Future Pattern for Error Handling
temporal-contract uses the Result/Future pattern from @swan-io/boxed for explicit, type-safe error handling:
In Activities
Activities return Future objects that can succeed or fail:
const activities = declareActivitiesHandler({
contract,
activities: {
processPayment: ({ orderId }) => {
return Future.fromPromise(paymentService.process(orderId))
.mapError((error) =>
new ActivityError('PAYMENT_FAILED', 'Payment failed', error)
)
.mapOk((txId) => ({ transactionId: txId }));
}
}
});
In Child Workflows
Child workflows also return Result objects for explicit error handling:
export const parentWorkflow = declareWorkflow({
workflowName: 'parentWorkflow',
contract: myContract,
implementation: async ({ executeChildWorkflow }, input) => {
// Execute child workflow and get Result
const childResult = await executeChildWorkflow(myContract, 'processPayment', {
workflowId: `payment-${input.orderId}`,
args: { amount: input.totalAmount }
});
// Check for errors explicitly
childResult.match({
Ok: (output) => console.log('Payment processed:', output),
Error: (error) => console.error('Payment failed:', error),
});
return { success: true };
}
});
Benefits:
- ✅ Explicit error handling — No hidden exceptions
- ✅ Type-safe — Errors are part of the type system
- ✅ Composable — Chain operations with
map,flatMap, etc. - ✅ Better debugging — Clear error paths through your code
Child Workflows with Type Safety
temporal-contract supports type-safe child workflow execution with the Result/Future pattern:
// Define parent and child contracts
const childContract = defineContract({
taskQueue: 'notifications',
workflows: {
sendNotifications: {
input: z. object({ orderId: z.string() }),
output: z.object({ sent: z.boolean() }),
},
},
});
const parentContract = defineContract({
taskQueue: 'orders',
workflows: {
processOrder: {
input: z. object({ orderId: z.string() }),
output: z.object({ success: z.boolean() }),
},
},
});
// Execute child workflow with full type safety
export const processOrder = declareWorkflow({
workflowName: 'processOrder',
contract: parentContract,
implementation: async ({ executeChildWorkflow, startChildWorkflow }, input) => {
// ... process order logic
// Execute and wait for result
const notificationResult = await executeChildWorkflow(
childContract,
'sendNotifications',
{
workflowId: `notification-${input.orderId}`,
args: { orderId: input.orderId },
}
);
notificationResult.match({
Ok: (output) => console.log('Notifications sent:', output. sent),
Error: (error) => console.error('Failed:', error),
});
// Or start without waiting
const handleResult = await startChildWorkflow(
childContract,
'sendNotifications',
{
workflowId: `notification-async-${input.orderId}`,
args: { orderId: input.orderId },
}
);
handleResult. match({
Ok: async (handle) => {
// Can wait for result later
const result = await handle. result();
// ...
},
Error: (error) => console.error('Failed to start:', error),
});
return { success: true };
}
});
NestJS Integration
For teams using NestJS, temporal-contract provides first-class integration. See the @temporal-contract/worker-nestjs package for details.
Real-World Benefits
After using temporal-contract in production, here are the benefits we've seen:
1. Catch Errors at Compile Time
// ❌ TypeScript error caught immediately
await client.executeWorkflow('processOrder', {
workflowId: 'order-123',
args: {
orderId: 123, // Error: Type 'number' is not assignable to 'string'
customerId: 'CUST-456',
},
});
2. Refactor with Confidence
Change your contract schema once, and TypeScript guides you to update all workflows, activities, and client code. No more runtime surprises!
3. Better Onboarding
New developers can see exactly what workflows are available, what they expect, and what they return — all through autocomplete and type hints.
4. Reduced Bugs
Validation at network boundaries catches invalid data before it reaches your business logic.
5. Explicit Error Handling
The Result/Future pattern provides clear, type-safe error handling for activities and child workflows without relying on exceptions.
Monorepo Architecture
temporal-contract is built as a modular monorepo with separate packages:
| Package | Description |
|---|---|
@temporal-contract/contract |
Contract builder and type definitions |
@temporal-contract/worker |
Type-safe worker with automatic validation (uses @swan-io/boxed) |
@temporal-contract/client |
Type-safe client for executing workflows (uses @swan-io/boxed) |
@temporal-contract/worker-nestjs |
NestJS integration with dependency injection |
@temporal-contract/boxed |
Temporal-compatible Result/Future types for workflows (alternative to @swan) |
@temporal-contract/testing |
Testing utilities for integration tests |
Install only what you need:
# Just contract and client
pnpm add @temporal-contract/contract @temporal-contract/client
# Add worker for workflow implementation
pnpm add @temporal-contract/worker
# Add NestJS integration
pnpm add @temporal-contract/worker-nestjs
Getting Started
Ready to try temporal-contract? Here's how to get started:
Installation
# Using pnpm (recommended)
pnpm add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client
pnpm add @temporalio/client @temporalio/worker zod @swan-io/boxed
# Using npm
npm install @temporal-contract/contract @temporal-contract/worker @temporal-contract/client
npm install @temporalio/client @temporalio/worker zod @swan-io/boxed
# Using yarn
yarn add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client
yarn add @temporalio/client @temporalio/worker zod @swan-io/boxed
Quick Start
-
Define your contract in
contract.ts -
Implement activities with
declareActivitiesHandlerreturning Futures -
Write workflows with
declareWorkflow - Create a worker with standard Temporal Worker
-
Use the client with
TypedClient. create() - *Enjoy type safety! *
Check out the Getting Started Guide for detailed instructions.
Examples
The repository includes several complete examples showing real-world usage patterns. Visit the examples directory to see:
- Basic workflow with activities
- Child workflow execution
- Error handling with Result/Future pattern
- NestJS integration
Each example is a working application you can run locally with a Temporal dev server.
Comparison with Other Solutions
vs. Plain Temporal SDK
- ✅ Type safety enforced at compile time
- ✅ Automatic validation
- ✅ Better DX with autocomplete
- ✅ Single source of truth for schemas
- ✅ Explicit error handling with Result/Future pattern
vs. Manual Type Definitions
- ✅ No drift between types and implementations
- ✅ Automatic validation
- ✅ Less boilerplate
- ✅ Guaranteed consistency
vs. Other Workflow Libraries
- ✅ Built specifically for Temporal. io
- ✅ Contract-first approach
- ✅ Result/Future pattern for error handling
- ✅ NestJS integration included
What's Next?
We have exciting plans for temporal-contract:
- 🎯 Nexus support — Cross-namespace operations
- 🎯 Enhanced testing utilities — Better test helpers
- 🎯 Signal/Query support — Type-safe signals and queries
- 🎯 More examples — Real-world use cases
- 🎯 Documentation improvements — More guides and tutorials
Contributing
temporal-contract is open source and we welcome contributions! Whether it's:
- 🐛 Bug reports
- 💡 Feature requests
- 📝 Documentation improvements
- 🔧 Code contributions
Check out the Contributing Guide to get started.
Resources
- Documentation: https://btravers.github.io/temporal-contract
- GitHub: https://github.com/btravers/temporal-contract
- npm: @temporal-contract/contract
- Temporal.io: https://temporal.io
Conclusion
If you're building TypeScript applications with Temporal.io, temporal-contract can dramatically improve your development experience. By bringing type safety to workflows and activities, it catches errors early and makes your code more maintainable.
The contract-first approach ensures consistency across your distributed workflows, while the Result/Future pattern provides robust error handling with explicit, type-safe errors.
Give it a try and let us know what you think! We'd love to hear your feedback and use cases.
Try temporal-contract today:
pnpm add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client
Happy orchestrating! ⚡🔄
btravers
/
temporal-contract
End-to-end type safety and automatic validation for workflows and activities
temporal-contract
Type-safe contracts for Temporal.io
End-to-end type safety and automatic validation for workflows and activities
Features
- ✅ End-to-end type safety — From contract to client, workflows, and activities
- ✅ Automatic validation — Zod schemas validate at all network boundaries
- ✅ Compile-time checks — TypeScript catches missing or incorrect implementations
- ✅ Better DX — Autocomplete, refactoring support, inline documentation
- ✅ Child workflows — Type-safe child workflow execution with Result/Future pattern
- ✅ Result/Future pattern — Explicit error handling without exceptions
- 🚧 Nexus support — Cross-namespace operations (planned for v0.5.0)
Quick Example
// Define contract once
const contract = defineContract({
taskQueue: 'orders',
workflows: {
processOrder: {
input: z.object({ orderId: z.string() }),
output: z.object({ success: z.boolean() }),
activities…
Top comments (0)