Skip to main content

Workflows in TypeScript

@temporalio/workflow NPM API reference | GitHub source

Workflows are async functions that can orchestrate Activities and access special Workflow APIs, subject to deterministic limitations.

Each Workflow function has two parts:

  • The function name is known as the Workflow Type.
  • The function implementation code (body) is known as the Workflow Definition.
  • Each Workflow Definition is bundled with any third party dependencies, and registered by Workflow Type in a Worker.

A Workflow function only becomes a Workflow Execution (instance) when started from a Workflow Client using its Workflow Type.

How to write a Workflow function#

Workflow Definitions are "just functions", which can store state, and orchestrate Activity functions.

import { createActivityHandle } from '@temporalio/workflow';// Only import the activity typesimport type * as activities from './activities';
const { greet } = createActivityHandle<typeof activities>({  startToCloseTimeout: '1 minute',});
/** A workflow that simply calls an activity */export async function example(name: string): Promise<string> {  return await greet(name);}

The snippet above uses createActivityHandle to create functions that, when called, schedule a greet Activity in the system to say "Hello World".

Workflow Limitations#

Workflow code must be deterministic, and the TypeScript SDK replaces common sources of nondeterminism for you, like Date.now(), Math.random, and setTimeout (we recommend using our sleep API instead). However, there are other important limitations:

  • No Node built-ins like process or the path and fs modules
  • No filesystem access
  • No network access

These constraints don't apply inside Activities. If you need to ping an API, or access the filesystem (e.g. for building a CI/CD system), move that code into Activities.

How to Start and Cancel Workflows#

See the TypeScript SDK Client docs for how to use WorkflowHandles to start, cancel, signal, query, describe and more.

Workflow APIs#

The @temporalio/workflow package exports all the useful primitives that you can use in Workflows. See the API reference for the full list, but the main ones are:

APIsPurpose
defineSignal/defineQuerySignal and Query Workflows while they are running
sleepPrimitive to build durable Timers
conditionBlock until a condition is true. Often used with Signals
createActivityHandleMake idempotent side effects (like making a HTTP request) with Activities (see Activities doc)
createChildWorkflowHandleSpawn new Child Workflows with the ability to cancel
continueAsNewTruncate Event History for infinitely long running Workflows
patched/deprecatePatchMigrate Workflows to new versions (see Patching doc)
uuid4Generate an RFC compliant V4 uuid without needing to call an Activity or Side Effect.
APIs for advanced usersincluding workflowInfo, isCancellation, dependencies, Cancellation Scopes, Failure, and createExternalWorkflowHandle

We fully expect that developers will bundle these into their own reusable Workflow libraries. If you do, please get in touch on Slack, we would love to work with you and promote your work.

Signals and Queries#

Signals are a way to send data IN to a running Workflow.

Signals are a fully asynchronous and durable mechanism for sending data into a running Workflow (as opposed to passing data as arguments when starting the Workflow or polling external data in Activities).

  • Signals can receive data, but cannot return it.
  • When a Signal is received for a running Workflow, Temporal persists the Signal event and payload in the Workflow history. The Workflow can then process the Signal at any time afterwards without the risk of losing the information.
  • In the Go SDK, a Workflow can pause until it receives a Signal by blocking on a Signal channel.
  • If you don't know if the Workflow is currently running, you can use SignalWithStart and a new Workflow run will start up to receive the Signal if needed.
Queries are a way to read data OUT from a running Workflow.
  • Queries can receive arguments, and return data, but must not mutate Workflow state.
  • If a Query is made to a completed Workflow, the final value is returned.

Signals and Queries are almost always used together. If you wanted to send data in, you probably will want to read data out.

How to define and receive Signals and Queries#

  • To add a Signal to a Workflow, call defineSignal with a name, and then attach a listener with setListener.
  • To add a Query to a Workflow, call defineQuery with a name, and then attach a listener with setListener.

signals-queries/src/workflows.ts

import * as wf from '@temporalio/workflow';
export const unblockSignal = wf.defineSignal('unblock');export const isBlockedQuery = wf.defineQuery<boolean>('isBlocked');
export async function unblockOrCancel(): Promise<void> {  let isBlocked = true;  wf.setListener(unblockSignal, () => void (isBlocked = false));  wf.setListener(isBlockedQuery, () => isBlocked);  console.log('Blocked');  try {    await wf.condition(() => !isBlocked);    console.log('Unblocked');  } catch (err) {    if (err instanceof wf.CancelledFailure) {      console.log('Cancelled');    }    throw err;  }}

