The schema contract

A schema declares a value's shape, types, constraints, and transformations. One declaration drives validation, type inference, defaults, and metadata, all from the same source.

Category
Conceptual
Default adapter
attaform/zod (Zod v4)
Also shipped
attaform/zod-v3
Custom
AbstractSchema contract

This page is the mental model for what a schema is and what it lets you do. The rest of the Schemas cluster takes each capability one at a time with side-by-side schema-and-result demos.

What a schema is

A schema is a declarative description of data. It states what keys exist, what types they hold, how they nest, which values are valid, and which transformations apply on the way in or out. A single declaration carries the answer to every question about the data's shape.

Different schema libraries take different approaches: parser-combinators, classes, descriptor objects, type-only signatures. Attaform is schema-agnostic at its core, consuming any object that implements the AbstractSchema contract. Out of the box, Zod is the canonical adapter. Zod v4 is the default; Zod v3 is one import away.

import { z } from 'zod'

const schema = z.object({
  email: z.email(),
  age: z.number().int().min(13),
})

That schema is the artifact every dimension below describes. Attaform reads it once and derives validation, defaults, types, and reactive surfaces from it.

What a schema declares

A schema covers six dimensions. Each one stands on its own; the rest of the Schemas cluster takes each in depth.

Shape

The structural skeleton: which keys exist, what types they hold, how they nest. Zod composes shape through z.object, z.array, z.tuple, z.record, z.discriminatedUnion, z.enum, and the primitives (z.string, z.number, z.boolean, z.date, z.bigint).

const schema = z.object({
  profile: z.object({
    name: z.string(),
    interests: z.array(z.string()),
  }),
  notify: z.discriminatedUnion('channel', [
    z.object({ channel: z.literal('email'), address: z.email() }),
    z.object({ channel: z.literal('sms'), phone: z.string() }),
  ]),
})

Shape is the substrate every other dimension builds on. The per-construct deep dives live at Nested objects, Arrays & tuples, Records & maps, and Discriminated unions.

Type safety

Every key, every leaf, every nested path carries a TypeScript type derived from the declaration. The schema is the single thing the type system reads; inference flows outward from there.

type Account = z.infer<typeof schema>
// { profile: { name: string; interests: string[] }, notify: ... }

No manual generics, no any, no reaching for plumbing whenever a field is added or renamed.

Validation

Refinements declare which values are valid. A predicate runs against a parsed value and either passes or attaches an error.

const password = z
  .string()
  .min(8, 'At least 8 characters')
  .refine((s) => /[A-Z]/.test(s), 'Needs an uppercase letter')

Refinements can be asynchronous. z.string().refine(async (v) => await api.isAvailable(v)) awaits the predicate before parsing settles. Synchronous predicates run eagerly; asynchronous ones await before submit dispatches. When validation runs covers the timing model.

Transformation

Two stages within parse can transform a value. z.preprocess(fn, T) normalizes the input before the inner schema sees it. .transform(fn) converts the validated value to the wire format.

z.preprocess((v) => (typeof v === 'string' ? v.trim() : v), z.string())

z.string().transform((s) => s.toLowerCase())

Both fire at parse time (handleSubmit, validate, validateAsync); storage holds the consumer's raw input verbatim. How values are stored walks the implications.

Metadata

Labels, descriptions, placeholders, and free-form annotations live on the schema itself. withMeta attaches them at any node.

import { withMeta } from 'attaform/zod'

const schema = z.object({
  email: withMeta(z.email(), {
    label: 'Email address',
    description: "We'll only use it for receipts.",
    placeholder: 'you@example.com',
  }),
})

Metadata travels with the schema; UI that consumes it reads from the declaration directly.

Defaults

.default(x) declares the value a field takes when no input is supplied. .catch(x) declares a fallback for parse failures.

const schema = z.object({
  priority: z.string().default('normal'),
  remember: z.boolean().default(true),
})

Defaults from the schema covers how declared defaults seed initial values and which operations re-apply them.

Zod adapters

attaform/zod wraps Zod v4 and is the canonical import for new projects. It walks the schema once at construction, caches structural metadata, and implements AbstractSchema against Zod's runtime.

import { useForm } from 'attaform/zod'

For projects still on Zod v3, swap the import: attaform/zod-v3. The consumer-facing surface is identical; the parsing engine and metadata walker differ to match v3's internals.

Schema-agnostic core

The core package (attaform) doesn't import Zod. It consumes any object that implements AbstractSchema, a small contract covering identity, defaults, shape introspection, and validation. The Zod adapters cover the bulk of real-world schemas; reach for AbstractSchema directly when you're wiring Valibot, ArkType, Effect Schema, or a hand-rolled validator.

Refinements vs. transforms

Refinements and transforms look adjacent but answer different questions.

// Refinement: runs at validate, doesn't change the value
z.string().refine((s) => /[a-z]/.test(s), 'Needs a lowercase letter')

// Transform: runs at parse, changes the value
z.string().transform((s) => s.toLowerCase())

Refinements ask "is this value acceptable?" Transforms ask "given this value, what should the next stage see?" Schemas stack both in any order; the order matters at validate / parse time.

The split is intentional. Refinements drive live feedback as users type; transforms shape the wire format on the way out.

Fingerprinting

Every schema carries a structural fingerprint: a short string that changes when the shape changes (adding or removing a field, changing a leaf type, restructuring nesting) but stays stable under refinement, transform, or metadata tweaks. The fingerprint surfaces in two places:

  • Persistence keys (a schema change auto-invalidates stale drafts).
  • Shared-key form mismatches in dev (two useForm({ key: 'x' }) calls with different schemas warn).

schema.fingerprint() lives on the adapter; the runtime calls it when needed.

Where to next