Creating a New Schematic
This guide walks through creating a new schematic for the NanoForge Schematics package.
Concepts
A schematic is a code generator built on the Angular DevKit Schematics framework. Each schematic consists of:
A JSON Schema (
schema.json) that defines the input optionsType definitions (
.d.ts) for the schema and validated optionsA factory function (
.factory.ts) that transforms input, generates files from templates, and merges them into the target projectTemplate files (
files/) in EJS format that produce the output
Step 1: Create the Directory Structure
Create a new directory under src/libs/ with the schematic name:
src/libs/my-schematic/
+-- my-schematic.factory.ts
+-- my-schematic.options.d.ts
+-- my-schematic.schema.d.ts
+-- schema.json
+-- files/
+-- ts/
| +-- ... (TypeScript templates)
+-- js/
+-- ... (JavaScript templates)
Step 2: Define the JSON Schema
Create schema.json to define the input options:
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "SchematicsNanoForgeMySchematic",
"title": "NanoForge My Schematic Options Schema",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the application",
"$default": {
"$source": "argv",
"index": 0
},
"x-prompt": "What name would you like to use?"
},
"directory": {
"type": "string",
"description": "Output directory"
},
"language": {
"type": "string",
"enum": ["ts", "js"],
"description": "Language for generated files",
"default": "ts"
}
},
"required": ["name"]
}
Key schema features:
$default.$source: "argv"reads the value from positional CLI argumentsx-promptprompts the user interactively if the value is not providedrequiredlists mandatory fieldsdefaultsets fallback values
Step 3: Define Type Interfaces
Create the schema type (my-schematic.schema.d.ts):
export interface MySchematicSchema {
name: string;
directory?: string;
language?: string;
}
Create the options type (my-schematic.options.d.ts):
export interface MySchematicOptions {
name: string;
language: string;
}
The schema type represents raw user input (with optional fields), while the options type represents the validated and defaulted values used internally.
Step 4: Implement the Factory
Create my-schematic.factory.ts following the transform-generate-merge
pattern:
import { type Path, join, strings } from "@angular-devkit/core";
import {
type Rule,
type Source,
apply,
mergeWith,
move,
template,
url,
} from "@angular-devkit/schematics";
import { toKebabCase } from "@utils/formatting";
import { resolvePackageName } from "@utils/name";
import { DEFAULT_APP_NAME, DEFAULT_LANGUAGE } from "~/defaults";
import { type MySchematicOptions } from "./my-schematic.options";
import { type MySchematicSchema } from "./my-schematic.schema";
// Phase 1: Transform raw schema into validated options
const transform = (schema: MySchematicSchema): MySchematicOptions => {
const name = resolvePackageName(
toKebabCase(schema.name?.toString() ?? DEFAULT_APP_NAME),
);
return {
name,
language: schema.language ?? DEFAULT_LANGUAGE,
};
};
// Phase 2: Generate file tree from templates
const generate = (options: MySchematicOptions, path: string): Source => {
return apply(url(join("./files" as Path, options.language)), [
template({
...strings, // Angular string helpers (dasherize, classify, etc.)
...options, // Interpolation variables
}),
move(path), // Move files to target directory
]);
};
// Phase 3: Export factory that merges generated files
export const main = (schema: MySchematicSchema): Rule => {
const options = transform(schema);
return mergeWith(generate(options, schema.directory ?? options.name));
};
Step 5: Create Template Files
Template files use EJS syntax for interpolation. Place them under files/ts/
and files/js/.
Example template file (files/ts/example.ts):
// Generated for <%= name %>
export class <%= classify(name) %>Manager {
constructor() {
console.log("<%= name %> initialized");
}
}
Available template helpers (from @angular-devkit/core/strings):
Helper |
Description |
Example |
|---|---|---|
|
Convert to kebab-case |
|
|
Convert to PascalCase |
|
|
Convert to camelCase |
|
|
Convert to snake_case |
|
|
Capitalize first letter |
|
|
Split camelCase with separator |
|
Dynamic file and directory names use __variable__ syntax:
files/ts/__name__/ -> my-project/
files/ts/__name__.config.ts -> my-project.config.ts
Step 6: Register the Schematic
Add the new schematic to src/collection.json:
{
"schematics": {
"my-schematic": {
"factory": "./libs/my-schematic/my-schematic.factory#main",
"description": "Create a NanoForge my-schematic.",
"schema": "./libs/my-schematic/schema.json"
}
}
}
Step 7: Add the Build Entry
Add a build entry in tsup.config.ts:
export default [
createTsupConfig(),
createLibTsupConfig("application"),
createLibTsupConfig("configuration"),
createLibTsupConfig("part-base"),
createLibTsupConfig("part-main"),
createLibTsupConfig("my-schematic"), // Add this line
];
The createLibTsupConfig helper generates a tsup entry that:
Uses
src/libs/<name>/<name>.factory.tsas the entry pointOutputs to
dist/libs/<name>/Builds ESM format only
Step 8: Build and Verify
Build the project and verify everything compiles:
pnpm build
Check the output in dist/libs/my-schematic/ to confirm:
The factory is compiled
The
schema.jsonis copiedTemplate files are copied
Step 9: Test the Schematic
Test the schematic locally by running it with the schematics CLI:
# From the repository root
npx @angular-devkit/schematics-cli .:my-schematic test-project
The . refers to the current package’s collection.json.
Advanced Patterns
Filtering Files
Use filter() to conditionally exclude files from the generated output:
import { filter } from "@angular-devkit/schematics";
const generate = (options: Options, path: string): Source => {
const rules = [
template({ ...strings, ...options }),
move(path),
filter((filePath) => {
// Exclude init/ directory if not needed
if (!options.initFunctions) {
return !filePath.includes("/init/");
}
return true;
}),
];
return apply(url("./files/ts"), rules);
};
Chaining Rules
Use chain() to compose multiple rules:
import { chain, branchAndMerge } from "@angular-devkit/schematics";
export const main = (schema: Schema): Rule => {
return chain([
branchAndMerge(mergeWith(generateBase(options))),
branchAndMerge(mergeWith(generateConfig(options))),
]);
};
Reading and Modifying Files
Use the Tree API to read and write files in the virtual file system:
import { type Rule, type Tree } from "@angular-devkit/schematics";
export const main = (schema: Schema): Rule => {
return (tree: Tree) => {
// Read an existing file
const content = tree.read("some/file.json");
if (content) {
const json = JSON.parse(content.toString());
json.newField = "value";
tree.overwrite("some/file.json", JSON.stringify(json, null, 2));
}
return tree;
};
};
Using Utility Functions
The src/utils/ directory provides several helpers:
toKebabCase(str)– Normalize strings to kebab-caseresolvePackageName(path)– Extract package name from scoped pathsdeepMerge(...objects)– Recursively merge objectsConfigFinder– Locatenanoforge.config.jsonin the treeMainGenerator– Programmatically build main file content
Refer to the /docs/api for full details on each utility.