Listeners for both Signals and Queries can take arguments, which can be used inside setListener to mutate state or compute return values respectively.

Why NOT new Signal and new Query?

The semantic of defineSignal/defineQuery is intentional, in that they return Signal/Query Definitions, not unique instances of Signals and Queries themselves. Signals/Queries are only instantiated with setListener and are specific to a particular Workflow Execution.

These distinctions may seem minor, but they model how Temporal works under the hood, because Signals and Queries are messages identified by "just strings" and don't have meaning independent of the Workflow having a listener to handle them.

Why setListener and not OTHER_API?

We named it setListener instead of subscribe because Signals/Queries can only have one listener at a time, whereas subscribe could imply an Observable with multiple consumers. If you are familiar with Rxjs, you are free to wrap your Signal and Query into Observables if you wish, or you could dynamically reassign the listener based on your business logic/Workflow state.

How to send Signals and make Queries#

  • You invoke a Signal with workflow.signal(signal, ...args). A Signal has no return value by definition.
  • You make a Query with workflow.query(query, ...args).
  • You can refer to either by string name, but you will lose type safety.
const increment =  defineSignal<[number /* more args can be added here */]>('increment');const count = defineQuery<number /*, Arg[] can be added here */>('count');
// these two are equivalentawait handle.signal(increment, 1);await handle.signal<[number]>('increment', 1);
// these two are equivalentlet state = await handle.query(count);let state = await handle.query<number>('count');

Type-safe Signals and Queries#

The Signals and Queries API has been designed with type safety in mind:

  • wf.defineQuery<Ret, Args>(name): QueryDefinition<Ret, Args>
  • wf.defineSignal<Args>(name): SignalDefinition<Args>
  • WorkflowHandle.query<Ret, Args>(def, ...args): Promise<Ret>
  • WorkflowHandle.signal<Args>(def, ...args): Promise<Ret>

You can either:

  • Define the argument type (and, for Queries, the return type) up front and import it for type inference with the WorkflowHandle
  • Define the expected type at the call site when you invoke the Signal/Query.
const increment =  defineSignal<[number /* more args can be added here */]>('increment');const count = defineQuery<number /*, Arg[] can be added here */>('count');
// type safety inferred from definitionsawait handle.signal(increment, 1);await handle.signal(increment); // Expected 2 arguments, but got 1.await handle.signal(increment, '1'); // Argument of type 'string' is not assignable to parameter of type 'number'
// common problems when you lack type safetyawait handle.signal('increment'); // No TS error but insufficient argumentsawait handle.signal('increment', '1'); // No TS error but sending in wrong type
// add type safety at callsiteawait handle.signal<[number]>('increment'); // Expected 2 arguments, but got 1.let state = await handle.query<number, [string]>('print', 'Count: ');

Advanced Notes#

Queries#

๐Ÿšจ WARNING: NEVER mutate Workflow state inside a query! This would be a source of non-determinism.

How NOT to write a Query

This mutates Workflow state - do not do this:

