Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ It catches stale `form.values.contacts[N]` reads at compile time. Nuxt 3 / 4 set
import { useForm } from 'attaform/zod' // auto-detects Zod major

const schema = z.object({
email: z.email(),
password: z.string().min(8),
username: z.string().min(2, 'At least 2 characters'),
password: z.string().min(8, 'At least 8 characters'),
})

const form = useForm({ schema, key: 'signup' })
Expand All @@ -89,8 +89,8 @@ It catches stale `form.values.contacts[N]` reads at compile time. Nuxt 3 / 4 set

<template>
<form @submit.prevent="onSubmit">
<input v-register="form.register('email')" placeholder="Email" />
<small v-if="form.errors.email?.[0]">{{ form.errors.email[0].message }}</small>
<input v-register="form.register('username')" placeholder="Username" />
<small v-if="form.errors.username?.[0]">{{ form.errors.username[0].message }}</small>

<input v-register="form.register('password')" type="password" placeholder="Password" />
<small v-if="form.errors.password?.[0]">{{ form.errors.password[0].message }}</small>
Expand All @@ -102,9 +102,9 @@ It catches stale `form.values.contacts[N]` reads at compile time. Nuxt 3 / 4 set

`useForm({ schema, key })` returns a Pinia-style reactive object — read leaves directly, no `.value`:

- **`form.values`** — current values. `form.values.email`, `form.values.address.city`.
- **`form.errors`** — per-field errors, keyed by dotted path. `form.errors.email?.[0]?.message`.
- **`form.fields`** — per-field flags (`dirty`, `touched`, `errors`, `blank`, …). `form.fields.email.dirty`.
- **`form.values`** — current values. `form.values.username`, `form.values.address.city`.
- **`form.errors`** — per-field errors, keyed by dotted path. `form.errors.username?.[0]?.message`.
- **`form.fields`** — per-field flags (`dirty`, `touched`, `errors`, `blank`, …). `form.fields.username.dirty`.
- **`form.meta`** — form-level flags + counters (`isSubmitting`, `isValid`, `canUndo`, `submitCount`, the flat `meta.errors` aggregate, the per-mount `instanceId`, …).
- **`form.register(path)`** — typed two-way binding; pair with `v-register` on `<input>` / `<textarea>` / `<select>`.
- **`form.handleSubmit(onValid, onInvalid?)`** — runs validation, dispatches. The valid callback receives the strict zod-inferred type.
Expand Down
47 changes: 36 additions & 11 deletions docs/api/zod.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ The unified Zod entry. Same import for Zod 3 and Zod 4 projects — the runtime
import { useForm, fieldMeta, withMeta } from 'attaform/zod'
import { z } from 'zod'

const form = useForm({ schema: z.object({ email: z.email() }) })
const form = useForm({
schema: z.object({
username: z.string().min(2, 'At least 2 characters'),
password: z.string().min(8, 'At least 8 characters'),
}),
})
```

This is THE recommended import for new projects. Use it whenever you don't have a specific reason to pin one Zod major.
Expand Down Expand Up @@ -42,13 +47,13 @@ import {
import type { FieldMetaPayload, Unset } from 'attaform/zod'
```

