formstand-cli
v0.7.0
Published
Generate formstand form components from a zod schema or TypeScript type
Maintainers
Readme
formstand-cli
Generate formstand form components from a zod schema or a TypeScript type.
npm install --save-dev formstand-cliRequirements
- formstand >= 0.3.0 for
--ui muiand--ui shadcnoutput (the inlined adapters useUseFieldReturn,numberToInputText, andparseNumberText); plain output works on 0.2.0. GenerateduseFieldArrayhooks get typed items on formstand >= 0.5 (inferred from the schema through the path); on 0.4 they compile with untyped items. - formstand >= 0.9 for
datefields: plain output emits<DateField>and the mui/shadcn adapters usedateToInputText/parseDateText, all shipped in 0.9. On older formstand, avoidz.date()in the schema (or replace the emitted date bindings by hand). - zod v4 in your project. The CLI walks your schema structurally (duck-typed by design — no
instanceofagainst a bundled copy), so it does not ship zod itself: the schema module and the generated code both use the zod your project supplies.
Two modes
1. Zod mode (default)
Point it at a module that exports a zod schema; it prints a complete, compilable component bound to that schema:
formstand-gen src/profileSchema.ts
formstand-gen src/profileSchema.ts --out src/ProfileForm.tsx
formstand-gen src/schemas.ts --export profileSchema --name ProfileForm --out src/ProfileForm.tsxThe schema module is loaded at runtime (via jiti, so plain .ts files work) and walked structurally — your own copy of zod is used, no instanceof games. Picked export: --export, else the default export, else the sole zod-schema export.
2. Type mode
Point it at an exported TypeScript type/interface; it generates a zod schema and a component that imports it:
formstand-gen src/types.ts --type Profile --out src/ProfileForm.tsx
# writes src/profileSchema.ts (override with --schema-out) and src/ProfileForm.tsxWithout --out, both files print to stdout separated by // --- file: ... headers. With --schema-out but no --out, the schema is written to that file and the component streams to stdout.
Flags
| Flag | Meaning |
| --- | --- |
| --export <name> | which export holds the zod schema |
| --type <TypeName> | generate from a TS type/interface instead |
| --ui plain\|mui\|shadcn | component flavor (default plain) |
| --layout single\|module | single (default): one file. module: a feature-module folder — see below |
| --sections flat\|panel\|collapsible | section chrome: flat headings (default), bordered panels, or collapsible sections (<details>; MUI Accordion) |
| --columns 1\|2\|3 | evenly spaced field columns inside each section (default 1); nested sections span the full row |
| --max-depth <n> | schema/type nesting budget before a level degrades to a string + TODO (default 10); also the recursion backstop and the bound on nested-array row extraction |
| --name <MyForm> | component name (default derived from the schema/type name) |
| --out <file> | write the component here instead of stdout |
| --schema-out <file> | type mode: where the generated zod schema goes (default <schemaName>.ts next to --out) |
| --config <file> | config file (default: formstand.config.{ts,mts,js,mjs} in the working directory) holding project defaults for ui/layout/sections/columns; explicit flags win |
| --watch | regenerate whenever the input file changes (requires --out) |
| --template <file> | a custom template module (defineTemplate) for a UI kit formstand doesn't ship — overrides the per-kind field rendering, inheriting the plain form scaffold; --layout single only, overrides --ui |
| --force | overwrite existing output files |
What is generated
useForm+ typedinitialValues(strings"", booleansfalse, numbers/dates/enumsundefined, nullable fieldsnull, arrays[]).- One bound control per field:
TextField,NumberField,CheckboxField,SelectField(enum options from the schema). - Nested objects as
<fieldset>/<legend>sections. - Field arrays via
useFieldArraywith stable row keys, add/remove buttons, and a typed empty-item constant. --sections/--columnspick each section's chrome and field grid, in the ui's own dialect: inline styles forplain,Card/Accordion+sxgrids formui, Tailwind classes (md:grid-cols-2,bg-card) forshadcn. Both flags work with either--layout.handleSubmit(console.log)and a submit button disabled while submitting.--ui mui: the same structure over@mui/materialv9 with an inlined ~50-line adapter (muiTextFieldProps/muiNumberFieldProps/muiSelectProps/muiSwitchProps) bindingUseFieldReturnto MUI props, sharingparseNumberText/numberToInputTextwith the library.--ui shadcn: the same structure over your app's shadcn/ui components (imported from the@/components/ui/*alias thatnpx shadcn addscaffolds) with an inlined adapter speaking the Radix dialect —onCheckedChange/onValueChangecallbacks, dropdown-close as the blur trigger, andaria-invaliderror styling with a message line.
--layout module
Instead of one file, a feature-module folder in the shape of the Onboarding playground demo:
ProfileForm/
schema.ts the zod schema (re-exported in zod mode, generated in type mode)
types.ts ProfileSchema / ProfileValues
hooks.ts createForm + createFormHooks(form, "profile") — the pre-wired hook API
fields/ one file per scalar leaf: props type + field hook + component
sections/ one per top-level object/array: props type + section hook
(path-scoped useProfileIsDirty/IsValid) + component
ProfileForm.tsx the body composing sections and root-level fields
index.ts the folder's public API--out names the folder (created if missing; every destination is checked before anything is written). Without --out, all files stream to stdout with // --- file: headers. Array sections bind their row fields inline with template paths; date fields get real DateField / date-input bindings (formstand ≥ 0.9). Works with all three uis — mui and shadcn modules get a shared adapter.ts(x) exporting the generic prop builders instead of inlining them per file. Requires formstand ≥ 0.7 (createFormHooks).
formstand-gen src/profileSchema.ts --layout module --out src/ProfileForm
formstand-gen src/types.ts --type Profile --layout module --out src/ProfileForm
formstand-gen src/profileSchema.ts --ui mui --sections panel --columns 2 --layout module --out src/ProfileFormConfig file
Project defaults live in formstand.config.ts next to where you run the CLI (flags always win):
import { defineConfig } from "formstand-cli";
export default defineConfig({
ui: "mui",
layout: "module",
sections: "panel",
columns: 2,
});defineConfig is an identity function with types — completion and typo-checking in the config file. Pair it with --watch for schema-first development: edit the schema, the module regenerates.
Custom templates
For a UI kit formstand doesn't ship built in — Mantine, Chakra, an in-house design system — a template overrides the per-kind field rendering while inheriting the generated form's scaffold (sections, arrays, discriminated unions, submit). A UI kit differs in its field components, not the form skeleton.
// mantine.template.ts
import { defineTemplate } from "formstand-cli";
export default defineTemplate({
name: "mantine",
imports: [{ from: "@mantine/core", names: ["TextInput", "NumberInput", "Select"] }],
leaf: {
string: ({ label, bind }) => `<TextInput label={${label}} {...${bind}} />`,
number: ({ label, bind }) => `<NumberInput label={${label}} {...${bind}} />`,
enum: ({ label, bind, options }) => `<Select label={${label}} data={${options}} {...${bind}} />`,
// string / number / boolean / date / enum — unlisted kinds fall back to plain
},
});formstand-gen src/profileSchema.ts --template ./mantine.template.ts --out src/ProfileForm.tsxEach leaf renderer receives a context whose fields are JS-expression strings to splice into your control's JSX:
bind— the formstand prop-builder spread (textInputProps(field)etc.), carryingname/value/onChange/onBlur/aria-invalid. Spread it:{...${bind}}.field— the bounduseFieldresult variable; reference.error/.valuefor custom error display.label— the field label as an expression: writelabel={${label}}.options— enum only: astring[]expression (data={${options}}).
Unlisted kinds fall back to the plain output, so a template can override only the kinds its kit changes. --template overrides --ui and currently supports --layout single (module support is planned). Set a project default with template: "./mantine.template.ts" in formstand.config.ts.
Supported schema surface
string, number/int, boolean, date, enum, unions of string literals, object, array, tuple, with .optional() / .nullable() / .default() / .pipe() unwrapped. Anything else falls back to a string field with a // TODO: comment so the file still compiles. date fields emit a real DateField (plain) or date-input binding (mui / shadcn) — no TODO (requires formstand ≥ 0.9). tuple fields (z.tuple([...]) / [A, B]) render fixed positional controls bound at static numeric-index paths (coord.0, coord.1) in both layouts; a non-scalar tuple element degrades to a TODO. Arrays nested inside array rows extract a useFieldArray-owning row component at every level, recursively (bounded by --max-depth), threading each enclosing row's index as a p0, p1, … prop — so teams[] › members[] › phones[] all generate, in both layouts. In --layout module each level is a {Stem}Row/{Stem}Rows pair in the section file; in the single-file layout it's a child {Stem}Rows component (taking a typed form prop) above the main component. A non-array shape inside a row (a nested object, union, or tuple) stays a TODO.
Known limitations:
- Dots in keys: formstand paths split on
., so a field named"a.b"is not path-addressable. The key is kept in the zod schema andinitialValues, but no control is bound — a{/* TODO: field "a.b" skipped ... */}comment marks the spot and the CLI prints a warning. - Tuple elements that aren't scalar (an object/array/union/nested tuple at a tuple position), and a tuple's variadic rest (
z.tuple([...], rest)), degrade to a// TODOat that position — the fixed scalar positions still generate. Methods and callable types are skipped / degraded to a string field the same way.
Programmatic API
The generator is exposed as two entry points.
formstand-cli/codegen — the browser-safe surface. Everything downstream of the IR is a pure string builder (no fs/path, no TypeScript compiler), so this subpath runs anywhere — a browser, a build script, your own tool. The pipeline is zod schema → fromZod → FieldSpec IR → emitters; build a FieldSpec by hand or from fromZod, then run any emitter:
import { fromZod, emitPlainForm, emitModuleForm, emitZodSchema } from "formstand-cli/codegen";
const ir = fromZod(profileSchema);
const code = emitPlainForm({
ir,
formName: "ProfileForm",
schemaImport: { name: "profileSchema", from: "./profileSchema", kind: "named" },
});This is exactly how the docs' Schema builder generates forms client-side. Exports: fromZod / isZodSchema, emitPlainForm / emitMuiForm / emitShadcnForm / emitTemplateForm / emitModuleForm, emitZodSchema / emitInitialValues, joinModuleFiles, defineTemplate, labelFromName and the casing helpers, and the FieldSpec / EmitFormOptions / VisualOptions types.
formstand-cli (the main entry) re-exports all of the above and adds the parts that need Node / the TypeScript compiler:
import { fromType, defineConfig } from "formstand-cli";
const { ir, typeName } = fromType("./types.ts", "Profile"); // parses TS via the compilerImport from formstand-cli/codegen for a browser bundle — the main entry pulls the TypeScript compiler through fromType and won't bundle for the browser.
License
MIT
