In Angular schematics, what is the virtual `Tree`, and why does a schematic that throws halfway leave every file on disk unchanged?
answer
- base plus staging area
- Rules record actions, nothing is written
- create, overwrite, rename, delete
- sinks commit only after success
- post-tasks skipped on a dry run
basics
~20 sA schematic's Tree is an in-memory view of the workspace: the files on disk as a base plus a staging area of recorded changes. Nothing reaches disk until the whole schematic succeeds, so an error discards everything.
solid answer
~50 sThe `Tree` from `@angular-devkit/schematics` is a virtual file system. Reads (`read`, `readText`, `readJson`, `exists`) see the real files as a base. Writes (`create`, `overwrite`, `beginUpdate`/`commitUpdate`, `rename`, `delete`) are recorded as actions in a staging area. A `Rule` is a function of `(tree, context)` that adds to those actions, and rules compose with `chain`, `apply` and `mergeWith`. Only when the whole rule pipeline finishes does the workflow hand the final tree to its sinks. A validating dry-run sink checks and reports every action, and only then does the host sink write to disk. If a rule throws, for example on a merge conflict, the pipeline errors before any sink runs. If validation finds a problem, such as creating a file that already exists, the run fails before the host sink writes. Either way the disk is untouched. `--dry-run` simply leaves the host sink out and skips follow-up tasks such as a package install.
code
ts · 22 linesimport { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
export function addBarrel(options: { path: string }): Rule {
return (tree: Tree, context: SchematicContext) => {
const file = `${options.path}/index.ts`;
if (!tree.exists(`${options.path}/routes.ts`)) {
// Throwing here discards every action staged so far; nothing is written.
throw new SchematicsException(`No routes.ts in ${options.path}`);
}
const line = "export * from './routes';\n";
if (tree.exists(file)) {
const current = tree.readText(file);
if (!current.includes(line)) {
tree.overwrite(file, current + line);
}
} else {
tree.create(file, line);
}
context.logger.info(`Barrel updated: ${file}`);
return tree;
};
}go deeper
Know that schematics change an in-memory copy of the project and only write files at the end.
Explain base versus staging area, the four action kinds, Rule and SchematicContext, and why create() on an existing path fails the run.
Write idempotent rules, keep side effects out of the tree, and explain exactly what the sinks and post-tasks do in normal and dry runs.
Judge when a codebase-wide change should be a schematic with all-or-nothing guarantees versus a script or codemod, given review and rollback needs.
## The problem the Tree solves A code generator that writes files as it goes is dangerous. If step five of eight fails, steps one to four are already on disk, and the project is left half-modified. Angular's schematics avoid this by never touching the disk while the schematic runs. Every change goes into a **virtual file system** called the `Tree` (from `@angular-devkit/schematics`), and the whole set of changes is applied at the end, or not at all. ## What the Tree contains The Angular docs describe the `Tree` as two parts: - a **base**: the files that already exist in the workspace, read lazily from disk; - a **staging area**: the list of changes a schematic wants to make. Its API splits the same way: | Kind | Methods | Effect | |---|---|---| | Read | `read`, `readText`, `readJson`, `exists`, `get`, `getDir`, `visit` | See the base plus any staged changes | | Write | `create`, `overwrite`, `beginUpdate` + `commitUpdate` | Record a create or overwrite action | | Structural | `rename`, `delete` | Record a rename or delete action | | Composition | `branch`, `merge` | Fork a tree and merge it back under a `MergeStrategy` | The docs name four **action** types: create, overwrite, rename and delete. `beginUpdate` returns an `UpdateRecorder` whose `insertLeft`, `insertRight` and `remove` edit a file by character offsets. That is how schematics insert an import or a provider without rewriting the whole file. Creating a path that already exists is an error, not an overwrite. Depending on whether the tree has already loaded that file, `create()` throws at once (`Path "..." already exist.`) or the validation before the write reports `ERROR! <path> already exists.` and the run fails. Either way nothing is written, so a careful rule checks `tree.exists()` first and calls `overwrite()` instead. Merging a generated sub-tree into the main tree with `mergeWith` throws a merge conflict when both contain the same path with different content, unless a more permissive `MergeStrategy` is passed. ## Rules and the pipeline A `Rule` has the signature `(tree: Tree, context: SchematicContext) => ...`. It may return a `Tree`, an `Observable<Tree>`, another `Rule`, a `Promise`, or nothing, so async work is fine. The `SchematicContext` carries the `logger`, the merge `strategy`, whether the run is `interactive`, and `addTask()` for work that must happen after the files are written. A schematic's entry point is a **rule factory**: a function from options to `Rule`. Rules compose: - `chain([ruleA, ruleB])` runs rules in sequence on the same tree; - `apply(url('./files'), [...])` builds a separate source tree from template files; - `mergeWith(source)` merges that source into the main tree. ## Merge strategies `mergeWith` and `branchAndMerge` take a `MergeStrategy`, which decides what happens when two trees touch the same path: | Strategy | Behaviour on a conflicting path | |---|---| | `Default` | Falls back to the context's strategy, which the CLI leaves at the default: identical content is accepted, different content throws | | `Error` | Throws on any conflict | | `ContentOnly` | Allows overwriting a file's content | | `Overwrite` | Allows overwrite, creation and delete conflicts: the latest change wins | ## How the workflow commits When the CLI runs a schematic, the workflow calls the rule pipeline with a tree over the real workspace. Only if the pipeline completes does it pass the final tree to a list of **sinks**, in order: 1. a **dry-run sink**, which validates each action (for example, creating a file that exists) and emits `CREATE`, `UPDATE`, `RENAME` and `DELETE` events the CLI prints; 2. a check that fails the run with `UnsuccessfulWorkflowExecution` if any validation error was reported; 3. a **host sink**, which writes to disk. It is added only when the run is not a dry run. After the sinks, the engine runs **post-tasks** scheduled with `context.addTask()`, such as `NodePackageInstallTask`. In a dry run they are skipped as well. So a schematic that throws halfway fails inside step zero, before any sink sees the tree. The CLI logs the error, returns a non-zero exit code, and the disk is exactly as it was. ## What the guarantee does not cover - **Side effects outside the tree.** A rule that calls Node's `fs`, spawns a process or hits the network directly escapes the staging area. Keep everything inside `Tree` calls, and do external work in tasks. - **Tasks after a successful write.** If a post-task such as a package install fails, the files are already written. - **Correctness.** The tree guarantees all-or-nothing, not that the generated code compiles. ## Why interviewers ask It separates people who have written a schematic from people who have only run one. The follow-ups are predictable: how you make a rule idempotent, why you check `tree.exists()` before `create()`, and what exactly `--dry-run` skips.
- Why should a rule never write with Node's `fs` module directly?Direct `fs` writes bypass the `Tree`, so they happen immediately. They are not shown by `--dry-run`, not validated by the sinks and not rolled back if a later rule throws. Keep file changes in `Tree` calls, and schedule outside work such as installs with `context.addTask()`, which runs only after a successful, non-dry-run commit.
- What makes a schematic rule idempotent, and why does it matter?Running it twice yields the same result: it checks `tree.exists()` before `create()` and checks content before inserting a line, as in the barrel example. It matters because users re-run generators and migrations, and a non-idempotent rule either throws on the second run or duplicates code.
A schematic is like drafting edits in a shared document's suggestion mode: every change is recorded as a suggestion against the original, and they are accepted together at the end. If the draft is abandoned partway, the original text never changed.
saying these in an interview costs you the question
- Thinks each tree.create() call writes the file to disk immediately
- Believes --dry-run skips running the schematic's rules entirely
- Says tree.create() silently overwrites a file that already exists
- Uses Node's fs module inside a Rule and expects dry run to show it
- Assumes post-tasks like package installs still run during a dry run