Just classes
Decorated workflow classes, plain activity classes, and a worker you hand both to. Nothing is generated, and there’s nothing to keep in sync.
All you need is Node.js 22.18 or later. pnpm dev downloads and starts a local Temporal server for you.
pnpm create @norn-ts my-appcd my-apppnpm devThen, in a second terminal, run pnpm order and open the link it prints. You’ll see an order workflow running in the Temporal Web UI.
A workflow charges a card, then waits five minutes for a cancellation. If the worker crashes during the wait, Temporal replays the history on another worker and the timer carries on.
import { condition } from '@temporalio/workflow';import { Query, Signal, Workflow, useActivities } from '@norn-ts/norn/workflow';import type { PaymentActivities } from '../activities/payment.activities';
@Workflow({ taskQueue: 'orders', id: 'order-{orderId}' })export class OrderWorkflow { private status: 'pending' | 'paid' | 'refunded' = 'pending'; private canceled = false; private readonly payments = useActivities<PaymentActivities>('payments', { timeout: '30s', retries: 3, nonRetryable: ['CardDeclinedError'], });
@Signal() cancel(_reason: string) { this.canceled = true; }
@Query() getStatus() { return this.status; }
async run(input: { orderId: string; cents: number }) { const charge = await this.payments.charge(input.orderId, input.cents); this.status = 'paid'; if (await condition(() => this.canceled, '5 minutes')) { await this.payments.refund(charge.id); this.status = 'refunded'; } return this.status; }}// src/activities/payment.activities.ts: a plain class, where the I/O happensexport class PaymentActivities { constructor(private readonly gateway: PaymentGateway) {}
async charge(orderId: string, cents: number) { return this.gateway.charge({ orderId, cents, idempotencyKey: `charge-${orderId}` }); }
async refund(chargeId: string) { return this.gateway.refund(chargeId); }}import { createWorker } from '@norn-ts/norn/worker';
const worker = await createWorker({ workflows: new URL('./workflows/index.ts', import.meta.url), activities: { payments: new PaymentActivities(new StripeGateway()) },});await worker.run();The client takes the workflow class itself, so names, inputs, results and handler arguments are all checked. Hover over the code to see the types:
import { class OrderWorkflow
OrderWorkflow } from './order.workflow';
const const norn: NornClient
norn = function createClient(options?: ClientOptions): NornClient
Creates a typed Temporal client. Pass @Workflow classes to workflow().
createClient();// The workflow ID, order-o-42, comes from the template.const const order: WorkflowHandle<ClassContract<typeof OrderWorkflow>>
order = await const norn: NornClient
norn.NornClient.workflow<typeof OrderWorkflow>(workflowClass: typeof OrderWorkflow): WorkflowClient<ClassContract<typeof OrderWorkflow>>
A typed client for a @Workflow class: norn.workflow(OrderWorkflow).start(input).
workflow(class OrderWorkflow
OrderWorkflow).WorkflowClient<ClassContract<typeof OrderWorkflow>>.start(input: { orderId: string; cents: number;}, options?: StartOptions<ClassContract<typeof OrderWorkflow>> | undefined): Promise<WorkflowHandle<ClassContract<typeof OrderWorkflow>>>
start({ orderId: string
orderId: 'o-42', cents: number
cents: 1999 });
const status = await const order: WorkflowHandle<ClassContract<typeof OrderWorkflow>>
order.WorkflowHandle<ClassContract<typeof OrderWorkflow>>.query<"getStatus">(key: "getStatus"): Promise<"pending" | "paid" | "refunded">
query('getStatus');const status: "pending" | "paid" | "refunded"
// Wrong arguments and unknown fields are compile errors:await const order: WorkflowHandle<ClassContract<typeof OrderWorkflow>>
order.WorkflowHandle<ClassContract<typeof OrderWorkflow>>.signal<"cancel">(key: "cancel", _reason: string): Promise<void>
signal('cancel', 42);Error ts(2345) ― const norn: NornClient
norn.NornClient.workflow<typeof OrderWorkflow>(workflowClass: typeof OrderWorkflow): WorkflowClient<ClassContract<typeof OrderWorkflow>>
A typed client for a @Workflow class: norn.workflow(OrderWorkflow).start(input).
workflow(class OrderWorkflow
OrderWorkflow).WorkflowClient<ClassContract<typeof OrderWorkflow>>.get(target: HandleTarget<ClassContract<typeof OrderWorkflow>>): WorkflowHandle<ClassContract<typeof OrderWorkflow>>
A handle by ID fields (from the contract's template) or { workflowId, runId? }. Makes no call.
get({ orderID: 'o-42' });Error ts(2353) ― Just classes
Decorated workflow classes, plain activity classes, and a worker you hand both to. Nothing is generated, and there’s nothing to keep in sync.
Typed from the class
Inputs, results, handler arguments and activity signatures come straight from your classes, in workflows and in the client.
Sandbox-safe
Workflows import activity classes with import type, so activity code never reaches the workflow sandbox.
Tests that skip time
useTestEnv() runs your workflows on Temporal’s time-skipping server with fake activities, so a five-day timer
takes milliseconds.