Type generation
Every schematic keeps an Input type in schema.generated.ts, derived from its
schema.json. pbuilder-codegen — a binary shipped inside @pbuilder/sdk — writes that
file. When you change schemas across several collections, one run regenerates all of them
from what project-builder.json registers; there is no second list of directories to keep in
sync.
pbuilder-codegen is a Node.js script, so any package runner works: npx, pnpm exec,
yarn, or bunx. The examples use bunx, like the rest of these docs.
Choose a mode
Section titled “Choose a mode”| You want to regenerate… | Run | What it selects |
|---|---|---|
| Every registered schematic in the project you are in | bunx pbuilder-codegen |
The nearest project-builder.json, walking up from the current directory |
| Every registered schematic in a specific project | bunx pbuilder-codegen --project <directory> |
Exactly that directory’s project-builder.json, from any working directory — no upward search |
| One schematic | bunx pbuilder-codegen <schematic-directory> |
Only that directory |
Use the project modes after editing several schemas, in CI, or as your generate:types
script. Use the single-directory mode while iterating on one schematic — it is what
Your first schematic teaches, and what
builder new schematic runs for you automatically.
Project modes process all collections, not only default.
Worked example: two collections
Section titled “Worked example: two collections”A workspace registers one schematic by directory and a second collection through a manifest whose factory is a compiled module:
workspace/├── package.json├── project-builder.json└── schematics/ ├── collection.json ├── hello/ │ ├── factory.ts │ └── schema.json └── dist/ ├── widget.js └── schema.json{ "collections": { "local": { "hello": { "path": "./schematics/hello" } }, "shared": { "path": "./schematics/collection.json" } }}{ "schematics": { "widget": { "factory": "./dist/widget.js#createWidget" } }}{ "properties": { "name": { "type": "string", "label": "Service name", "required": true } }}{ "properties": { "label": { "type": "string", "label": "Widget label", "required": true } }}-
Run codegen from anywhere inside the workspace — here, from a schematic’s own folder:
Terminal window cd schematics/hellobunx pbuilder-codegenpbuilder-codegen: generated 2, failed 0, duplicates 0The exit status is
0. -
Check the outputs. Each schema gets a
schema.generated.tsbeside it — for the manifest entry, beside the compileddist/widget.jsthat the pointer names:schematics/├── dist/│ ├── schema.generated.ts ← new│ ├── schema.json│ └── widget.js└── hello/├── factory.ts├── schema.generated.ts ← new└── schema.jsonschematics/dist/schema.generated.ts // AUTO-GENERATED by pbuilder-codegen — do not edit. Regenerate: pbuilder-codegen <package-dir>// @schema-digest sha256:…export type Input = {/** Widget label */label: string;}; -
Or target the project from outside it, for example from a monorepo root or a CI script:
Terminal window bunx pbuilder-codegen --project ./workspaceThe result is the same.
Which project is selected
Section titled “Which project is selected”- Without arguments, the search walks up from the current directory, and the first
project-builder.jsonfound owns the run. If that file is malformed, unreadable, a directory, or a dangling link, the run fails — it never falls back to a valid file further up. - With
--project <directory>, exactly that directory is used. Pass a directory, not the path to the configuration file. - A missing or malformed configuration fails before anything is generated. A configuration
with no
collections, or an empty one, succeeds with zero work. - The selected project’s own directory is the write boundary. If
project-builder.jsonis a symbolic link, its target’s directory does not gain write permission.
Where the SDK is installed does not matter for selection: the working directory and
--project decide which project runs.
What gets generated
Section titled “What gets generated”Two registration forms have files to generate:
| Registration | Example | Rules |
|---|---|---|
| Schematic directory | "hello": { "path": "./schematics/hello" } |
The directory must contain exactly one regular factory.ts or factory.js — neither or both is an error. The schema is schema.json in that directory. |
| Collection manifest | "shared": { "path": "./schematics/collection.json" } |
Each factory pointer needs an explicit export: module#export, split at the last #, with a non-empty module and an identifier-shaped export (default is valid). The module path is relative to the manifest and must be a real file. The schema is the schema.json beside that module. |
Both path values resolve against the selected project’s directory; absolute paths are
accepted as long as the result stays inside the project. Factories are never imported or
run — codegen only reads files. JSON files with a leading byte-order mark are accepted.
Not supported:
- Inline registrations — schematics embedded in
project-builder.json, such as those created bybuilder new schematic --inline— have no file to write. Each one counts as a failure. A name registered both inline and as a directory also fails. - Package lookup, extension inference, inheritance, URL-encoded paths, and schematics that are on disk but not registered. Codegen does not scan folders.
Read the result
Section titled “Read the result”Every project run ends with one summary line on stdout:
pbuilder-codegen: generated G, failed F, duplicates DWarnings about individual entries go to stderr. The exit status is 1 if any entry
failed, and 0 otherwise — including a run with zero work.
A run keeps going after an entry fails. In this example, drafts/broken has a property
without a label, and legacy/broken-copy registers the same directory again:
{ "collections": { "local": { "hello": { "path": "./schematics/hello" } }, "drafts": { "broken": { "path": "./schematics/broken" } }, "legacy": { "broken-copy": { "path": "./schematics/broken" } } }}$ bunx pbuilder-codegenpbuilder-codegen: "drafts/broken" ("<workspace>/schematics/broken"): "pbuilder-codegen: <workspace>/schematics/broken/schema.json: property \"title\" is missing a label"pbuilder-codegen: generated 1, failed 1, duplicates 1$ echo $?1How entries are counted:
- Duplicates — registrations that resolve to a destination already attempted in this run
count once in
duplicates, even when the first attempt failed; they do not add another failure. Aliases through directory symlinks are recognised as the same destination. Distinct hard-linked files are not. - Failures — each unresolvable, invalid or refused registration counts on its own.
- Not a transaction — files generated successfully stay written when another entry fails.
An entry that fails validation keeps its previous
schema.generated.ts.
Fix the reported schema or registration and run again. Never edit schema.generated.ts by
hand.
Let the exit status fail the job. Files on disk are not proof that the whole run succeeded:
bunx pbuilder-codegenIf you pipe the output, keep the status — for example with set -o pipefail in Bash. To
also catch schemas changed without regenerating, check for differences afterwards:
bunx pbuilder-codegengit diff --exit-code -- '*schema.generated.ts'When the SDK comes from sdk.root
Section titled “When the SDK comes from sdk.root”With sdk.root, node_modules/@pbuilder/sdk is a link the CLI created, not a package your package manager installed — so there is no node_modules/.bin/pbuilder-codegen shim. Package runners then misbehave in one of two ways:
bunx pbuilder-codegenandnpx pbuilder-codegenreport the binary as missing, even though the SDK is present andbuilder executeworks.- If an SDK is also installed globally,
npxcan run that global copy without telling you — possibly a different version from the onesdk.rootnames. - Recent pnpm versions run an install before
pnpm exec. That makes the command work, but it writes apnpm-lock.yamland a shim into your project — the kind of changesdk.rootexists to avoid.
The generate:types script that builder init adds calls pbuilder-codegen by name, so it is affected too.
Run the script directly with Node instead. It accepts the same forms — no argument, --project <directory>, or a schematic directory:
node node_modules/@pbuilder/sdk/dist/bin/pbuilder-codegen.jsnode node_modules/@pbuilder/sdk/dist/bin/pbuilder-codegen.js schematics/helloThe link exists once a committing builder execute has run. Before that, use the configured root itself: node <sdk.root>/dist/bin/pbuilder-codegen.js.
The same applies when the link was created from --sdk-root or BUILDER_SDK_ROOT instead of sdk.root. Before the link exists, run the script from that value: node $BUILDER_SDK_ROOT/dist/bin/pbuilder-codegen.js.
builder new schematic needs none of this with sdk.root: it resolves the configured root on its own and regenerates the new schematic’s types. It does not read BUILDER_SDK_ROOT: when the variable is your only SDK source, it warns that @pbuilder/sdk was not found and skips schema.generated.ts — run the script as above.
Output files and write boundaries
Section titled “Output files and write boundaries”Codegen only writes where the run is allowed to:
| Mode | Write boundary |
|---|---|
Project (pbuilder-codegen, --project) |
The selected project’s directory |
Single directory (pbuilder-codegen <dir>) |
The nearest ancestor of the working directory that has a package.json, or the working directory itself if there is none |
Both the schematic directory and the output file — after resolving symbolic links — must stay
inside that boundary. A destination outside it is not written: in a project run it is reported
as refusing to write outside the selected project root and counts as a failure. Fix the
registration or run codegen from the project that owns the schematic.
Symbolic-link outputs
Section titled “Symbolic-link outputs”On Linux and macOS, schema.generated.ts must be either absent or a regular file. An output
that is a symbolic link is refused — even if its target is inside the project — and so is
any other non-regular entry:
$ bunx pbuilder-codegen schematics/hellopbuilder-codegen: schematics/hello/schema.generated.ts: refusing symbolic-link outputIn a project run the same entry reports cannot write a regular, non-symbolic-link output.
builder new schematic surfaces it as new_codegen_failed. Reinstalling the SDK does not
help — the file layout is the cause.
To migrate:
-
Confirm the entry is a link and note where it points:
Terminal window ls -l schematics/hello/schema.generated.ts -
Remove only the link. Leave its target alone:
Terminal window rm schematics/hello/schema.generated.ts -
Regenerate an ordinary file in place:
Terminal window bunx pbuilder-codegen schematics/hello
Directory links are different: a schematic directory reached through a symbolic link keeps working, as long as the resolved directory and its output stay inside the write boundary. Creating a missing output and replacing an existing regular one — including with shorter content — work as always.
The symbolic-link and non-regular output checks apply on Linux and macOS. Other platforms keep the ordinary writer and the write-boundary check; native Windows is not verified. These checks are not a filesystem sandbox and do not protect against parent directories being swapped while a run is in progress.
Related
Section titled “Related”- Your first schematic — the single-schematic flow end to end.
builder new schematic— scaffolds a schematic and runs codegen for it.builder init— thegenerate:typesscript it adds, which runs codegen once perschema.jsonfound underschematics/.