Define a contract
A contract declares the shape of your Temporal application: which workflows exist, what they accept and return, which activities they may call, and which signals, queries, updates, errors, and search attributes they expose.
Compose, don't inline
Define each resource as its own named value, then reference it from defineContract:
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() }),
output: z.object({ status: z.enum(["completed", "failed"]) }),
startPolicy: "retry-if-failed", // charges a card — see below
activities: { chargeCard },
});
export const orderContract = defineContract({
taskQueue: "orders",
workflows: { processOrder },
});Inlining everything into defineContract works, but named resources are reusable across workflows and contracts, give you precise hover and jump-to-definition, and keep the contract itself readable as a table of contents.
Declare a start policy
startPolicy is required on every workflow. It answers one question: is it safe to start this workflow ID again after a previous run has closed?
Temporal's own default (workflowIdReusePolicy: ALLOW_DUPLICATE) says yes — to any closed run, including one that completed successfully. For a workflow keyed charge-${orderId}, that means a client that retries a start after a network blip — not knowing the first start actually succeeded — kicks off a second charge under the same order ID. Nothing about that is a bug in Temporal; ALLOW_DUPLICATE is a reasonable default for workflows where re-running a completed one is harmless. It is just the wrong default for one that moved money.
Three modes, each a named intent rather than Temporal's raw enum:
| Mode | Temporal policy | Means |
|---|---|---|
"once-per-id" | REJECT_DUPLICATE | This workflow ID may run exactly once, ever. |
"retry-if-failed" | ALLOW_DUPLICATE_FAILED_ONLY | Re-runnable only if the previous run reached a Closed state other than Completed — Failed, Cancelled, Terminated, or TimedOut. |
"allow-duplicate" | ALLOW_DUPLICATE | Temporal's own default: re-runnable after any Closed state, including Completed. |
const chargeOrder = defineWorkflow({
input: OrderSchema,
output: OrderResultSchema,
// A completed run already charged the customer — block a second
// successful run under the same order ID. 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 },
});Why this lives on the contract, not the call. Whether a workflow is safe to re-run is a property of the operation — chargeOrder either double-charges on re-run or it does not, and that fact does not change depending on who is starting it or why. workflowIdConflictPolicy (still a per-call client option, untouched by this field) answers a different question — "what do I want if a run with this ID is already in flight?" — and that legitimately varies: a scheduler might want to attach to it, a user-facing retry button might want to fail fast. Splitting the two means the safety question gets asked once, on the contract, instead of re-litigated — or forgotten — at every call site.
There is no per-call override: the typed client's start options and the worker's child-workflow options do not accept workflowIdReusePolicy. To change the behavior, change the contract.
Derive the workflow ID
A start policy only works if two starts of the same logical request share an ID. A caller free to pass crypto.randomUUID() defeats "once-per-id" silently — every start gets a fresh ID and the policy never fires. Declare workflowId to move the ID onto the contract:
const chargeOrder = defineWorkflow({
input: OrderSchema,
output: OrderResultSchema,
workflowId: ({ orderId }) => `charge-${orderId}`,
startPolicy: "retry-if-failed",
activities: { chargeCard },
});The function receives the validated input and must be pure. The client computes the ID on every start (and passing workflowId becomes a type error), the worker does the same for a child-workflow start, and workflowIdFor("chargeOrder", input) on the contract client computes it without starting anything, to getHandle the execution later. Schedules are the exception: schedule.create generates one ID per firing.
Scope activities globally or per workflow
Activities declared on the contract are reachable from every workflow. Activities declared on a workflow are reachable only from that one.
export const orderContract = defineContract({
taskQueue: "orders",
// Global: available to every workflow in this contract.
activities: { sendNotification, writeAuditLog },
workflows: {
processOrder, // can call chargeCard + sendNotification + writeAuditLog
cancelOrder, // can call refundCard + sendNotification + writeAuditLog
},
});Use global for genuinely cross-cutting effects (notifications, audit logging). Use workflow-scoped for everything else — it keeps each workflow's reachable surface small and makes ownership obvious.
Activity names share one flat namespace
At runtime Temporal resolves activities from a single map, regardless of which workflow calls them. defineContract therefore rejects a duplicate name that points at two different definitions — whether the collision is workflow-vs-global or between two different workflows:
Contract validation failed at workflows.cancelOrder.activities.chargeCard:
workflow "cancelOrder" has activity "chargeCard" that conflicts with a
different same-named activity in workflow "processOrder". Activities share a single flat namespace at runtime — hoist
the shared activity to the contract's global "activities" block, or rename
one of them.Referencing the same defineActivity result from several scopes is allowed — it is one activity, and it flattens unambiguously. On the worker side you must then implement it with the same function reference (or hoist it to the global block); declareActivitiesHandler rejects two different implementations for one flat name at declaration time.
If two workflows genuinely need the same operation, declaring it once as a global activity remains the simplest shape.
Reuse schemas
Schemas are ordinary values. Extract and share them:
const Money = z.object({
amount: z.number().positive(),
currency: z.enum(["USD", "EUR", "GBP"]),
});
const Address = z.object({
street: z.string(),
city: z.string(),
postalCode: z.string().regex(/^\d{5}$/),
});
const OrderSchema = z.object({
orderId: z.string(),
items: z.array(z.object({ sku: z.string(), quantity: z.number().int().positive() })).min(1),
total: Money,
shipTo: Address,
billTo: Address.optional(),
});Reuse the whole resource, not just its schemas, when the operation is identical:
const auditActivities = { writeAuditLog };
export const orderContract = defineContract({
taskQueue: "orders",
activities: auditActivities,
workflows: { processOrder },
});
export const shipmentContract = defineContract({
taskQueue: "shipments",
activities: auditActivities,
workflows: { dispatchShipment },
});Attach signals, queries, and updates
import { defineQuery, defineSignal, defineUpdate } from "@temporal-contract/contract";
const approve = defineSignal({
input: z.object({ approvedBy: z.string() }),
});
const getStatus = defineQuery({
output: z.object({ state: z.enum(["pending", "approved", "shipped"]) }),
});
const changeAddress = defineUpdate({
input: z.object({ address: Address }),
output: z.object({ address: Address }),
});
const processOrder = defineWorkflow({
input: OrderSchema,
output: OrderResultSchema,
startPolicy: "retry-if-failed",
activities: { chargeCard },
signals: { approve },
queries: { getStatus },
updates: { changeAddress },
});Signals have input only. Queries and updates have input and output. On all three, input is optional: omit it for a no-payload signal (defineSignal()) or a no-argument query/update (defineQuery({ output }), defineUpdate({ output })) — the handler then receives undefined, and the client-side payload argument becomes omittable.
See Use signals, queries, and updates for handling them.
Declare domain errors
An errors map turns anticipated failures into typed, schema-validated values instead of opaque strings:
const chargeCard = defineActivity({
input: z.object({ customerId: z.string(), amount: z.number().positive() }),
output: z.object({ transactionId: z.string() }),
errors: {
CardDeclined: {
data: z.object({ reason: z.string(), retryAfter: z.number().optional() }),
message: "The card was declined",
nonRetryable: true,
},
GatewayUnavailable: {}, // no payload, retryable
},
});The error's key becomes the ApplicationFailure.type on the wire, data is validated when the error is raised and parsed when it is rehydrated on the consuming side, and nonRetryable drives Temporal's retry policy straight from the contract.
Workflows declare errors the same way. See Model domain errors.
Declare search attributes
import { defineSearchAttribute } from "@temporal-contract/contract";
const processOrder = defineWorkflow({
input: OrderSchema,
output: OrderResultSchema,
startPolicy: "retry-if-failed",
searchAttributes: {
customerId: defineSearchAttribute({ kind: "KEYWORD" }),
orderTotal: defineSearchAttribute({ kind: "DOUBLE" }),
placedAt: defineSearchAttribute({ kind: "DATETIME" }),
},
});See Index workflows with search attributes.
Ship activity options with the contract
Operational defaults can live on the contract rather than being repeated in every worker:
const sendNotification = defineActivity({
input: NotificationSchema,
output: z.void(),
activityOptions: {
startToCloseTimeout: "30 seconds",
retry: { maximumAttempts: 5 },
},
});activityOptions is a strict object — a typo like startToCloseTimeOut fails at defineContract time instead of being silently ignored, and duration strings are validated against the ms grammar ("30 seconds", "5m", "1.5h"), so "5 minutos" fails at definition instead of surfacing later as an opaque worker error. For the full merge order, see Tune activity options.
Keep contracts focused
One contract maps to one task queue, which maps to one worker deployment. Let that boundary guide the split:
// ✅ A contract per domain, each with its own queue and worker
export const orderContract = defineContract({
taskQueue: "orders",
workflows: { processOrder, cancelOrder, refundOrder },
});
export const shipmentContract = defineContract({
taskQueue: "shipments",
workflows: { dispatchShipment, trackShipment },
});Workflows in different contracts still call each other — see Run child workflows.
What defineContract checks
It validates at call time and throws a ContractDefinitionError (exported from the package root; path names the offending slot, e.g. workflows.processOrder.activities.charge) on:
- a missing or empty
taskQueue, one with leading/trailing whitespace, or one longer than 1000 characters (Temporal's default limit); - a contract that declares nothing — at least one workflow or one global activity is required (
workflows: {}with globalactivitiesis valid: an activity-only contract models a dedicated activity-pool task queue); - an unknown key anywhere — on the contract root or on any workflow, activity, signal, query, update, error, or search-attribute definition (e.g. a misspelled
workflow, or a leftoverdefaultOptions/idempotency); - a workflow without a valid
startPolicy, or aworkflowId/idempotencyKeythat is not a function; - any name that is not a valid JavaScript identifier, or that is an
Object.prototypemember (constructor,toString, …); - a name Temporal reserves for its SDK internals — anything starting with
__temporal_, plus the exact query names__stack_traceand__enhanced_stack_trace; - an error name the worker uses for its own failures (
WorkflowInputValidationError,ContractMisuseError, …), or a search attribute named like a Temporal system attribute (WorkflowId,ExecutionStatus, …) or declared with two different kinds across workflows; - an
input,output, or errordatathat is not Standard Schema compatible; - duplicate activity names across the flat namespace that point at different definitions (sharing one
defineActivityresult is allowed); - unknown keys, malformed duration strings, or a retry policy Temporal would reject in
activityOptions(backoffCoefficientbelow 1, a non-integer or non-positivemaximumAttempts, a zero interval, ormaximumIntervalbelowinitialInterval).
Contract validation failed at taskQueue: taskQueue cannot be emptyBecause this runs at import time, a malformed contract fails when the process starts rather than when a workflow first executes. TypeScript additionally rejects a misspelled key on a defineActivity / defineWorkflow / defineSignal / defineQuery / defineUpdate literal; everything else is a runtime check only.
Next
- Implement activities
- Evolve a contract — changing it once executions are in flight
- Contract surface — every option, exhaustively
- Validation boundaries — where and why schemas run