Type-Safe Temporal Workflows with the Contract Pattern

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:

temporal-contracts/src/hello-world.ts
import { defineSignal } from '@temporalio/workflow';

// Consistently structured workflow IDs
export function id({ name }: { name: string }) {
return `hello-world:${name}`;
}

// The workflow type - this cast enables type inference
export const type = 'helloWorld' as unknown as Fn & string;

// Task queue as a function (can add logic later if needed)
export const taskQueue = () => 'my-task-queue';

// Explicit input/output types
type Input = {
name: string;
};

type Output = string;

// The function signature that ties it all together
export type Fn = (input: Input) => Promise<Output>;

// Typed signals
export const goodbyeSignal = defineSignal<[reason: string]>('goodbye');

The key insight is that type string literal double-cast. By typing it as Fn & string, you get:

  1. A string value that Temporal can use to identify the workflow
  2. Type information that flows through to workflow.start() and workflow.getHandle()

Then, re-export all your contracts from a single entry point:

temporal-contracts/src/index.ts
export * as HelloWorldWorkflow from './hello-world';
export * as OrderProcessingWorkflow from './order-processing';
export * as UserOnboardingWorkflow from './user-onboarding';
// ... all your workflows

Implementing the Workflow

With the contract defined, your workflow implementation becomes straightforward:

apps/worker/src/workflows/hello-world.ts
import { HelloWorldWorkflow } from '@your-project/temporal-contracts';
import { condition, setHandler } from '@temporalio/workflow';

export const helloWorld: HelloWorldWorkflow.Fn = async ({ name }) => {
let goodbyeReason: string | null = null;

setHandler(HelloWorldWorkflow.goodbyeSignal, (reason) => {
  goodbyeReason = reason;
});

await condition(() => goodbyeReason !== null);

return `Goodbye, ${name}! Reason: ${goodbyeReason}`;
};

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:

temporal-client/src/temporal-client.ts
import { Client, Connection } from '@temporalio/client';

let _clientPromise: Promise<Client> | null = null;

export const getTemporalClient = () => {
if (!_clientPromise) {
  _clientPromise = (async () => {
    const connection = await Connection.connect({
      address: process.env.TEMPORAL_ADDRESS!,
      tls: process.env.ENVIRONMENT === 'production' ? {} : false,
      metadata: {
        'temporal-namespace': process.env.TEMPORAL_NAMESPACE!,
      },
      apiKey: process.env.TEMPORAL_API_KEY,
    });

    return new Client({
      connection,
      namespace: process.env.TEMPORAL_NAMESPACE!,
    });
  })();
}

return _clientPromise;
};

Worker Setup

The worker configuration stays standard—the magic is all in how contracts are used elsewhere:

apps/worker/src/app.ts
import { NativeConnection, Worker } from '@temporalio/worker';
import * as activities from './activities';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

async function main() {
const connection = await NativeConnection.connect({
  address: process.env.TEMPORAL_ADDRESS!,
  apiKey: process.env.TEMPORAL_API_KEY,
  tls: process.env.ENVIRONMENT === 'production',
  metadata: {
    'temporal-namespace': process.env.TEMPORAL_NAMESPACE!,
  },
});

const worker = await Worker.create({
  taskQueue: process.env.TEMPORAL_TASK_QUEUE!,
  workflowsPath: require.resolve('./workflows'),
  activities,
  connection,
  namespace: process.env.TEMPORAL_NAMESPACE!,
});

try {
  await worker.run();
} finally {
  await connection.close();
}
}

main().catch((err) => {
console.error('Worker failed:', err);
process.exit(1);
});

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:

apps/api/src/routes/greet.ts
import { HelloWorldWorkflow } from '@your-project/temporal-contracts';
import { getTemporalClient } from '@your-project/temporal-client';

export async function startGreeting(name: string) {
const temporal = await getTemporalClient();

// Everything here is fully typed!
await temporal.workflow.signalWithStart(HelloWorldWorkflow.type, {
  workflowId: HelloWorldWorkflow.id({ name }),
  args: [{ name }],  // TypeScript knows the exact shape
  taskQueue: HelloWorldWorkflow.taskQueue(),
  signal: HelloWorldWorkflow.goodbyeSignal,
  signalArgs: ['User requested goodbye'],  // Typed signal args
});
}

export async function getGreetingResult(name: string) {
const temporal = await getTemporalClient();

// The handle is typed to the workflow's return type
const handle = temporal.workflow.getHandle<HelloWorldWorkflow.Fn>(
  HelloWorldWorkflow.id({ name })
);

const result = await handle.result();
// result is typed as string, not unknown!

return result;
}

Why This Works

The contract pattern gives you:

  1. Single source of truth — The workflow ID format, types, and task queue are defined once
  2. Compile-time safety — Typos and type mismatches are caught before deploy
  3. Better DX — Autocomplete shows you exactly what’s available
  4. Easier refactoring — Change the contract, fix the red squiggles
  5. 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.