npm package: @ngx-signal-form/zod
Description: Angular Signal Forms powered by Zod schemas. Build a typed `FieldTree` from any Zod schema and get parsed values, errors, and validity as signals.
Latest version: 0.0.1

# @ngx-signal-form/zod

Angular Signal Forms powered by Zod schemas. Build a typed `FieldTree` from any Zod schema and get parsed values, errors, and validity as signals.

## Install

```bash
npm install @ngx-signal-form/zod zod
```

Peer deps: `@angular/core`/`@angular/forms` v21+ and `zod` v4.

## Quick start (string)

```ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Field } from '@angular/forms/signals';
import z from 'zod';
import { NgxSignalForm, NullableFieldCoercionPipe } from '@ngx-signal-form/zod';

@Component({
  selector: 'app-name-form',
  imports: [Field, NullableFieldCoercionPipe],
  template: `
    <label>
      Name
      <input type="text" [field]="form | nullableField" />
    </label>

    <p>Parsed: {{ form.parsedValue$() }}</p>
    <p>Valid: {{ form.isValid$() }}</p>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NameFormComponent {
  private readonly ngxSignalForm = inject(NgxSignalForm);
  readonly schema = z.string().min(1);
  readonly form = this.ngxSignalForm.create(this.schema);

  // Internal init (conceptual):
  // form(signal(null), path => { required(path); minLength(path, 1); });
  // Initial model shape: form.model$() => null
}
```

Internally, `create` builds an Angular Signal Forms `FieldTree` (via `form(model$, schemaFn)`) and maps your Zod rules to the matching signal-forms checks. For example, `z.string().min(1)` becomes a `minLength` rule on that path, so `[field]` enforces it automatically.

### Why `nullableField`

Angular's `[field]` typing currently rejects `FieldTree<T | null>` (see angular/angular#65839). Use the provided `NullableFieldCoercionPipe` (`form.someOptional | nullableField`) to bridge optional/nullable nodes until Angular fixes it.

## API surface

- `NgxSignalForm.create(schema, options?)` -> `NgxZodFieldTree<T>` (a `FieldTree` you can call and navigate like `form.userName()` or `form()`).
  - `parsedValue$`: signal of parsed Zod output or `null`
  - `parsedError$`: signal of `ZodError | undefined`
  - `isValid$`: signal of boolean validity
  - `model$`: writable signal of the form model (null-filled shape)
- Types: `NgxZodFieldTree`, `DeepNullModel`, `PartialFormForSchema`
- Utilities: `NullableFieldCoercionPipe` for template bindings

### Options

```ts
// Use `$ZodType` from `zod/v4/core`.
type NgxSignalFormOptions<T extends $ZodType> = {
  // Optional initial value that follows the form shape (missing parts are filled with nulls for you).
  initialValue?: PartialFormForSchema<T>;
  useSchemaDefaults?: boolean; // defaults true
};

// Example signature
ngxSignalForm.create(schema, options);
```

`PartialFormForSchema<T>` is the partial, developer-friendly shape of the null-filled model produced from your schema, so you can pass only the fields you care about.

## Advanced (checks + initialValue)

```ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Field } from '@angular/forms/signals';
import z from 'zod';
import { NgxSignalForm, NullableFieldCoercionPipe } from '@ngx-signal-form/zod';

const orderSchema = z.object({
  sku: z.string().regex(/^[A-Z0-9]{8}$/),
  quantity: z.number().int().min(1).max(100).multipleOf(5),
  notes: z.string().min(5).max(50).optional(),
});

@Component({
  selector: 'app-order-form',
  imports: [Field, NullableFieldCoercionPipe],
  template: `
    <input type="text" [field]="form.sku | nullableField" />
    <input type="number" [field]="form.quantity | nullableField" />
    <textarea [field]="form.notes | nullableField"></textarea>
    <p>Parsed: {{ form.parsedValue$() | json }}</p>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderFormComponent {
  private readonly ngxSignalForm = inject(NgxSignalForm);

  readonly form = this.ngxSignalForm.create(orderSchema, {
    initialValue: { quantity: 5 },
  });

  // Internal init (conceptual):
  // form(signal({ sku: null, quantity: 5, notes: null }), path => {
  //   required(path);              // root object
  //   required(path.sku);          // string regex /^[A-Z0-9]{8}$/
  //   pattern(path.sku, /^[A-Z0-9]{8}$/);
  //   required(path.quantity);     // min(1), max(100), multipleOf(5)
  //   min(path.quantity, 1);
  //   max(path.quantity, 100);
  //   validate(path.quantity, /* multiple-of-5 check */);
  //   // notes is optional; minLength/maxLength apply only when present
  //   minLength(path.notes, 5);
  //   maxLength(path.notes, 50);
  // });
  // Initial model shape: form.model$() => { sku: null, quantity: 5, notes: null }
}
```

## Building & testing

```bash
ng build ngx-signal-form-zod
ng test ngx-signal-form-zod
```