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 file (schema.json) that defines the input options
  • Type definitions (.d.ts) for the schema and validated options
  • A factory function (.factory.ts) that transforms input, generates files from templates, and merges them into the target project
  • Template 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/
    |   +-- ...
    +-- js/
        +-- ...

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 arguments
  • x-prompt prompts the user interactively if the value is not provided
  • required lists mandatory fields
  • default sets 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, 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";

const transform = (schema: MySchematicSchema): MySchematicOptions => {
  const name = resolvePackageName(toKebabCase(schema.name?.toString() ?? DEFAULT_APP_NAME));

  return {
    name,
    language: schema.language ?? DEFAULT_LANGUAGE,
  };
};

const generate = (options: MySchematicOptions, path: string): Source => {
  return apply(url(join("./files" as Path, options.language)), [
    template({
      ...strings,
      ...options,
    }),
    move(path),
  ]);
};

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:
HelperDescriptionExample
dasherizeConvert to kebab-caseMyApp -> my-app
classifyConvert to PascalCasemy-app -> MyApp
camelizeConvert to camelCasemy-app -> MyApp
underscoreConvert to snake_caseMyApp -> my_app
capitalizeCapitalize first letterhello -> Hello
decamelizeSplit camelCase with separatormyApp -> my_app
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 tsdown.config.ts.
export default [
  createTsdownConfig(),
  createLibTsdownConfig("application"),
  createLibTsdownConfig("configuration"),
  createLibTsdownConfig("part-base"),
  createLibTsdownConfig("part-main"),
  createLibTsdownConfig("my-schematic"),
];

Step 8: Build and Verify

Build the project and verify everything compiles:
pnpm build