@rusl-labs/zodforge
v0.2.3
Published
Codegen CLI that mirrors JSON Schema paths into generated Zod modules
Readme
@rusl-labs/zodforge
Codegen CLI that mirrors JSON Schema paths into generated Zod modules.
Point zodforge at a directory of .json schema files — no other tooling required.
Quick start
Put JSON Schema files in ./schemas/, then run:
bunx @rusl-labs/zodforge generate
# npx @rusl-labs/zodforge generateDefaults: ./schemas/**/*.json → ./src/schemas/. No install or build step required.
Import the generated modules in your app (requires zod in your project):
import { profileRaw } from "./src/schemas/user/profile.raw";
import {
zUserProfile,
type UserProfile,
} from "./src/schemas/user/profile.zod";For repeated use, add it as a dev dependency:
npm i -D @rusl-labs/zodforge zodThen npx zodforge generate, npx zodforge clean, etc.
How it works
schemas/**/*.json ← your JSON Schema files
│
▼ zodforge generate
src/schemas/**/*.raw.ts ← raw JSON Schema exports (do not edit)
src/schemas/**/*.zod.ts ← generated Zod modules (do not edit)
│
▼ import
your app| Command | Purpose |
|---------|---------|
| bunx @rusl-labs/zodforge generate | Derive Zod modules from JSON Schema |
| bunx @rusl-labs/zodforge clean | Remove generated output |
| verifyGeneratedSchemas() | Fail if derived output is stale (API — see below) |
Using in your app
zodforge is codegen-only. Your application imports the generated Zod modules at runtime — it does not call zodforge on every request.
1. Install (optional)
Skip this for one-off runs — use bunx or npx directly (see Quick start).
To add zodforge to a project:
npm i -D @rusl-labs/zodforge zod
# bun add -d @rusl-labs/zodforge zod
# pnpm add -D @rusl-labs/zodforge zodRequirements: Node ≥ 18, Zod ^4.3, TypeScript ^5 (peer deps).
2. Project layout
my-app/
schemas/ # JSON Schema source files
user/
profile.json
src/
schemas/ # generated Zod modules
app.tsPut .json files anywhere under your schemas directory. zodforge mirrors the directory tree into the output folder.
3. Generate, verify, clean
# Defaults: glob ./schemas/**/*.json → output ./src/schemas
bunx @rusl-labs/zodforge generate
# Custom paths
bunx @rusl-labs/zodforge generate --path ./contracts/**/*.json -o ./src/schemas --schemas-dir ./contracts
# Remove generated files
bunx @rusl-labs/zodforge cleanRecommended package.json scripts:
{
"scripts": {
"schemas:generate": "zodforge generate",
"schemas:clean": "zodforge clean"
}
}Run zodforge generate whenever schema files change. generate wipes the output directory first, so removed schemas disappear from generated output automatically.
Verify (check output is up to date):
import { verifyGeneratedSchemas } from "@rusl-labs/zodforge";
const result = await verifyGeneratedSchemas();
if (!result.ok) {
console.error("Stale:", result.stale);
process.exit(1);
}4. TypeScript config
Generated modules import JSON from your schemas directory:
{
"compilerOptions": {
"resolveJsonModule": true,
"moduleResolution": "bundler"
},
"include": ["src/**/*.ts", "schemas/**/*.json"]
}5. Import in application code
Each schema file produces two generated modules — raw (validator-agnostic) and zod:
// Raw JSON Schema — unconstrained escape hatch for AJV, docs, introspection
import { userProfileRaw } from "./schemas/user/profile.raw";
// Zod validation + generated TypeScript types
import {
zUserProfile,
type UserProfile,
} from "./schemas/user/profile.zod";
const parsed: UserProfile = zUserProfile.parse({ /* … */ });
ajv.compile(userProfileRaw);
// Barrel import from a directory
import { zUserProfile } from "./schemas/user";
// Lookup registries
import { getSchemaByIdentifier } from "./schemas/_lookup.zod";
import { getRawSchemaByIdentifier } from "./schemas/_lookup.raw";
const byPath = getSchemaByIdentifier("user/profile");
const rawByPath = getRawSchemaByIdentifier("user/profile");UserProfile (and UserProfileInput) is inferred from the JSON Schema at generate time — not from z.infer of an untyped ZodType. const becomes a literal, oneOf / anyOf a union, allOf an intersection, and additionalProperties: false a closed object. External $refs use the target module's emitted type, the same way Zod exports are wired. The Zod export is annotated as z.ZodType<UserProfile, UserProfile> so parse() is typed too.
Open objects retain additional properties in parsed values and generated types. A closed object with sibling anyOf, oneOf, or allOf constraints still rejects extra keys; required-only branches do not reopen its generated type. Nested open objects remain open.
Generated zod files use z.fromJSONSchema(raw) when a schema is self-contained. When it has external $refs (e.g. Rusl https://resources.rusl.com/...#/$defs/...), zodforge wires them to imports of the target Zod exports and compiles via a small _compile.ts helper — so us-address reuses zPragmaticGeoDefPoint instead of inlining a copy. Sibling anyOf / oneOf / allOf keywords are intersected with object/array/scalar constraints beside them, matching Zod's fromJSONSchema for ref-free trees. A $defs / definitions export is compiled from $schema, $id, the defs bag, and $ref only — never the root document's other keywords — so a def is not constrained by the root anyOf / oneOf / allOf. Referenced schemas must be in the same generate set and match by $id (Rusl /schemas/ aliases included). Raw exports always keep the original $ref URIs.
6. What gets generated
For each schemas/foo/bar.json, zodforge writes two mirrored modules:
schemas/user/profile.json → src/schemas/user/profile.raw.ts
→ src/schemas/user/profile.zod.tsPlus:
index.tsbarrel in each directory (re-exports*.raw.tsand*.zod.ts)_lookup.raw.tsat the output root withrawByPath,rawById, andgetRawSchemaByIdentifier()_lookup.zod.tsat the output root withbyPath,byId, andgetSchemaByIdentifier().zodforge-manifest.json(used byzodforge clean)
Naming (default: full): export names derive from the full schema path to avoid collisions across accounts:
| pathId | Raw | Zod | Type |
|--------|-----|-----|------|
| user/profile | userProfileRaw | zUserProfile | UserProfile |
| rusl/schemas/common | ruslSchemasCommonRaw | zRuslSchemasCommon | RuslSchemasCommon |
Use --naming short for filename-only names when paths are unique (profileRaw, zProfile).
7. CLI reference
zodforge generate [options]
zodforge clean [options]| Option | Default | Description |
|--------|---------|-------------|
| --path <glob> | ./schemas/**/*.json | Schema files to compile |
| -o, --output-dir <dir> | ./src/schemas | Generated output directory |
| --schemas-dir <dir> | ./schemas | Root for resolving JSON imports |
| --path-prefix <prefix> | — | Strip prefix from pathId values |
| --naming <mode> | full | Export naming: full (path-based) or short (filename-only) |
| --cwd <dir> | process.cwd() | Working directory |
zodforge clean removes files listed in .zodforge-manifest.json. generate always does a full wipe of the output dir before writing.
8. Commit or gitignore?
Two valid approaches:
- Gitignore
src/schemas/— regenerate in CI and locally after schema changes. - Commit generated output — consumers get Zod modules without running codegen.
Either way, treat generated files as derived — never edit them by hand.
9. Programmatic API
For tests, CI verify steps, or custom tooling:
import {
generateSchemas,
verifyGeneratedSchemas,
forgeSchemas,
cleanGeneratedSchemas,
} from "@rusl-labs/zodforge";
await generateSchemas({ outputDir: "./src/schemas" });
const result = await verifyGeneratedSchemas();
const { zUserProfile, userProfileRaw } = forgeSchemas({ path: "./schemas/**/*.json" });
await cleanGeneratedSchemas();Most apps only need the CLI and generated imports.
Schema registries (optional)
If you use a schema registry or package manager (e.g. Rusl) to vendor JSON Schema into schemas/, zodforge works the same way — it only reads the files on disk. See SCHEMA.md for this repo's schema-driven workflow.
Development
Working on zodforge itself:
bun install
bun run build
bun run schemas:generate
bun testAgent setup
Agents working in this repo should read AGENTS.md and SCHEMA.md.
License
MIT
