Skip to content

Repository files navigation

temporal-contract

temporal-contract

Type-safe contracts for Temporal.io

End-to-end type safety and runtime validation for workflows and activities

CI npm version npm downloads TypeScript License: MIT

Documentation · Tutorial · Reference

The problem

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.

What this does

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() }),
  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, qualify } from "@temporal-contract/worker/activity";
import { fromPromise } from "unthrown";

export const activities = declareActivitiesHandler({
  contract: orderContract,
  activities: {
    processOrder: {
      chargeCard: ({ customerId, amount }) =>
        fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).map((charge) => ({
          transactionId: charge.id,
        })),
    },
  },
});

Call it — names, arguments, and results all typed, and validated at runtime:

const result = await client.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) =>
    matcher.with(
      tag("@temporal-contract/WorkflowValidationError"),
      tag("@temporal-contract/WorkflowFailedError"),
      // ...exhaustive — a missing tag is a compile error
      (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.

Features

  • 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
  • Typed domain errors — declare failures on the contract; consume them as schema-validated values with an exhaustive matcher
  • Explicit error handlingResult / AsyncResult from unthrown, with a separate defect channel that keeps genuine bugs loud
  • Child workflows — typed, including across contracts and teams
  • Schedules, cancellation scopes, continue-as-new, activity middleware, client interceptors — all contract-aware
  • Testing utilities — time-skipping (no Docker) and real-server (testcontainers) fixtures
  • Nexus — not implemented; see the status page

Install

# Core packages
pnpm add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client

# Peer dependencies
pnpm add unthrown zod \
  @temporalio/client @temporalio/common @temporalio/worker @temporalio/workflow

Requires Node.js ≥ 22.19, ESM ("type": "module"), and TypeScript strict. Developed against TypeScript 6.0.

Install unthrown explicitly even if your package manager auto-installs peers — your own code imports its Result / AsyncResult types, so it is a real dependency of yours. It must resolve to v5.

See Install for the per-process breakdown.

Documentation

📖 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

Packages

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.

Stability

The contract API (defineContract, declareWorkflow, declareActivitiesHandler, TypedClient) is stable. Earlier major bumps were migrations of the underlying result library, now settled on unthrown; there are no plans to switch again. The unthrown peer range tracks its current major line, and each raise is documented in the changelog with migration notes.

Upgrading from 7.x? See Upgrade to v8.

Contributing

See CONTRIBUTING.md.

License

MIT

About

End-to-end type safety and automatic validation for workflows and activities

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Used by

Contributors

Languages