Run child workflows
A child workflow is a workflow started by another workflow. Use one when a sub-process deserves its own execution history, its own retry and timeout policy, or its own task queue and worker fleet.
Both entry points take the child's contract as the first argument, so same-contract and cross-contract calls look identical.
Execute and wait
executeChildWorkflow starts the child and waits for its result:
import { declareWorkflow, propagateFailure } from "@temporal-contract/worker/workflow";
import { P } from "unthrown";
import { orderContract } from "./contract.js";
export const processOrder = declareWorkflow({
workflowName: "processOrder",
contract: orderContract,
activityOptions: { startToCloseTimeout: "1 minute", retry: { maximumAttempts: 3 } },
implementation: async (context, order) => {
const payment = await context.executeChildWorkflow(orderContract, "collectPayment", {
workflowId: `payment-${order.orderId}`,
args: { customerId: order.customerId, amount: order.total },
parentClosePolicy: "TERMINATE",
});
return payment.match({
ok: (output) => ({ status: "completed" as const, transactionId: output.transactionId }),
errCases: (matcher) =>
matcher.with(
P.tag("@temporal-contract/ChildWorkflowError"),
P.tag("@temporal-contract/ChildWorkflowCancelledError"),
P.tag("@temporal-contract/ChildWorkflowNotFoundError"),
(error) => ({ status: "failed" as const, reason: error.message }),
),
defect: (cause) => ({
status: "failed" as const,
reason: cause instanceof Error ? cause.message : "unexpected failure",
}),
});
},
});Both activity and child-workflow calls return an AsyncResult — the uniformity is deliberate. What differs is the usual response: a child workflow is a peer operation whose failure is usually a branch in your logic (narrow it, as above), whereas an activity failure is normally something Temporal's retry policy should already have handled by the time it reaches the workflow (propagate it with propagateFailure, unless the workflow itself needs to branch on it too). See The result model.
Start without waiting
startChildWorkflow returns a handle as soon as the child starts, letting the parent continue and collect the result later:
implementation: async (context, order) => {
const started = await context.startChildWorkflow(orderContract, "sendReceipt", {
workflowId: `receipt-${order.orderId}`,
args: { customerId: order.customerId },
parentClosePolicy: "TERMINATE",
});
if (started.isDefect()) {
throw started.cause;
}
if (started.isErr()) {
return { status: "failed", reason: started.error.message };
}
// Do other work while the child runs.
const shipment = await propagateFailure(
context.activities.createShipment({ orderId: order.orderId }),
);
// Then collect.
const receipt = await started.value.result();
return {
status: "completed",
trackingNumber: shipment.trackingNumber,
receiptSent: receipt.isOk(),
};
};The handle exposes workflowId, firstExecutionRunId (the anchor of the child's execution chain, stable across continue-as-new), a typed signals map, and result().
Signal a running child
The handle's signals map mirrors the client handle's — one sender per signal the child's contract entry declares, fully typed:
implementation: async (context, order) => {
const started = await context.startChildWorkflow(orderContract, "collectPayment", {
workflowId: `payment-${order.orderId}`,
args: { customerId: order.customerId, amount: order.total },
parentClosePolicy: "TERMINATE",
});
if (started.isDefect()) {
throw started.cause;
}
if (started.isErr()) {
return { status: "failed", reason: started.error.message };
}
// Typed: the payload is checked against the child's signal schema.
const signaled = await started.value.signals.applyDiscount({ percent: 10 });
if (signaled.isErr()) {
// ChildWorkflowError (incl. a payload failing validation before send)
// or ChildWorkflowCancelledError.
}
const payment = await started.value.result();
return { status: payment.isOk() ? "completed" : "failed" };
};The payload is validated before sending and parsed by the child on receive. Unlike a client handle — where a payload-less signal's argument is omittable — a child handle's signal sender always takes an explicit argument, so pass undefined for a payload-less signal (applyDiscount(undefined)).
Run children in parallel
Start them all, then await:
implementation: async (context, order) => {
const started = await Promise.all(
order.items.map((item) =>
context.startChildWorkflow(orderContract, "fulfilItem", {
workflowId: `fulfil-${order.orderId}-${item.sku}`,
args: { sku: item.sku, quantity: item.quantity },
parentClosePolicy: "TERMINATE",
}),
),
);
const handles = started.filter((s) => s.isOk()).map((s) => s.value);
const results = await Promise.all(handles.map((h) => h.result()));
return {
fulfilled: results.filter((r) => r.isOk()).length,
failed: results.filter((r) => r.isErr()).length,
};
};Promise.all is safe in workflow code — Temporal's deterministic scheduler handles it. What is not safe is anything that reads real-world state; see Workflow determinism.
Call across contracts
Pass the other contract. Its task queue and workflow type come along, so the child runs on whichever worker fleet serves it:
import { notificationContract } from "@acme/notification-contract";
implementation: async (context, order) => {
const notified = await context.executeChildWorkflow(
notificationContract,
"sendOrderConfirmation",
{
workflowId: `notify-${order.orderId}`,
args: { orderId: order.orderId, customerId: order.customerId },
parentClosePolicy: "TERMINATE",
},
);
return { status: notified.isOk() ? "completed" : "partial" };
};Both sides stay fully typed: the child's args are checked against its contract, and its output type flows back. Publish contracts as their own packages and this works across teams.
Control the child's lifecycle
TypedChildWorkflowOptions is Temporal's ChildWorkflowOptions minus taskQueue and args (which come from the contract), plus the typed args:
await context.executeChildWorkflow(orderContract, "collectPayment", {
workflowId: `payment-${order.orderId}`,
args: { customerId: order.customerId, amount: order.total },
// Required — there is no default to inherit. What happens to the child if
// the parent closes: TERMINATE | REQUEST_CANCEL | ABANDON.
parentClosePolicy: "REQUEST_CANCEL",
workflowExecutionTimeout: "1 hour",
workflowRunTimeout: "10 minutes",
retry: { maximumAttempts: 3 },
// Reuse behaviour when the id already exists. The child's contract already
// supplies this from its `startPolicy` mode — set it here only to override
// that default for this one call.
workflowIdReusePolicy: "ALLOW_DUPLICATE_FAILED_ONLY",
});parentClosePolicy is required — there is no default to inherit, so every call has to state what happens to the child when the parent closes: TERMINATE kills the child immediately (this reproduces the behavior every call site got by default before this was enforced), REQUEST_CANCEL if a child needs to compensate first, or ABANDON for fire-and-forget work that should outlive its parent. declareWorkflow rejects the call at TypeScript compile time if the field is omitted, and rejects an explicit undefined too — it will not silently fall through to Temporal's own TERMINATE default.
Choose child workflows or activities
| Use an activity | Use a child workflow |
|---|---|
| A single side effect | A multi-step process |
| Retry policy is enough | Needs its own signals, queries, or timers |
| Short-lived | Long-running or independently cancellable |
| Shares the parent's worker | Should run on a different task queue |
Child workflows are heavier — each gets its own execution history. Do not reach for one where an activity suffices.
Error channel
| Error | When |
|---|---|
ChildWorkflowNotFoundError | The name is not on the contract you passed |
ChildWorkflowError | The child failed, timed out, or was terminated |
ChildWorkflowCancelledError | The child was cancelled |
result() narrows further — it cannot return ChildWorkflowNotFoundError, because resolution already succeeded.