When is writing a custom Angular CLI builder justified, and how do you create one that `ng run` can execute?
answer
- Architect runs targets
- createBuilder from @angular-devkit/architect
- builders.json plus package.json builders field
- BuilderOutput with success
- scheduleTarget to compose targets
basics
~20 sIt is justified for a repeatable project task that needs angular.json options and configurations. Write a createBuilder() handler returning { success }, declare it in builders.json, point the package.json builders field at it, and run ng run project:target.
solid answer
~40 sThe CLI's Architect tool runs **targets**, and each target names a builder: a function of `(options, context)` returning a `BuilderOutput` (`{ success, error? }`) directly, as a Promise, or as an Observable for watch mode. You write it with `createBuilder()` from `@angular-devkit/architect`. You declare it in a `builders.json` with `implementation`, `schema` and `description`, and point the package's `package.json` `builders` field at that file. The builder is then `<package>:<name>` in `angular.json`, gets options and configurations like any target, and runs with `ng run app:target[:configuration]`. `context.scheduleTarget()` lets it run other targets, such as a build, with their `angular.json` configuration resolved. It is justified when a task needs workspace-aware, configurable, composable execution. A one-off script is better as an npm script. Wrapping the application builder to inject bundler plugins is not a supported extension point.
code
ts · 22 linesimport { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core';
interface Options extends JsonObject {
buildTarget: string;
reportFile: string;
}
export default createBuilder(async (options: Options, context: BuilderContext): Promise<BuilderOutput> => {
const [project, target, configuration] = options.buildTarget.split(':');
context.reportStatus(`Building ${options.buildTarget}`);
const run = await context.scheduleTarget({ project, target, configuration });
const result = await run.result;
await run.stop();
if (!result.success) {
return { success: false, error: `Build ${options.buildTarget} failed.` };
}
context.logger.info(`Build succeeded; writing ${options.reportFile}`);
return { success: true };
});go deeper
Know that ng build and ng serve run builders configured as targets in angular.json, and that ng run can run any target.
Describe a builder's parts: createBuilder handler, BuilderOutput, schema, builders.json and the package.json builders field.
Decide when a builder is warranted, compose targets with scheduleTarget, support watch mode, and avoid unsupported hooks into the application builder.
Govern shared builders as internal products: versioning against Angular majors, ownership, and when to retire a builder in favour of built-in options.
## What a builder is Every `ng build`, `ng serve`, `ng test` and `ng extract-i18n` call is a request to **Architect**, the CLI's task runner, to run a **target** from `angular.json`. The target names a **builder** (`package:name`), and its `options` and `configurations` become the builder's input. Angular's own builders (`@angular/build:application`, `dev-server`, `unit-test`, `extract-i18n`, `karma`, `ng-packagr`) are ordinary builders declared in that package's `builders.json`. You can write your own the same way, and `ng run` executes any target: `ng run shop:copy-assets:production`. ## When it is justified Write a builder when a task: - belongs to a **project** and needs its options and configurations (`production`, `staging`) like other targets; - should run **other targets** in a controlled way, for example build, then post-process, then report; - needs **validated, documented options** through a JSON schema; - must behave consistently across many projects or repositories as a shared package. Prefer something simpler when: - a shell or npm script does the job and has no project context; - you want to change how the application is **bundled**. The application builder has no supported plugin extension point. Its exported `buildApplication()` function is marked experimental, and its `extensions` parameter is documented as unsupported and liable to break builds. Reach for the builder's own options first (`define`, `loader`, `externalDependencies`). ## Creating one 1. **The handler**: `createBuilder(async (options, context) => { ...; return { success: true }; })`. The `context` (`BuilderContext`) offers: - `logger`, `reportStatus()` and `reportProgress()` for output; - `target`, the project/target/configuration being run; - `workspaceRoot` and `getTargetOptions()` to read another target's options; - `scheduleTarget()` and `scheduleBuilder()` to run other work; - `addTeardown()` for cleanup. 2. **The schema**: a `schema.json` describing options with types and defaults, which Architect validates before calling you. 3. **The definition**: `builders.json`, with `{ "builders": { "copy": { "implementation": "./dist/copy.js", "schema": "./schema.json", "description": "..." } } }`. 4. **The package**: `"builders": "builders.json"` in `package.json`. The builder's full name is the package name plus the builder name, for example `@my-org/builders:copy`. 5. **The target**: add it to a project's `architect` block with `options` and `configurations`, then `ng run shop:copy`. ## Wiring it into a project ```json "architect": { "build-and-report": { "builder": "@my-org/builders:build-and-report", "options": { "buildTarget": "shop:build:production", "reportFile": "dist/report.json" }, "configurations": { "staging": { "buildTarget": "shop:build:staging" } } } } ``` `ng run shop:build-and-report` uses the base options, and `ng run shop:build-and-report:staging` applies the `staging` configuration on top. Command-line flags such as `--report-file` override both, exactly as they do for `ng build`. ## Builders versus schematics | | Builder | Schematic | |---|---|---| | Runs via | `ng build`, `ng serve`, `ng run` | `ng generate`, `ng add`, `ng update` | | Job | Perform a task (compile, copy, deploy) | Change project files | | Works on | The real file system and processes | A virtual `Tree`, committed at the end | | Returns | `BuilderOutput` | A `Tree` via `Rule`s | ## Composing targets correctly `context.scheduleTarget({ project, target, configuration }, overrides)` resolves the target's options the same way the CLI does: base options, then the configuration, then the overrides. It returns a `BuilderRun` whose `result` you await and whose `stop()` ends a watching run. `scheduleBuilder()` runs a builder by name *without* reading `angular.json` configurations. Only `scheduleTarget()` resolves configuration. ## Return values and watch mode | Returns | Use | |---|---| | `BuilderOutput` or `Promise<BuilderOutput>` | One-shot task | | `Observable<BuilderOutput>` | Watch mode: emit an output after each run, clean up in teardown | Always report failure through `{ success: false, error }` rather than throwing or calling `process.exit`, so that callers and CI get a proper result. ## Testing Architect ships testing helpers that run a builder against a fake workspace. Combine them with plain unit tests for the handler's logic. Keep the builder thin: most logic should live in functions you can test without Architect.
- What is the difference between `context.scheduleTarget()` and `context.scheduleBuilder()`?`scheduleTarget()` runs a target from `angular.json` and resolves its options like the CLI does: base options, then the chosen configuration, then your overrides. `scheduleBuilder()` runs a builder by name with only the options you pass, ignoring `angular.json`. Use `scheduleTarget` when you want a project's real build settings.
- Why not wrap `buildApplication()` from `@angular/build` to add bundler plugins?Its JSDoc marks direct use as experimental and says the `extensions` parameter is not supported and may cause unexpected output or failures. Such a wrapper can break on any minor update. Prefer the application builder's own options (`define`, `loader`, `externalDependencies`), or do post-processing in a separate builder that schedules the real build.
saying these in an interview costs you the question
- Writes a custom builder for a task an npm script already handles
- Returns nothing or calls process.exit instead of returning a BuilderOutput
- Uses scheduleBuilder and expects angular.json configurations to apply
- Wraps the application builder's unsupported extensions to inject bundler plugins
- Forgets the builders field in package.json and wonders why the name is unknown