export function badExample() {  let someState = 123;  setListener(query, () => {    return someState++; // bad! don't do this!  });}
Signals#
Notes on Signals

WorkflowHandle.signal returns a Promise that only resolves when Temporal Server has persisted receipt of the Signal, before the Workflow's Signal handler is called. This Promise resolves with no value; Signal handlers cannot return data to the caller.

No Synchronous Updates

A common request is for a Signal to be invoked with a bad argument, causing a validation error. However Temporal has no way to surface the error to the external invocation. Signals and Queries are always asynchronous, in other words, a Signal always succeeds.

The solution to this is "Synchronous Update" and we plan to add it in future.

For now the best workaround is to use a Query to return Workflow state after signaling. Temporal guarantees read-after-write consistency of Signals-followed-by-Queries.

Componentization#

Because Signal and Query Definitions are separate from Workflow Definitions, we can now compose them together:

// basic reusable Workflow componentexport async function unblocked() {  let isBlocked = true;  setListener(unblockSignal, () => (isBlocked = false));  setListener(isBlockedQuery, () => isBlocked);  await condition(() => !isBlocked);}
// usage: signals can be sent to each Workflow separatelyexport async function myWorkflow1() {  await unblocked();}export async function myWorkflow2() {  await unblocked();}

Another example of componentization can be found in our code samples.

signalWithStart#

If you're not sure if a Workflow is running, you can signalWithStart a Workflow to send it a Signal and optionally start the Workflow if it is not running. Arguments for both are sent as needed.

// Signal With Startconst client = new WorkflowClient();let workflow = client.createWorkflowHandle(  interruptableWorkflow, // which Workflow to start  { taskQueue: 'test' });await workflow.signalWithStart(  interruptSignal, // which Signal to send  ['interrupted from signalWithStart'], // arguments to send with Signal  [] // arguments to start the Workflow if needed);
Triggers#

Triggers are a concept unique to the Temporal TypeScript SDK. They may be deprecated in future.

Triggers, like Promises, can be awaited and expose a then method. Unlike Promises they are triggered when their resolve or reject methods are called.

Trigger is CancellationScope-aware. It is linked to the current scope on construction and throws when that scope is cancelled.

condition#

condition(timeout?, function) returns a promise that resolves when a supplied function returns true or if an (optional) timeout happens first. This API is comparable to Workflow.await in other SDKs and often used to wait for Signals.

The timeout also uses the ms package to take either a string or number of milliseconds.

/** * Returns a Promise that resolves when `fn` evaluates to `true` or `timeout` expires. * * @param timeout - formatted string or number of milliseconds * * @returns a boolean indicating whether the condition was true before the timeout expires */export function condition(  timeout: number | string,  fn: () => boolean): Promise<boolean>;
// Returns a Promise that resolves when `fn` evaluates to `true`.export function condition(fn: () => boolean): Promise<void>;
// Usageimport { condition } from '@temporalio/workflow';
let x = 0;// do stuff with x, eg increment every time you receive a signalawait condition(() => x > 3);// you only reach here when x > 3
// await earlier of condition to be true or 30 day timeoutawait condition('30 days', () => x > 3);

condition Anti-patterns#

condition Antipatterns
  • No time based condition functions are allowed in your function as this is very error prone. Use the optional timeout arg or a sleep timer.
  • condition only accepts synchronous functions that return a boolean. Do not put async functions, like Activities, inside the condition function.

Timers#

Timers help you write durable asynchronous code in Temporal. Temporal offers you just two primitives โ€” setTimeout and sleep โ€” that you can use to build reusable workflow libraries and utilities:

  • The setTimeout global works as normal in JavaScript. The Workflow's v8 isolate environment completely replaces it, including inside libraries that you use, to provide a complete JS runtime. We recommend using our sleep API instead of setTimeout because it supports cancellation (see below).
  • sleep(timeout): a cancellation-aware Promise wrapper for setTimeout, that accepts either a string or integer timeout.
Why Durable Timers Are a Hard Problem

JavaScript has a setTimeout, which seems relatively unremarkable. However, they are held in memory - if your system goes down, those timers are gone.

A lot of careful code is required to make these timeouts fully reliable (aka recoverable in case of outage.) Beyond that, further engineering is needed to scale this - imagine 100,000 independently running timers in your system, firing every minute. That is the kind of scale Temporal handles.

Preventing Confusion

This section only covers Workflow Timers.

sleep#

sleep uses the ms package to take either a string or number of milliseconds, and returns a promise that you can await.

/** * Asynchronous sleep. Schedules a timer on the Temporal service. * * @param ms sleep duration - formatted string or number of milliseconds */export function sleep(ms: number | string): Promise<void>;
// durably sleep for 30 daysimport { sleep } from '@temporalio/workflow';
await sleep('30 days'); // string APIawait sleep(30 * 24 * 60 * 60 * 1000); // numerical API

sleep is cancellation-aware, meaning that when the workflow gets cancelled, the sleep timer is canceled and the promise is rejected:

await sleep('30 days').catch(() => {  // clean up code if workflow is canceled during sleep});

You can read more on the Cancellation Scopes doc.

Timer design patterns#

There are only two Timer APIs, but the important part is knowing how to use them to model asynchronous business logic. Here are some examples we use the most; we welcome more if you can think of them!

Racing Timers

Use Promise.race with Signals and Triggers to have a promise resolve at the earlier of either system time or human intervention.

You can invert this to create a Reminder pattern where the promise resolves IF no Signal is received.

Antipattern: Racing Sleep.then

Be careful when racing a chained sleep. This may cause bugs.

await Promise.race([  sleep('5s').then(() => (status = 'timed_out')),  somethingElse.then(() => (status = 'processed')),]);
if (status === 'processed') await complete(); // takes more than 5 seconds// status = timed_out
Updatable Timer

Here is how you can build an updatable timer with condition:

Child Workflows#

Besides Activities, a Workflow can also start other Workflows.

Child Workflows vs Activities

Child Workflows and Activities are both started from Workflows, so you may feel confused about when to use which. Here are some important differences:

  • Child Workflows have access to all Workflow APIs, but are subject to Workflow Limitations. Activities have the inverse pros and cons.
  • Child Workflows can continue on if their Parent is canceled, with a ParentClosePolicy of ABANDON, whereas Activities are always canceled when their Workflow is canceled (they may react to a cancellationSignal for cleanup if canceled). The decision is roughly analogous to spawning a child process in a terminal to do work vs doing work in the same process.
  • Temporal tracks all state changes within Child Workflows in Event History, whereas only the input, output, and retry attempts of Activities are tracked.

Activities usually model a single operation on the external world. Workflows are modeling composite operations that consist of multiple activities or other child workflows.

When in doubt, use Activities.

To execute a child workflow and await its completion:

child-workflows/src/workflows.ts

export async function parentWorkflow(names: string[]): Promise<string> {  const responseArray = await Promise.all(    names.map((name) => {      const child = createChildWorkflowHandle(childWorkflow);      return child.execute(name);    })  );  return responseArray.join('\n');}

createChildWorkflowHandle returns a ChildWorkflowHandle that can be used to start a new child Workflow, signal it and await its completion.

Child Workflow Option fields automatically inherit their values from the Parent Workflow Options if they are not explicitly set.

Child Workflow executions are CancellationScope aware and will automatically be cancelled when their containing scope is cancelled.

Parent Close Policy#

A Parent Close Policy determines what happens to a Child Workflow Execution if its Parent changes to a Closed status (Completed, Failed, or Timed out). There are three possible values:

  • Abandon: the Child Workflow Execution is not affected.
  • Terminate (default): the Child Workflow Execution is forcefully Terminated.
  • Request Cancel: a Cancellation request is sent to the Child Workflow Execution.

Each Child Workflow Execution may have its own Parent Close Policy. This policy only applies to Child Workflow Executions and has no effect otherwise.

Infinite Workflows#

Why ContinueAsNew is needed#

Temporal stores the execution history of all Workflows. There is a maximum limit of this execution history (50,000 events). Even though Temporal Server emits warnings while your workflow are approaching this limit (every 10,000 events), you should make sure your workflows don't reach it.

Workflows that periodically execute a number of Activities, for a long time, have the potential of running into this execution history size limit.

One way of dealing with this issue is to use ContinueAsNew. This feature allows you to complete the current Workflow execution and start a new one atomically. This new execution has the same Workflow Id, but a different Run Id, and as such will get its own execution history.

If your Workflows are running periodically using a Cron definition, the ContinueAsNew feature is used internally by Temporal. In this case, each Workflow execution as defined by the Cron definition will have its own Run Id and execution history.

The continueAsNew API#

Use the continueAsNew API to instruct the TypeScript SDK to restart loopingWorkflow with a new starting value and a new event history.

import { continueAsNew, sleep } from '@temporalio/workflow';
export async function loopingWorkflow(iteration = 0): Promise<void> {  if (iteration === 10) {    return;  }  console.log('Running Workflow iteration:', iteration);  await sleep(1000);  // Must match the arguments expected by `loopingWorkflow`  await continueAsNew<typeof loopingWorkflow>(iteration + 1);  // Unreachable code, continueAsNew is like `process.exit` and will stop execution once called.}

You can also call continueAsNew from a signal handler or continueAsNew to a different Workflow (or different Task Queue) using makeContinueAsNewFunc.

If you need to know whether a Workflow was started via continueAsNew, you can pass an optional last argument as true:

import { continueAsNew } from '@temporalio/workflow';
export async function loopingWorkflow(  foo: any,  isContinuedAsNew: boolean): Promise<void> {  // some logic based on foo, branching on isContinuedAsNew
  (await continueAsNew) < typeof loopingWorkflow(foo, true);}