| Export | What it does |
| ------------------------------------------- | --------------------------------------------------------------------------------------- |
| `useForm` | Runtime-dispatching wrapper. Type signature targets Zod 4. See [usage](#useform). |
| `injectForm`, `useRegister` | Schema-agnostic — identical across the two adapters. |
| `fieldMeta`, `withMeta`, `FieldMetaPayload` | Re-exported from the v4 adapter; see [Caveats](#caveats) for the runtime-dispatch case. |
| `unset`, `isUnset`, `Unset` | Sentinel for "displayed empty"; identical across adapters. |
| `AttaformErrorCode` | Library-emitted error-code enum (`atta:*`). |
| Export | What it does |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useForm` | Runtime-dispatching wrapper. Type signature targets Zod 4. See [usage](#useform). |
| `injectForm`, `useRegister` | Schema-agnostic — identical across the two adapters. |
| `fieldMeta`, `withMeta`, `FieldMetaPayload` | Cross-major schema metadata. Backed by a shared store so writes here are read by whichever adapter dispatches; `withMeta` runtime-branches on schema shape for cloning. |
| `unset`, `isUnset`, `Unset` | Sentinel for "displayed empty"; identical across adapters. |
| `AttaformErrorCode` | Library-emitted error-code enum (`atta:*`). |

For `zodAdapter`, `kindOf`, `assertZodVersion`, `ZodKind`, and `UnsupportedSchemaError` — the surfaces that diverge between v3 and v4 — use the [`attaform/zod-v3`](/docs/api/zod-v3) or [`attaform/zod-v4`](/docs/api/zod-v4) explicit subpath.

Expand All @@ -58,7 +63,10 @@ Same surface as the per-major adapters. See [The useForm return value](/docs/api

```ts
const form = useForm({
schema: z.object({ email: z.email() }),
schema: z.object({
username: z.string().min(2, 'At least 2 characters'),
password: z.string().min(8, 'At least 8 characters'),
}),
key: 'signup',
})
```
Expand All @@ -67,11 +75,28 @@ When the build-time alias is in play, the consumer's bundled code resolves to th

## Schema-attached metadata

`fieldMeta` and `withMeta` are re-exported from the v4 adapter. Same surface as documented in [`attaform/zod-v4` § Schema-attached metadata](/docs/api/zod-v4#schema-attached-metadata).
`fieldMeta` and `withMeta` write to a cross-adapter store, so a payload registered through this entry is visible at lookup whether the v3 or v4 adapter actually runs. `withMeta` runtime-branches on the schema's shape: Zod 4 schemas are cloned via the native `.clone()`, Zod 3 schemas via constructor + `_def` reconstruction. The native `schema.register(fieldMeta, payload)` chain still works for v4 schemas — Zod 4's `.register()` only needs `.add(this, payload)` on the registry, which the shared store provides.

```ts
import { z } from 'zod'
import { useForm, withMeta } from 'attaform/zod'

const schema = z.object({
username: withMeta(z.string().min(2, 'At least 2 characters'), {
label: 'Username',
placeholder: 'your-handle',
}),
})

const form = useForm({ schema })
// form.fields.username.label → 'Username'
// form.fields.username.placeholder → 'your-handle'
```

See [`attaform/zod-v4` § Schema-attached metadata](/docs/api/zod-v4#schema-attached-metadata) for the resolution-order table and the registration-pattern notes — both apply identically to the unified entry.

## Caveats

- **`fieldMeta` shape on Zod 3 without the Vite plugin.** Without the build-time alias, the unified entry exports the v4 `fieldMeta` registry. The v4 registry's `getFieldMetaList` helper isn't available on Zod 3. If you're on Zod 3 and not using Vite, import `fieldMeta` from [`attaform/zod-v3`](/docs/api/zod-v3) directly.
- **Both Zod versions installed.** Aliasing both `zod` and `zod-v3` (or similar) bypasses the Vite plugin's detection — pass `attaform({ resolveZodAlias: false })` and consume the runtime dispatch, or import the explicit subpath at every call site.
- **TypeScript inference.** The unified entry's `useForm` signature targets Zod 4. With the build-time alias, the consumer's code is rewritten to the explicit subpath, so post-build inference is exact. Without it, Zod 3 consumers should reach for [`attaform/zod-v3`](/docs/api/zod-v3) for the strongest inference.

Expand Down
28 changes: 13 additions & 15 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Get a working Attaform form on screen in under five minutes.
import { useForm } from 'attaform/zod'

const schema = z.object({
email: z.email(),
username: z.string().min(2, 'At least 2 characters'),
password: z.string().min(8, 'At least 8 characters'),
})

Expand All @@ -36,9 +36,9 @@ Get a working Attaform form on screen in under five minutes.
<template>
<form @submit.prevent="onSubmit">
<label>
Email
<input v-register="form.register('email')" type="email" />
<small>{{ form.errors.email?.[0]?.message }}</small>
Username
<input v-register="form.register('username')" />
<small>{{ form.errors.username?.[0]?.message }}</small>
</label>

<label>
Expand Down Expand Up @@ -75,27 +75,25 @@ Attach metadata to fields so the schema stays the single source of truth for bot
```vue
<script setup lang="ts">
import { z } from 'zod'
import { useForm, fieldMeta } from 'attaform/zod'
import { useForm, withMeta } from 'attaform/zod'

const schema = z.object({
email: z.email().register(fieldMeta, {
label: 'Email',
placeholder: 'you@example.com',
username: withMeta(z.string().min(2, 'At least 2 characters'), {
label: 'Username',
placeholder: 'your-handle',
}),
password: withMeta(z.string().min(8, 'At least 8 characters'), {
label: 'Password',
}),
password: z.string().min(8).register(fieldMeta, { label: 'Password' }),
})

const form = useForm({ schema, key: 'signup' })
</script>

<template>
<label>
{{ form.fields.email.label }}
<input
v-register="form.register('email')"
type="email"
:placeholder="form.fields.email.placeholder"
/>
{{ form.fields.username.label }}
<input v-register="form.register('username')" :placeholder="form.fields.username.placeholder" />
</label>
</template>
```
Expand Down
71 changes: 71 additions & 0 deletions src/runtime/adapters/unified/field-meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Field-metadata write/read API for the unified `attaform/zod` entry.
*
* Storage is shared with both adapters via `field-meta-store` — a
* payload written here is visible to whichever adapter the unified
* `useForm` dispatches to at runtime, regardless of Zod major. No
* `zod` runtime import; the type-only `import type` is erased at
* build, so `attaform/zod` carries no `z.registry` reference even
* when consumed by a Zod 3 project without the Vite plugin alias.
*
* The native v4 chain `schema.register(fieldMeta, payload)` continues
* to work — Zod 4's `.register()` only calls `.add(this, payload)`
* structurally, satisfied by the shared store.
*/
import type { z } from 'zod'
import type { FieldMetaPayload } from '../../core/field-meta'
import { fieldMetaStore, getFieldMetaForSchema } from '../../core/field-meta-store'

// Zod v4's `$ZodRegistry` class isn't surfaced under the `z` namespace
// of the classic external entry, but `z.registry()` returns one — so
// `ReturnType<typeof z.registry<T>>` resolves to the registry type
// without needing a direct import. The `import type` keeps the
// reference type-only; nothing about `z.registry` lands in the bundle.
type ZodFieldMetaRegistry = ReturnType<typeof z.registry<FieldMetaPayload>>

/**
* The shared registry every Attaform-aware Zod schema can register
* field metadata against, regardless of major. Same instance the v3
* and v4 adapter entries expose — write in one place, read from
* any.
*
* Cast to Zod 4's `$ZodRegistry<FieldMetaPayload>` so the native
* `schema.register(fieldMeta, payload)` chain type-checks for v4
* users; the runtime call only needs `.add` structurally, which the
* shared store provides.
*/
export const fieldMeta = fieldMetaStore as unknown as ZodFieldMetaRegistry

/**
* Attach `payload` to `schema` in the shared registry and return a
* clone of `schema` so each call gets its own identity (the registry
* keys on schema reference, so cloning prevents last-write-wins
* collisions for sub-schemas reused at multiple paths).
*
* Works on both Zod 3 and Zod 4 schemas — branches on the runtime
* shape of the schema:
* - Zod 4 schemas expose a public `.clone()` method; we call it.
* - Zod 3 schemas don't, so we reconstruct via
* `new schema.constructor(schema._def)`.
*
* Both forms produce a fresh schema with the same effective
* structure, so the registry slot is unique to this call site.
*/
export function withMeta<S>(schema: S, payload: FieldMetaPayload): S {
const target = schema as object
const existing = getFieldMetaForSchema(target) ?? {}
const cloned = cloneSchema(schema)
fieldMetaStore.add(cloned as object, { ...existing, ...payload })
return cloned
}

function cloneSchema<S>(schema: S): S {
const candidate = schema as { clone?: unknown; constructor: unknown; _def: unknown }
if (typeof candidate.clone === 'function') {
return (candidate.clone as () => S)()
}
// Zod 3 path: reconstruct via constructor + _def (no public
// `.clone()` on v3).
const Ctor = candidate.constructor as new (def: unknown) => S
return new Ctor(candidate._def)
}
4 changes: 2 additions & 2 deletions src/runtime/adapters/unified/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ import type { DeepPartial, DefaultValuesShape, GenericForm } from '../../types/t
*
* const form = useForm({
* schema: z.object({
* email: z.email(),
* password: z.string().min(8),
* username: z.string().min(2, 'At least 2 characters'),
* password: z.string().min(8, 'At least 8 characters'),
* }),
* })
* ```
Expand Down
49 changes: 21 additions & 28 deletions src/runtime/adapters/zod-v3/field-meta.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
/**
* Field-metadata write/read API for the Zod v3 adapter.
*
* Zod 3 has no `z.registry()` mechanism, so we shim one with a
* module-scoped `WeakMap<ZodTypeAny, FieldMetaPayload>` plus a
* registry-shaped object exposing `add` / `get` / `has`. The public
* `withMeta(schema, payload)` write API matches `attaform/zod` so
* schema authoring reads identically across the two adapters.
* Storage lives in the shared `field-meta-store` core — every entry
* (`attaform/zod`, `attaform/zod-v3`, `attaform/zod-v4`) writes to and
* reads from the same `WeakMap`s, so a payload registered via any
* entry surfaces at lookup regardless of which adapter actually runs.
*
* Zod 3 has no `z.registry()` mechanism, so `fieldMeta` is the
* shared registry-shaped object exposing `add` / `get` / `has` /
* `remove`. The public `withMeta(schema, payload)` write API matches
* `attaform/zod`'s so schema authoring reads identically across the
* two adapters.
*
* **Registration patterns:** both styles work — register on whatever
* schema reference you assign into the parent's shape, OR on the
Expand All @@ -23,14 +28,13 @@
*/
import type { z } from 'zod-v3'
import type { FieldMetaPayload } from '../../core/field-meta'

const store = new WeakMap<z.ZodTypeAny, FieldMetaPayload>()
import { fieldMetaStore, getFieldMetaForSchema } from '../../core/field-meta-store'

/**
* The shared registry every Attaform-aware Zod 3 schema can register
* field metadata against. Exposes a registry-shaped surface so that
* v3 user code can use the same idiom v4 users do (`fieldMeta.add(schema,
* payload)`); under the hood it's just a `WeakMap`.
* field metadata against. Backed by the cross-adapter
* `fieldMetaStore` — a payload registered here is visible to the v4
* adapter and the unified `attaform/zod` entry, and vice versa.
*/
type FieldMetaRegistryV3 = {
/**
Expand All @@ -47,25 +51,14 @@ type FieldMetaRegistryV3 = {
has(schema: z.ZodTypeAny): boolean
}

export const fieldMeta: FieldMetaRegistryV3 = {
add(schema, payload) {
store.set(schema, payload)
return fieldMeta
},
get(schema) {
return store.get(schema)
},
has(schema) {
return store.has(schema)
},
}
export const fieldMeta = fieldMetaStore as unknown as FieldMetaRegistryV3

/**
* Attach `payload` to `schema` in the shared `fieldMeta` registry
* and return a clone of `schema` (chainable, with the new metadata).
* Cross-version with `attaform/zod`'s `withMeta()`.
*
* **Why clone, not mutate.** The WeakMap shim keys metadata on the
* **Why clone, not mutate.** The shared store keys metadata on the
* schema reference. Calling `withMeta` twice on the same instance
* would overwrite (last-write-wins) — so a sub-schema reused at
* multiple form paths (e.g. an address schema shared between pickup
Expand All @@ -83,18 +76,18 @@ export const fieldMeta: FieldMetaRegistryV3 = {
* registers once and surfaces at every path.
*
* `schema.register()` does NOT exist on Zod 3 — `withMeta` is the
* only write API. Register on the inner schema before wrapping;
* see the "Registration rule" note in this file's header.
* only fluent write API. Register on the inner schema before
* wrapping; see the "Registration rule" note in this file's header.
*/
export function withMeta<S extends z.ZodTypeAny>(schema: S, payload: FieldMetaPayload): S {
const existing = store.get(schema) ?? {}
const existing = getFieldMetaForSchema(schema as object) ?? {}
// Zod 3 lacks a public `.clone()`, so reconstruct via the
// constructor + _def. Each ZodSchema subclass's constructor takes
// a `_def` object and produces an instance — same shape, fresh
// identity.
const Ctor = schema.constructor as new (def: S['_def']) => S
const cloned = new Ctor(schema._def)
store.set(cloned, { ...existing, ...payload })
fieldMetaStore.add(cloned as object, { ...existing, ...payload })
return cloned
}

Expand All @@ -107,5 +100,5 @@ export function withMeta<S extends z.ZodTypeAny>(schema: S, payload: FieldMetaPa
* Not part of the public `attaform/zod-v3` surface.
*/
export function getFieldMeta(schema: z.ZodTypeAny): FieldMetaPayload | undefined {
return store.get(schema)
return getFieldMetaForSchema(schema as object)
}
Loading
Loading