npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gravionlabs/helix-zod

v22.1.1

Published

Helix Zod v4 adapter for Angular — reactive-forms validator bridge and dynamic forms from annotated Zod schemas.

Readme

@gravionlabs/helix-zod

Zod v4 adapter for @gravionlabs/helix-core forms. Two independent features:

  1. Reactive-forms validator bridgeHelixZodValidators.fromZod() converts a Zod field schema into an Angular ValidatorFn that emits HelixValidatorKey-keyed ValidationErrors — compatible with HelixFormField, HelixFirstError, and helixFormErrorMap out of the box.
  2. Dynamic formsHelixDynamicForm generates 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: titlelabel, descriptionhint.

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 schema input recreates the form and resets its state.
  • Provide your own WritableSignal via [model] to control/observe the raw value; otherwise an initial model is derived from the schema (buildDefaultValue).
  • submitted emits the schema.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): ValidatorFn

Parameters

| 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_stringinvalid_format (with a format property), too_small/too_big use origin instead of type, and not_integer was replaced by invalid_type with expected: '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 Luxon IsoDateTime field) 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:lib

Tests 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 HttpClient and httpResource
  • Luxon IsoDateTime transform schema
  • Environment config validation at startup
  • Generic zodFieldValidator (framework-agnostic, no HelixValidatorKey dependency)

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 |