Using Executors / Builders

Executors perform actions on your code. This can include building, linting, testing, serving and many other actions.

There are two main differences between an executor and a shell script or an npm script:

  1. Executors encourage a consistent methodology for performing similar actions on unrelated projects. i.e. A developer switching between teams can be confident that nx build project2 will build project2 with the default settings, just like nx build project1 built project1.
  2. Nx can leverage this consistency to perform the same executor across multiple projects. i.e. nx affected --target=test will run the test executor on every project that is affected by the current code change.

Executor definitions

The executors that are available for each project are defined and configured in the /workspace.json file.

1{
2  "projects": {
3    "cart": {
4      "root": "apps/cart",
5      "sourceRoot": "apps/cart/src",
6      "projectType": "application",
7      "generators": {},
8      "targets": {
9        "build": {
10          "executor": "@nrwl/web:build",
11          "options": {
12            "outputPath": "dist/apps/cart",
13            ...
14          },
15          "configurations": {
16            "production": {
17              "sourceMap": false,
18              ...
19            }
20          }
21        },
22        "test": {
23          "executor": "@nrwl/jest:jest",
24          "options": {
25            ...
26          }
27        }
28      }
29    }
30  }
31}

Note: There are a few property keys in workspace.json that have interchangeable aliases. You can replace generators with schematics, targets with architect or executor with builder.

Each project has its executors defined in the targets property. In this snippet, cart has two executors defined - build and test.

Note: build and test can be any strings you choose. For the sake of consistency, we make test run unit tests for every project and build produce compiled code for the projects which can be built.

Each executor definition has an executor property and, optionally, an options and a configurations property.

  • executor is a string of the from [package name]:[executor name]. For the build executor, the package name is @nrwl/web and the executor name is build.
  • options is an object that contains any configuration defaults for the executor. These options vary from executor to executor.
  • configurations allows you to create presets of options for different scenarios. All the configurations start with the properties defined in options as a baseline and then overwrite those options. In the example, there is a production configuration that overrides the default options to set sourceMap to false.

Running executors

The nx run cli command (or the shorthand versions) can be used to run executors.

nx run [project]:[command]
nx run cart:build

As long as your command name doesn't conflict with an existing nx cli command, you can use this short hand:

nx [command] [project]
nx build cart

You can also use a specific configuration preset like this:

nx [command] [project] --configuration=[configuration]
nx build cart --configuration=production

Or you can overwrite individual executor options like this:

nx [command] [project] --[optionNameInCamelCase]=[value]
nx build cart --outputPath=some/other/path

Simplest executor

1{
2  "cli": "nx",
3  "id": "CustomExecutor",
4  "type": "object",
5  "properties": {},
6  "additionalProperties": true
7}
1export default async function (opts) {
2  console.log('options', opts);
3}

Defining an executor schema

An executor's schema describes the inputs--what you can pass into it. The schema is used to validate inputs, to parse args (e.g., covert strings into numbers), to set defaults, and to power the VSCode plugin. It is written with JSON Schema.

1{
2  "cli": "nx",
3  "id": "Echo",
4  "description": "echo given string",
5  "type": "object",
6  "properties": {
7    "message": {
8      "type": "string",
9      "description": "Message to echo"
10    },
11    "upperCase": {
12      "type": "boolean",
13      "description": "Covert to all upper case",
14      "default": false
15    }
16  },
17  "required": ["message"]
18}

The schema above defines two fields: message and upperCase. The message field is a string, upperCase is a boolean. The schema support for executors and generators is identical. See the section on generators above for more information.

Implementing an executor

The implementation function takes two arguments (the options and the executor context) and returns a promise (or an async iterable) with the success property. The context params contains information about the workspace and the invoked target.

Most of the time executors return a promise.

1interface Schema {
2  message: string;
3  upperCase: boolean;
4}
5
6export default async function printAllCaps(
7  options: Schema,
8  context: ExecutorContext
9): Promise<{ success: true }> {
10  if (options.upperCase) {
11    console.log(options.message.toUpperCase());
12  } else {
13    console.log(options.message);
14  }
15  return { success: true };
16}

But you can also return an async iterable that can yield several values.

1async function wait() {
2  return new Promise((res) => {
3    setTimeout(() => res(), 1000);
4  });
5}
6
7export default async function* counter(opts: { to: number; result: boolean }) {
8  for (let i = 0; i < opts.to; ++i) {
9    console.log(i);
10    yield { success: false };
11    await wait();
12  }
13  yield { success: opts.result };
14}

Composing executors

An executor is just a function, so you can import and invoke it directly, as follows:

1import printAllCaps from 'print-all-caps';
2
3export default async function (
4  options: Schema,
5  context: ExecutorContext
6): Promise<{ success: true }> {
7  // do something before
8  await printAllCaps({ message: 'All caps' });
9  // do something after
10}

This only works when you know what executor you want to invoke. Sometimes, however, you need to invoke a target. For instance, the e2e target is often configured like this:

1{
2  "e2e": {
3    "builder": "@nrwl/cypress:cypress",
4    "options": {
5      "cypressConfig": "apps/myapp-e2e/cypress.json",
6      "tsConfig": "apps/myapp-e2e/tsconfig.e2e.json",
7      "devServerTarget": "myapp:serve"
8    }
9  }
10}

In this case we need to invoke the target configured in devSeverTarget. We can do it as follows:

1async function* startDevServer(
2  opts: CypressExecutorOptions,
3  context: ExecutorContext
4) {
5  const { project, target, configuration } = parseTargetString(
6    opts.devServerTarget
7  );
8  for await (const output of await runExecutor<{
9    success: boolean;
10    baseUrl?: string;
11  }>(
12    { project, target, configuration },
13    {
14      watch: opts.watch,
15    },
16    context
17  )) {
18    if (!output.success && !opts.watch)
19      throw new Error('Could not compile application files');
20    yield opts.baseUrl || (output.baseUrl as string);
21  }
22}

The runExecutor utility will find the target in the configuration, find the executor, construct the options (as if you invoked it in the terminal) and invoke the executor. Note that runExecutor always returns an iterable instead of a promise.

Devkit helper functions

  • logger -- Wraps console to add some formatting.
  • getPackageManagerCommand -- Returns commands for the package manager used in the workspace.
  • parseTargetString -- Parses a target string into {project, target, configuration}.
  • readTargetOptions -- Reads and combines options for a given target.
  • runExecutor -- Constructs options and invokes an executor.

See more helper functions in the Devkit API Docs

Using RxJS observables

The Nx devkit only uses language primitives (promises and async iterables). It doesn't use RxJS observables, but you can use them and convert them to a Promise or an async iterable.

You can convert Observables to a Promise with toPromise.

1import { of } from 'rxjs';
2
3export default async function (opts) {
4  return of({ success: true }).toPromise();
5}

You can use the rxjs-for-await library to convert an Observable into an async iterable.

1import { of } from 'rxjs';
2import { eachValueFrom } from 'rxjs-for-await-async';
3
4export default async function (opts) {
5  return eachValueFrom(of({ success: true }));
6}

See Also