If you’re using Temporal at scale, you’ve probably felt the pain: dozens of workflows, multiple workers, task queues everywhere, and teammates constantly asking “what’s the workflow ID format again?” or “what arguments does this signal take?”
After building and maintaining large Temporal codebases, I’ve landed on a pattern that eliminates these problems entirely. I call it the contract pattern, and it brings full type safety to every interaction with your workflows.
The Problem with Vanilla Temporal
Out of the box, Temporal’s TypeScript SDK is powerful but loosely coupled. When you want to start a workflow from your API server, you’re on your own:
// Somewhere in your API...
await client.workflow.start('helloWorld', {
workflowId: `hello-world:${name}`, // Hope this matches the worker!
args: [{ name }], // Hope this is the right shape!
taskQueue: 'my-task-queue', // Hope this is spelled right!
});
This works, but it’s fragile. Nothing prevents you from:
- Misspelling the workflow type
- Using the wrong ID format
- Passing malformed arguments
- Targeting the wrong task queue
These bugs slip through code review and only surface at runtime—often in production.
The Contract Pattern
The solution is to define a contract for each workflow that captures all the type information in one place. Then, import this contract everywhere you interact with that workflow.
Here’s the structure I use:
The key insight is that type string literal double-cast. By typing it as Fn & string, you get:
- A string value that Temporal can use to identify the workflow
- Type information that flows through to
workflow.start()andworkflow.getHandle()
Then, re-export all your contracts from a single entry point:
Implementing the Workflow
With the contract defined, your workflow implementation becomes straightforward:
Notice that the function signature is typed as HelloWorldWorkflow.Fn. If you change the input/output types in the contract, TypeScript will immediately flag any implementation that doesn’t match.
Setting Up the Client
For interacting with workflows, I wrap the Temporal client in a singleton:
Worker Setup
The worker configuration stays standard—the magic is all in how contracts are used elsewhere:
The Payoff: Type-Safe Workflow Interactions
Now, anywhere in your codebase—API routes, background jobs, other workflows—you can interact with workflows using the contract:
Why This Works
The contract pattern gives you:
- Single source of truth — The workflow ID format, types, and task queue are defined once
- Compile-time safety — Typos and type mismatches are caught before deploy
- Better DX — Autocomplete shows you exactly what’s available
- Easier refactoring — Change the contract, fix the red squiggles
- Self-documenting code — New team members can see exactly how to interact with any workflow
Package Structure
For a monorepo, I recommend this structure:
your-project/
├── packages/
│ ├── temporal-contracts/ # Just types and constants
│ │ └── src/
│ │ ├── hello-world.ts
│ │ ├── order-processing.ts
│ │ └── index.ts
│ └── temporal-client/ # Shared client singleton
│ └── src/
│ └── temporal-client.ts
└── apps/
├── worker/ # Workflow implementations
│ └── src/
│ ├── workflows/
│ ├── activities/
│ └── app.ts
└── api/ # Uses contracts + client
└── src/
└── routes/
The temporal-contracts package has zero runtime dependencies on Temporal itself (except for defineSignal from @temporalio/workflow)—it’s just types and pure functions. This means any part of your codebase can depend on it without bloating their bundle.
That’s the pattern. It’s simple, but it’s saved me countless hours debugging workflow interactions that should have been caught at compile time. Give it a try in your next Temporal project.