Type-safe contracts for Temporal.io
End-to-end type safety and runtime validation for workflows and activities
Temporal invokes workflows by string name with positional arguments:
await client.workflow.execute("processOrder", {
taskQueue: "orders",
workflowId: "order-123",
args: [{ orderId: "ORD-1", amount: 99.99 }],
});Nothing here is checked — not the name, not the queue, not the argument shape.
The client and the worker are usually different deployments on different release
cadences, so typeof activities is a claim nobody verifies. Rename a field and
the workflow reads undefined, runs to completion, and does the wrong thing —
durably.
Declare the shape once; both sides import it.
import { defineActivity, defineContract, defineWorkflow } from "@temporal-contract/contract";
import { z } from "zod";
const chargeCard = defineActivity({
input: z.object({ customerId: z.string(), amount: z.number().positive() }),
output: z.object({ transactionId: z.string() }),
});
const processOrder = defineWorkflow({
input: z.object({ orderId: z.string(), customerId: z.string(), amount: z.number().positive() }),
output: z.object({ orderId: z.string(), transactionId: z.string() }),
// Payment already moved money on success — block a second successful
// run per order. A start is still retryable after a genuinely failed
// attempt (e.g. a declined payment, where no charge went through).
startPolicy: "retry-if-failed",
activities: { chargeCard },
});
export const orderContract = defineContract({
taskQueue: "orders",
workflows: { processOrder },
});Implement the activities — note that workflow-scoped activities nest under their workflow, mirroring the contract:
import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity";
import { fromPromise } from "unthrown";
export const activities = declareActivitiesHandler({
contract: orderContract,
activities: {
processOrder: {
chargeCard: ({ input: { customerId, amount } }) =>
fromPromise(
gateway.charge(customerId, amount),
// `expected` names the failures this activity anticipates; anything
// else rides the defect channel with its original stack.
qualifyFailure("CHARGE_FAILED", { expected: GatewayError }),
).map((charge) => ({ transactionId: charge.id })),
},
},
});Call it — names, arguments, and results all typed, and validated at runtime:
import { TypedClient, WORKFLOW_EXECUTE_PATTERNS } from "@temporal-contract/client";
import { Client, Connection } from "@temporalio/client";
// Once per process: the connection-scoped root, then a contract binding.
const connection = await Connection.connect({ address: "localhost:7233" });
const client = await TypedClient.create({ client: new Client({ connection }) }).get();
const orders = client.for(orderContract);
const result = await orders.executeWorkflow("processOrder", {
workflowId: "order-123",
args: { orderId: "ORD-1", customerId: "CUST-1", amount: 99.99 },
});
result.match({
ok: (output) => console.log(output.transactionId),
errCases: (matcher) =>
// Every error `executeWorkflow` can produce — exhaustive, so a missing
// arm (e.g. a declared domain error added later) is a compile error.
matcher.with(...WORKFLOW_EXECUTE_PATTERNS, (error) => console.error(error.message)),
defect: (cause) => console.error("unexpected:", cause),
});An invalid call is rejected before a workflow is started — no history, no partial state, nothing to unwind.
- End-to-end type safety — workflows, activities, signals, queries, updates, errors, and search attributes all derive from one contract
- Validation at every boundary — Standard Schema (Zod, Valibot, ArkType) runs on both sides of every network hop: validated on send, parsed on receive, so transforms apply exactly once
- Typed domain errors — declare failures on the contract; consume them as schema-validated values with an exhaustive matcher
- Explicit error handling —
Result/AsyncResultfrom unthrown, with a separatedefectchannel that keeps genuine bugs loud - Child workflows — typed, including across contracts and teams
- Schedules, cancellation scopes, continue-as-new, activity middleware — all contract-aware
- Testing utilities — time-skipping (no Docker) and real-server (testcontainers) fixtures
- Nexus — not implemented; see the status page
8.0 is currently a prerelease. npm's
latesttag still resolves to 7.x, while this README documents the v8 API. Install the@temporal-contract/*packages with thebetatag until 8.0 is stable — a plainpnpm add @temporal-contract/contractgives you the previous major.
# Core packages (8.0 beta — `latest` still resolves 7.x). `contract` is also
# a peer of `worker` and `client`, so it must be installed alongside them.
pnpm add @temporal-contract/contract@beta @temporal-contract/worker@beta \
@temporal-contract/client@beta
# Peer dependencies (stable releases): unthrown ^5.11, @temporalio/* ^1.24
pnpm add unthrown \
@temporalio/client @temporalio/common @temporalio/worker @temporalio/workflow
# Plus one Standard Schema validator of your choice — zod, valibot, arktype, …
pnpm add zodRequires Node.js ≥ 22.22, ESM ("type": "module"), and TypeScript strict.
Developed against TypeScript 7.0.
Install
unthrownexplicitly even if your package manager auto-installs peers — your own code imports itsResult/AsyncResulttypes, so it is a real dependency of yours. It must resolve to v5.
See Install for the per-process breakdown.
📖 btravstack.github.io/temporal-contract
Organized by Diátaxis:
| Tutorial | Build a working app end to end |
| How-to guides | Recipes for specific problems |
| Reference | Every option, type, and error |
| Explanation | Why it works this way |
| Package | Description |
|---|---|
| @temporal-contract/contract | Contract builders, types, and errors |
| @temporal-contract/worker | Workflow, activity, and worker entry points |
| @temporal-contract/client | Typed client, handles, and schedules |
| @temporal-contract/testing | Time-skipping and testcontainers fixtures |
All four version together as a fixed release group — one version number describes a compatible set.
8.0 is in beta: the API these docs describe can still take breaking changes
between betas, each one recorded in the changelog with migration notes (see
Upgrade between 8.0 betas).
The shape of the contract API (defineContract, declareWorkflow,
declareActivitiesHandler, TypedClient) is settled; earlier major bumps were
migrations of the underlying result library, now settled on
unthrown. The unthrown peer range
tracks its current major line.
Upgrading from 7.x? See Upgrade to v8.
See CONTRIBUTING.md.
MIT