@gravionlabs/helix-zod 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GravionLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,395 @@
1
+ # @gravionlabs/helix-zod
2
+
3
+ Zod v4 adapter for `@gravionlabs/helix-core` forms. Two independent features:
4
+
5
+ 1. **Reactive-forms validator bridge** — `HelixZodValidators.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.
6
+ 2. **Dynamic forms** — `HelixDynamicForm` 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**).
7
+
8
+ ---
9
+
10
+ ## Peer Dependencies
11
+
12
+ | Package | Version |
13
+ |---|---|
14
+ | `zod` | `^4.0.0` |
15
+ | `@gravionlabs/helix-core` | `>=0.2.0` |
16
+ | `@angular/core` / `@angular/common` / `@angular/forms` | `>=21` (dynamic forms: `>=21.2`) |
17
+
18
+ ---
19
+
20
+ ## Dynamic Forms from Zod Schemas
21
+
22
+ > Built on `@angular/forms/signals`, which is **experimental** — minor Angular releases may introduce breaking API changes.
23
+
24
+ ### Quick start
25
+
26
+ ```ts
27
+ import { z } from 'zod';
28
+ import { HelixDynamicForm, helixMeta, provideHelixDynamicForms } from '@gravionlabs/helix-zod';
29
+
30
+ const UserSchema = z.object({
31
+ email: helixMeta(z.email('Invalid email'), { label: 'E-mail', placeholder: 'you@example.com' }),
32
+ age: helixMeta(z.number().min(18, 'Must be 18+'), { label: 'Age' }),
33
+ role: helixMeta(z.enum(['user', 'editor']), { label: 'Role' }),
34
+ newsletter: helixMeta(z.boolean(), { label: 'Subscribe' }),
35
+ frequency: helixMeta(z.enum(['daily', 'weekly']), {
36
+ label: 'Frequency',
37
+ hiddenWhen: (root) => !root['newsletter'], // conditional visibility
38
+ }),
39
+ });
40
+
41
+ @Component({
42
+ imports: [HelixDynamicForm],
43
+ providers: [provideHelixDynamicForms()],
44
+ template: `<helix-dynamic-form [schema]="schema" (submitted)="save($event)" />`,
45
+ })
46
+ export class UserForm {
47
+ readonly schema = UserSchema;
48
+ save(value: unknown) { /* value is UserSchema.parse()d */ }
49
+ }
50
+ ```
51
+
52
+ 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.
53
+
54
+ ### Widget inference
55
+
56
+ | Zod type | Widget |
57
+ |---|---|
58
+ | `z.string()` | `text` (`z.email()` → `email`) |
59
+ | `z.number()` / `z.int()` | `number` |
60
+ | `z.boolean()` | `checkbox` |
61
+ | `z.date()` | `date` (`Date | null` model) |
62
+ | `z.enum()` / `z.literal()` | `select` |
63
+ | `z.array()` | `array` (add/remove UI) |
64
+ | `z.object()` | `object` (fieldset recursion) |
65
+ | `z.discriminatedUnion()` | `union` (variant switcher; switching **resets** the union value) |
66
+
67
+ Anything else needs a `widget` override in its metadata pointing at a registered (custom) widget — unmapped types throw in dev mode.
68
+
69
+ ### Metadata (`HelixFieldMeta`)
70
+
71
+ 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`.
72
+
73
+ 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.
74
+
75
+ ### Custom widgets & error messages
76
+
77
+ ```ts
78
+ provideHelixDynamicForms({
79
+ widgets: [{ widget: 'rating', component: RatingWidget }], // or override built-ins
80
+ errorMessageResolver: (error, helixKey) =>
81
+ helixKey === HelixValidatorKey.Required ? 'Pflichtfeld' : null, // null → default message
82
+ })
83
+ ```
84
+
85
+ Custom widgets extend `HelixFieldWidgetBase` (inputs `field` + `descriptor`, computeds `state`/`firstError`/`label`/`hint`/`placeholder`) and bind their control via `[formField]="field()"`.
86
+
87
+ ### Notes & caveats
88
+
89
+ - Passing a new `schema` input **recreates the form and resets its state**.
90
+ - Provide your own `WritableSignal` via `[model]` to control/observe the raw value; otherwise an initial model is derived from the schema (`buildDefaultValue`).
91
+ - `submitted` emits the `schema.parse()`d output (defaults/transforms applied) — only when the form is valid.
92
+ - Lower-level building blocks are exported for advanced use: `zodToFieldDescriptors`, `buildHelixSchema`, `buildDefaultValue`, `helixFirstErrorMessage`, `fieldAtPath`.
93
+
94
+ ---
95
+
96
+ ## Installation
97
+
98
+ This is a workspace library — no `npm install` needed. The path alias is already registered in `tsconfig.json`:
99
+
100
+ ```json
101
+ "@gravionlabs/helix-zod": ["./projects/zod/src/public-api.ts"]
102
+ ```
103
+
104
+ Import directly in your application:
105
+
106
+ ```ts
107
+ import { HelixZodValidators } from '@gravionlabs/helix-zod';
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Quick Start
113
+
114
+ ```ts
115
+ import { z } from 'zod';
116
+ import { HelixZodValidators } from '@gravionlabs/helix-zod';
117
+
118
+ // Wrap any Zod field schema in a reactive form control
119
+ form = this.fb.group({
120
+ email: ['', HelixZodValidators.fromZod(z.string().email('Invalid email'))],
121
+ name: ['', HelixZodValidators.fromZod(z.string().min(1, 'Required'), { allowEmpty: false })],
122
+ });
123
+ ```
124
+
125
+ `HelixFormField` reads the resulting `ValidationErrors` keys directly — no template changes required.
126
+
127
+ ---
128
+
129
+ ## API
130
+
131
+ ### `HelixZodValidators.fromZod(schema, options?)`
132
+
133
+ Converts a Zod field schema into a Helix-compatible Angular `ValidatorFn`.
134
+
135
+ ```ts
136
+ import type { ValidatorFn } from '@angular/forms';
137
+ import type { ZodSchema } from 'zod';
138
+ import type { ZodHelixOptions } from '@gravionlabs/helix-zod';
139
+
140
+ fromZod(schema: ZodSchema, options?: ZodHelixOptions): ValidatorFn
141
+ ```
142
+
143
+ **Parameters**
144
+
145
+ | Parameter | Type | Description |
146
+ |---|---|---|
147
+ | `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. |
148
+ | `options` | `ZodHelixOptions` | Optional configuration (see below). |
149
+
150
+ ### `ZodHelixOptions`
151
+
152
+ ```ts
153
+ export interface ZodHelixOptions {
154
+ fallbackKey?: HelixValidatorKey; // required when schema uses .refine() / .superRefine()
155
+ allowEmpty?: boolean; // default: true
156
+ }
157
+ ```
158
+
159
+ ---
160
+
161
+ ## Zod v4 → `HelixValidatorKey` Mapping
162
+
163
+ > This library targets **Zod v4**. Zod v4 introduced breaking changes from v3:
164
+ > `invalid_string` → `invalid_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'`.
165
+
166
+ | Zod v4 issue code | Condition | `HelixValidatorKey` |
167
+ |---|---|---|
168
+ | `invalid_type` | value is `''`, `null`, or `undefined` | `Required` |
169
+ | `invalid_type` | `expected === 'int'` | `Integer` |
170
+ | `invalid_type` | `expected === 'number'` / `'float'` | `Number` |
171
+ | `invalid_format` | `format === 'email'` | `Email` |
172
+ | `invalid_format` | `format === 'regex'` | `Pattern` |
173
+ | `invalid_format` | `format === 'datetime'` / `'date'` / `'time'` | `Date` |
174
+ | `too_small` | `origin === 'string'` or `'array'` | `MinLength` |
175
+ | `too_big` | `origin === 'string'` or `'array'` | `MaxLength` |
176
+ | `too_small` | `origin === 'number'` | `Min` |
177
+ | `too_big` | `origin === 'number'` | `Max` |
178
+ | `custom` | — | requires `fallbackKey` (see below) |
179
+ | anything else | — | requires `fallbackKey` (see below) |
180
+
181
+ All issues from a single `safeParse` are processed simultaneously, producing one error key per issue — equivalent to stacking multiple `HelixValidators` calls.
182
+
183
+ ### Known gaps — no automatic mapping
184
+
185
+ | Scenario | Recommendation |
186
+ |---|---|
187
+ | `z.enum()` → `invalid_value` | Use `fallbackKey` or keep using `HelixValidators.oneOf` |
188
+ | `z.array()` item-level errors | Use `fallbackKey` or `HelixValidators.allOf` |
189
+ | `invalid_type` for `boolean` | Helix has no `Boolean` key — use `fallbackKey` |
190
+
191
+ ---
192
+
193
+ ## `allowEmpty` Behaviour
194
+
195
+ `HelixValidators` defaults to `allowEmpty = true`: validation passes silently on empty values. `fromZod` mirrors this:
196
+
197
+ | Scenario | Recommended pattern |
198
+ |---|---|
199
+ | Optional field with format check | Default `allowEmpty: true` — empty passes, invalid format shows error |
200
+ | Mandatory field (required + format) | Stack `HelixValidators.required('msg')` alongside `fromZod(schema)` |
201
+ | Required via Zod only | `allowEmpty: false` — empty triggers `Required` (null/undefined) or `MinLength` (empty string) |
202
+
203
+ ```ts
204
+ // Optional — empty passes, bad format shows Email error
205
+ HelixZodValidators.fromZod(z.string().email('Invalid email'))
206
+
207
+ // Required — must have a value; empty string → MinLength, null → Required
208
+ HelixZodValidators.fromZod(z.string().min(1, 'Name is required'), { allowEmpty: false })
209
+
210
+ // Stacked — explicit required message + Zod format check
211
+ [
212
+ HelixValidators.required('Email is required'),
213
+ HelixZodValidators.fromZod(z.string().email('Invalid email')),
214
+ ]
215
+ ```
216
+
217
+ **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.
218
+
219
+ ---
220
+
221
+ ## `.refine()` and `fallbackKey`
222
+
223
+ `.refine()` and `.superRefine()` produce `ZodIssueCode.custom`, which has no automatic `HelixValidatorKey` mapping. Provide a `fallbackKey` to capture these errors:
224
+
225
+ ```ts
226
+ const bannedUsernames = ['admin', 'root', 'system'];
227
+
228
+ HelixZodValidators.fromZod(
229
+ z.string()
230
+ .min(3, 'At least 3 characters')
231
+ .refine((v) => !bannedUsernames.includes(v), 'Username is not allowed'),
232
+ { fallbackKey: HelixValidatorKey.Pattern },
233
+ )
234
+ ```
235
+
236
+ **Missing `fallbackKey` behaviour:**
237
+
238
+ - **Development** (`ngDevMode = true`): throws a descriptive error identifying the unmapped issue code and the failing issue JSON.
239
+ - **Production** (`ngDevMode = false`): the unmapped issue is silently skipped. The control remains invalid if other issues produce mapped errors.
240
+
241
+ ---
242
+
243
+ ## Component Example
244
+
245
+ ```ts
246
+ import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
247
+ import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
248
+ import { z } from 'zod';
249
+ import { HelixFormField, HelixValidators, HelixValidatorKey } from '@gravionlabs/helix-core';
250
+ import { HelixZodValidators } from '@gravionlabs/helix-zod';
251
+
252
+ // Define your schema once — reuse it for both API parsing and form validation
253
+ const UserSchema = z.object({
254
+ email: z.string().email('Invalid email'),
255
+ name: z.string().min(1, 'Name is required'),
256
+ });
257
+
258
+ const bannedUsernames = ['admin', 'root'];
259
+
260
+ @Component({
261
+ selector: 'app-register',
262
+ standalone: true,
263
+ imports: [ReactiveFormsModule, HelixFormField],
264
+ changeDetection: ChangeDetectionStrategy.OnPush,
265
+ template: `
266
+ <form [formGroup]="form" (ngSubmit)="onSubmit()">
267
+ <helix-form-field label="Email" [control]="form.controls.email">
268
+ <input type="email" formControlName="email" />
269
+ </helix-form-field>
270
+
271
+ <helix-form-field label="Name" [control]="form.controls.name">
272
+ <input type="text" formControlName="name" />
273
+ </helix-form-field>
274
+
275
+ <helix-form-field label="Username" [control]="form.controls.username">
276
+ <input type="text" formControlName="username" />
277
+ </helix-form-field>
278
+
279
+ <button type="submit" [disabled]="form.invalid">Register</button>
280
+ </form>
281
+ `,
282
+ })
283
+ export class RegisterComponent {
284
+ readonly #fb = inject(FormBuilder);
285
+
286
+ form = this.#fb.group({
287
+ // Stack required + Zod for a custom required message
288
+ email: [
289
+ '',
290
+ [
291
+ HelixValidators.required('Email is required'),
292
+ HelixZodValidators.fromZod(UserSchema.shape.email),
293
+ ],
294
+ ],
295
+
296
+ // allowEmpty: false — Zod handles both empty and format validation
297
+ name: [
298
+ '',
299
+ HelixZodValidators.fromZod(UserSchema.shape.name, { allowEmpty: false }),
300
+ ],
301
+
302
+ // .refine() requires fallbackKey
303
+ username: [
304
+ '',
305
+ HelixZodValidators.fromZod(
306
+ z.string()
307
+ .min(3, 'At least 3 characters')
308
+ .refine((v) => !bannedUsernames.includes(v), 'Username is not allowed'),
309
+ { fallbackKey: HelixValidatorKey.Pattern },
310
+ ),
311
+ ],
312
+ });
313
+
314
+ protected onSubmit() {
315
+ if (this.form.invalid) {
316
+ this.form.markAllAsTouched();
317
+ return;
318
+ }
319
+ // Parse the form value through the full schema for the API call
320
+ const payload = UserSchema.parse(this.form.value);
321
+ console.log(payload);
322
+ }
323
+ }
324
+ ```
325
+
326
+ ### How errors surface in `HelixFormField`
327
+
328
+ `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.
329
+
330
+ ```ts
331
+ // helixFormErrorMap also works identically
332
+ import { helixFormErrorMap } from '@gravionlabs/helix-core';
333
+ const errors = helixFormErrorMap(this.form);
334
+ // → { email: 'Invalid email', name: 'Name is required' }
335
+ ```
336
+
337
+ ---
338
+
339
+ ## `UserSchema.shape` — Single Source of Truth
340
+
341
+ Using `UserSchema.shape.<field>` directly in `fromZod` eliminates duplicated validation rules between your API schema and your form:
342
+
343
+ ```ts
344
+ // Without shape — rules written twice
345
+ z.string().email() // API parsing
346
+ HelixValidators.email('...') // form (same rule, again)
347
+
348
+ // With shape — one definition drives both
349
+ HelixZodValidators.fromZod(UserSchema.shape.email)
350
+ ```
351
+
352
+ > 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.
353
+
354
+ ---
355
+
356
+ ## Running Tests
357
+
358
+ ```bash
359
+ # Run helix-zod tests
360
+ pnpm ng test zod
361
+
362
+ # Run all library tests
363
+ pnpm test:lib
364
+ ```
365
+
366
+ Tests are written with [Vitest](https://vitest.dev) and cover all mapped issue codes, `allowEmpty` behaviour, `fallbackKey` usage, and the dev/prod error modes.
367
+
368
+ ---
369
+
370
+ ## Architecture Notes
371
+
372
+ This library is the `@gravionlabs/helix-zod` portion of the broader Zod integration architecture documented in [`ZOD_ARCHITECTURE_HELIX.md`](../../ZOD_ARCHITECTURE_HELIX.md) at the repo root. The arch doc also covers:
373
+
374
+ - App-level patterns: domain schemas, `UserSchema.shape`, schema composition
375
+ - REST endpoint validation with `HttpClient` and `httpResource`
376
+ - Luxon `IsoDateTime` transform schema
377
+ - Environment config validation at startup
378
+ - Generic `zodFieldValidator` (framework-agnostic, no `HelixValidatorKey` dependency)
379
+
380
+ Those patterns live in your application (`src/app/schemas/`, `src/app/api/`, etc.) — this library provides only the Angular `ValidatorFn` bridge.
381
+
382
+ ### Implementation vs Architecture Plan
383
+
384
+ The library fully implements the `helix-zod` scope defined in `ZOD_ARCHITECTURE_HELIX.md §6b`:
385
+
386
+ | Plan item | Status |
387
+ |---|---|
388
+ | `ZodHelixOptions` interface (`fallbackKey`, `allowEmpty`) | Implemented |
389
+ | `HelixZodValidators.fromZod()` factory | Implemented |
390
+ | Full Zod v4 issue → `HelixValidatorKey` mapping | Implemented |
391
+ | `allowEmpty = true` default mirroring `HelixValidators` | Implemented |
392
+ | `ngDevMode` throw on unmapped issue without `fallbackKey` | Implemented |
393
+ | Silent skip in production builds | Implemented |
394
+ | `z.array().min()` → `MinLength` | Implemented |
395
+ | App-level patterns (schemas, API services, Luxon) | Out of scope — application layer |