@gravionlabs/helix-zod
v22.1.1
Published
Helix Zod v4 adapter for Angular — reactive-forms validator bridge and dynamic forms from annotated Zod schemas.
Maintainers
Readme
@gravionlabs/helix-zod
Zod v4 adapter for @gravionlabs/helix-core forms. Two independent features:
- Reactive-forms validator bridge —
HelixZodValidators.fromZod()converts a Zod field schema into an AngularValidatorFnthat emitsHelixValidatorKey-keyedValidationErrors— compatible withHelixFormField,HelixFirstError, andhelixFormErrorMapout of the box. - Dynamic forms —
HelixDynamicFormgenerates a complete, validated form from a single annotated Zod object schema, built on Angular's experimental signal forms (@angular/forms/signals, requires Angular ≥ 21.2).
Peer Dependencies
| Package | Version |
|---|---|
| zod | ^4.0.0 |
| @gravionlabs/helix-core | >=0.2.0 |
| @angular/core / @angular/common / @angular/forms | >=21 (dynamic forms: >=21.2) |
Dynamic Forms from Zod Schemas
Built on
@angular/forms/signals, which is experimental — minor Angular releases may introduce breaking API changes.
Quick start
import { z } from 'zod';
import { HelixDynamicForm, helixMeta, provideHelixDynamicForms } from '@gravionlabs/helix-zod';
const UserSchema = z.object({
email: helixMeta(z.email('Invalid email'), { label: 'E-mail', placeholder: '[email protected]' }),
age: helixMeta(z.number().min(18, 'Must be 18+'), { label: 'Age' }),
role: helixMeta(z.enum(['user', 'editor']), { label: 'Role' }),
newsletter: helixMeta(z.boolean(), { label: 'Subscribe' }),
frequency: helixMeta(z.enum(['daily', 'weekly']), {
label: 'Frequency',
hiddenWhen: (root) => !root['newsletter'], // conditional visibility
}),
});
@Component({
imports: [HelixDynamicForm],
providers: [provideHelixDynamicForms()],
template: `<helix-dynamic-form [schema]="schema" (submitted)="save($event)" />`,
})
export class UserForm {
readonly schema = UserSchema;
save(value: unknown) { /* value is UserSchema.parse()d */ }
}Validation runs through signal forms' validateStandardSchema against the whole Zod schema — issue paths route each error to its field, including cross-field .refine(..., { path }) errors.
Widget inference
| Zod type | Widget |
|---|---|
| z.string() | text (z.email() → email) |
| z.number() / z.int() | number |
| z.boolean() | checkbox |
| z.date() | date (Date | null model) |
| z.enum() / z.literal() | select |
| z.array() | array (add/remove UI) |
| z.object() | object (fieldset recursion) |
| z.discriminatedUnion() | union (variant switcher; switching resets the union value) |
Anything else needs a widget override in its metadata pointing at a registered (custom) widget — unmapped types throw in dev mode.
Metadata (HelixFieldMeta)
Attach UI metadata with helixMeta(schema, meta) — it registers on the same schema instance (unlike .meta(), which clones; metadata must sit on the exact instance composed into the z.object() shape). Alternatively schema.meta({ title, description, helix: {...} }) works: title → label, description → hint.
Keys: label, placeholder, hint, widget, options, order, addLabel/removeLabel (arrays), and the conditional engine: hiddenWhen, disabledWhen (string return = disabled reason), readonlyWhen, requiredWhen — all predicates over the root form value — plus extraSchema(path) as an escape hatch for arbitrary signal-forms rules.
Custom widgets & error messages
provideHelixDynamicForms({
widgets: [{ widget: 'rating', component: RatingWidget }], // or override built-ins
errorMessageResolver: (error, helixKey) =>
helixKey === HelixValidatorKey.Required ? 'Pflichtfeld' : null, // null → default message
})Custom widgets extend HelixFieldWidgetBase (inputs field + descriptor, computeds state/firstError/label/hint/placeholder) and bind their control via [formField]="field()".
Notes & caveats
- Passing a new
schemainput recreates the form and resets its state. - Provide your own
WritableSignalvia[model]to control/observe the raw value; otherwise an initial model is derived from the schema (buildDefaultValue). submittedemits theschema.parse()d output (defaults/transforms applied) — only when the form is valid.- Lower-level building blocks are exported for advanced use:
zodToFieldDescriptors,buildHelixSchema,buildDefaultValue,helixFirstErrorMessage,fieldAtPath.
Installation
This is a workspace library — no npm install needed. The path alias is already registered in tsconfig.json:
"@gravionlabs/helix-zod": ["./projects/zod/src/public-api.ts"]Import directly in your application:
import { HelixZodValidators } from '@gravionlabs/helix-zod';Quick Start
import { z } from 'zod';
import { HelixZodValidators } from '@gravionlabs/helix-zod';
// Wrap any Zod field schema in a reactive form control
form = this.fb.group({
email: ['', HelixZodValidators.fromZod(z.string().email('Invalid email'))],
name: ['', HelixZodValidators.fromZod(z.string().min(1, 'Required'), { allowEmpty: false })],
});HelixFormField reads the resulting ValidationErrors keys directly — no template changes required.
API
HelixZodValidators.fromZod(schema, options?)
Converts a Zod field schema into a Helix-compatible Angular ValidatorFn.
import type { ValidatorFn } from '@angular/forms';
import type { ZodSchema } from 'zod';
import type { ZodHelixOptions } from '@gravionlabs/helix-zod';
fromZod(schema: ZodSchema, options?: ZodHelixOptions): ValidatorFnParameters
| Parameter | Type | Description |
|---|---|---|
| schema | ZodSchema | A Zod field schema. Prefer UserSchema.shape.email over a full object schema. Do not pass schemas with .transform() — use the pre-transform shape for form controls. |
| options | ZodHelixOptions | Optional configuration (see below). |
ZodHelixOptions
export interface ZodHelixOptions {
fallbackKey?: HelixValidatorKey; // required when schema uses .refine() / .superRefine()
allowEmpty?: boolean; // default: true
}Zod v4 → HelixValidatorKey Mapping
This library targets Zod v4. Zod v4 introduced breaking changes from v3:
invalid_string→invalid_format(with aformatproperty),too_small/too_biguseorigininstead oftype, andnot_integerwas replaced byinvalid_typewithexpected: 'int'.
| Zod v4 issue code | Condition | HelixValidatorKey |
|---|---|---|
| invalid_type | value is '', null, or undefined | Required |
| invalid_type | expected === 'int' | Integer |
| invalid_type | expected === 'number' / 'float' | Number |
| invalid_format | format === 'email' | Email |
| invalid_format | format === 'regex' | Pattern |
| invalid_format | format === 'datetime' / 'date' / 'time' | Date |
| too_small | origin === 'string' or 'array' | MinLength |
| too_big | origin === 'string' or 'array' | MaxLength |
| too_small | origin === 'number' | Min |
| too_big | origin === 'number' | Max |
| custom | — | requires fallbackKey (see below) |
| anything else | — | requires fallbackKey (see below) |
All issues from a single safeParse are processed simultaneously, producing one error key per issue — equivalent to stacking multiple HelixValidators calls.
Known gaps — no automatic mapping
| Scenario | Recommendation |
|---|---|
| z.enum() → invalid_value | Use fallbackKey or keep using HelixValidators.oneOf |
| z.array() item-level errors | Use fallbackKey or HelixValidators.allOf |
| invalid_type for boolean | Helix has no Boolean key — use fallbackKey |
allowEmpty Behaviour
HelixValidators defaults to allowEmpty = true: validation passes silently on empty values. fromZod mirrors this:
| Scenario | Recommended pattern |
|---|---|
| Optional field with format check | Default allowEmpty: true — empty passes, invalid format shows error |
| Mandatory field (required + format) | Stack HelixValidators.required('msg') alongside fromZod(schema) |
| Required via Zod only | allowEmpty: false — empty triggers Required (null/undefined) or MinLength (empty string) |
// Optional — empty passes, bad format shows Email error
HelixZodValidators.fromZod(z.string().email('Invalid email'))
// Required — must have a value; empty string → MinLength, null → Required
HelixZodValidators.fromZod(z.string().min(1, 'Name is required'), { allowEmpty: false })
// Stacked — explicit required message + Zod format check
[
HelixValidators.required('Email is required'),
HelixZodValidators.fromZod(z.string().email('Invalid email')),
]Note: when allowEmpty: false and the value is null or undefined, Zod emits invalid_type with its default mismatch message, not the message from .min() or .email(). Stack HelixValidators.required('...') for a custom required message.
.refine() and fallbackKey
.refine() and .superRefine() produce ZodIssueCode.custom, which has no automatic HelixValidatorKey mapping. Provide a fallbackKey to capture these errors:
const bannedUsernames = ['admin', 'root', 'system'];
HelixZodValidators.fromZod(
z.string()
.min(3, 'At least 3 characters')
.refine((v) => !bannedUsernames.includes(v), 'Username is not allowed'),
{ fallbackKey: HelixValidatorKey.Pattern },
)Missing fallbackKey behaviour:
- Development (
ngDevMode = true): throws a descriptive error identifying the unmapped issue code and the failing issue JSON. - Production (
ngDevMode = false): the unmapped issue is silently skipped. The control remains invalid if other issues produce mapped errors.
Component Example
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
import { z } from 'zod';
import { HelixFormField, HelixValidators, HelixValidatorKey } from '@gravionlabs/helix-core';
import { HelixZodValidators } from '@gravionlabs/helix-zod';
// Define your schema once — reuse it for both API parsing and form validation
const UserSchema = z.object({
email: z.string().email('Invalid email'),
name: z.string().min(1, 'Name is required'),
});
const bannedUsernames = ['admin', 'root'];
@Component({
selector: 'app-register',
standalone: true,
imports: [ReactiveFormsModule, HelixFormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<helix-form-field label="Email" [control]="form.controls.email">
<input type="email" formControlName="email" />
</helix-form-field>
<helix-form-field label="Name" [control]="form.controls.name">
<input type="text" formControlName="name" />
</helix-form-field>
<helix-form-field label="Username" [control]="form.controls.username">
<input type="text" formControlName="username" />
</helix-form-field>
<button type="submit" [disabled]="form.invalid">Register</button>
</form>
`,
})
export class RegisterComponent {
readonly #fb = inject(FormBuilder);
form = this.#fb.group({
// Stack required + Zod for a custom required message
email: [
'',
[
HelixValidators.required('Email is required'),
HelixZodValidators.fromZod(UserSchema.shape.email),
],
],
// allowEmpty: false — Zod handles both empty and format validation
name: [
'',
HelixZodValidators.fromZod(UserSchema.shape.name, { allowEmpty: false }),
],
// .refine() requires fallbackKey
username: [
'',
HelixZodValidators.fromZod(
z.string()
.min(3, 'At least 3 characters')
.refine((v) => !bannedUsernames.includes(v), 'Username is not allowed'),
{ fallbackKey: HelixValidatorKey.Pattern },
),
],
});
protected onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// Parse the form value through the full schema for the API call
const payload = UserSchema.parse(this.form.value);
console.log(payload);
}
}How errors surface in HelixFormField
HelixFormField reads control.errors and takes the first string value. Since fromZod stores the Zod error message as the value (e.g. { Email: 'Invalid email' }), activeError picks it up with no adapter layer.
// helixFormErrorMap also works identically
import { helixFormErrorMap } from '@gravionlabs/helix-core';
const errors = helixFormErrorMap(this.form);
// → { email: 'Invalid email', name: 'Name is required' }UserSchema.shape — Single Source of Truth
Using UserSchema.shape.<field> directly in fromZod eliminates duplicated validation rules between your API schema and your form:
// Without shape — rules written twice
z.string().email() // API parsing
HelixValidators.email('...') // form (same rule, again)
// With shape — one definition drives both
HelixZodValidators.fromZod(UserSchema.shape.email)Do not pass fields with
.transform()(e.g. a LuxonIsoDateTimefield) into form controls. Use the input-only (pre-transform) field shape instead, or define a separate schema without the transform.
Running Tests
# Run helix-zod tests
pnpm ng test zod
# Run all library tests
pnpm test:libTests are written with Vitest and cover all mapped issue codes, allowEmpty behaviour, fallbackKey usage, and the dev/prod error modes.
Architecture Notes
This library is the @gravionlabs/helix-zod portion of the broader Zod integration architecture documented in ZOD_ARCHITECTURE_HELIX.md at the repo root. The arch doc also covers:
- App-level patterns: domain schemas,
UserSchema.shape, schema composition - REST endpoint validation with
HttpClientandhttpResource - Luxon
IsoDateTimetransform schema - Environment config validation at startup
- Generic
zodFieldValidator(framework-agnostic, noHelixValidatorKeydependency)
Those patterns live in your application (src/app/schemas/, src/app/api/, etc.) — this library provides only the Angular ValidatorFn bridge.
Implementation vs Architecture Plan
The library fully implements the helix-zod scope defined in ZOD_ARCHITECTURE_HELIX.md §6b:
| Plan item | Status |
|---|---|
| ZodHelixOptions interface (fallbackKey, allowEmpty) | Implemented |
| HelixZodValidators.fromZod() factory | Implemented |
| Full Zod v4 issue → HelixValidatorKey mapping | Implemented |
| allowEmpty = true default mirroring HelixValidators | Implemented |
| ngDevMode throw on unmapped issue without fallbackKey | Implemented |
| Silent skip in production builds | Implemented |
| z.array().min() → MinLength | Implemented |
| App-level patterns (schemas, API services, Luxon) | Out of scope — application layer |
