/skills/fuz-stack/references/zod-schemas
  • docs
  • skills
  • fuz-stack
    • Async Patterns
    • Code Generation
    • Common Utilities
    • CSS Patterns
    • Dependency Injection
    • Documentation System
    • File Organization
    • mdz — Strict Markdown Dialect
    • Approved npm Dependencies
    • Path References in Documentation
    • Approved Rust Dependencies
    • Rust Patterns for the Fuz Ecosystem
    • Rust Performance Patterns
    • Rust Spine & Consumer Servers
    • Svelte 5 Patterns
    • Task Patterns
    • Testing Patterns
    • TSDoc Comment Style Guide
    • Twin Implementations (TS ↔ Rust)
    • Type Utilities
    • WASM Patterns for the Fuz Ecosystem
    • Zod Schemas
  • grimoire
  • tools
  • hash

Zod Schemas

Zod schema conventions for @fuzdev TypeScript/Svelte projects.

Schema-First Design

Zod schemas are source of truth for JSON shape, TypeScript type (z.infer), defaults, metadata, CLI help text, and serialization.

  • .meta({description}) — introspectable metadata for CLI help and runtime reflection
  • Runtime-inspectable — walkable (zod_to_schema_properties), exportable as JSON Schema (z.toJSONSchema)
  • JSON-native — branded strings for timestamps (Datetime), IDs (Uuid), paths (FilePath) eliminate serialization friction
  • Composition cascades — .extend() for hierarchies, .brand() for domain safety, .default() for partial construction

Schema helpers by layer

LayerModuleCapabilities
Foundation@fuzdev/fuz_util/zod.tsSchema introspection — extract descriptions, defaults, aliases, types, properties; unwrap wrappers (zod_get_innermost_type, zod_unwrap_to_object); object-field helpers (zod_get_schema_keys, zod_get_field_schema, zod_maybe_get_field_schema); check optional/nullable/default; format values for display
Foundation@fuzdev/fuz_util/id.ts, @fuzdev/fuz_util/datetime.tsUuid, Datetime branded types and factories (create_uuid, get_datetime_now, UuidWithDefault, DatetimeNow)
Cell helpers@fuzdev/zzz/zod_helpers.tsPath-transform schemas (PathWithTrailingSlash, PathWithoutTrailingSlash, PathWithLeadingSlash)
CLI@fuzdev/fuz_app/cli/args.ts, help.tsSchema-validated CLI arg parsing; schema-driven help text generation
HTTP@fuzdev/fuz_app/http/schema_helpers.tsschema_to_surface() exports JSON Schema via z.toJSONSchema() for snapshot-testable API surfaces; instanceof checks for schema type detection
Testing@fuzdev/fuz_app/testing/schema_generators.tsSchema-driven test data generation — valid bodies, adversarial inputs

Core Conventions

  1. z.strictObject() — default for all object schemas, including inside z.discriminatedUnion() and z.union(). Rejects unknown keys. Exceptions: external data (z.looseObject() or z.object() with a comment explaining why); response/error schemas consumed by clients (z.looseObject() — add fields without breaking consumers); protocol schemas where the other side may add fields per spec (e.g., JSON-RPC messages).
  2. PascalCase naming — schema and inferred type share the same name.
  3. .meta({description: '...'}) — not .describe(). .meta() supports additional keys (aliases, sensitivity).
  4. safeParse for external input, parse for fail-fast — full guidance (external input, internal assertions/CLI args, custom-throw for error context, return-null for optional config) in §Validation at Boundaries.

The Canonical Pattern

import {z} from 'zod'; export const MyThing = z.strictObject({ name: z.string().min(1), count: z.number().int().default(0), kind: z.enum(['a', 'b']), }); export type MyThing = z.infer<typeof MyThing>;

The const and type share the same name — TypeScript resolves from context.

Wrong Patterns

// WRONG: z.object for internal types — allows unknown keys silently const Foo = z.object({name: z.string()}); // WRONG: z.object inside discriminated union — same rule applies const Action = z.discriminatedUnion('type', [ z.object({type: z.literal('a'), value: z.string()}), ]); // OK: z.looseObject for external data — source adds fields without notice // z.looseObject: parses external package.json (npm adds fields) const PackageJson = z.looseObject({name: z.string(), version: z.string()}); // OK: z.object for external API responses — same reason // z.object: parses external GitHub API responses const GithubPullRequest = z.object({number: z.number(), title: z.string()}); // OK: z.looseObject for response/error schemas — clients tolerate additions // z.looseObject: error responses may carry extra context fields const ApiError = z.looseObject({error: z.string()}); const TableListOutput = z.looseObject({tables: z.array(z.strictObject({name: z.string()}))}); // WRONG: .describe() — works but not the convention const Bar = z.string().describe('a bar'); // WRONG: snake_case schema name or -Schema suffix const my_thing = z.strictObject({...}); const MyThingSchema = z.strictObject({...}); // RIGHT const Foo = z.strictObject({name: z.string()}); const Bar = z.string().meta({description: 'a bar'}); const MyThing = z.strictObject({...}); // RIGHT: strictObject inside discriminated union const Action = z.discriminatedUnion('type', [ z.strictObject({type: z.literal('a'), value: z.string()}), ]);

Input vs Output Types

Schemas with .default() or .transform() have different input and output types. z.infer<> gives the output (post-parse) type; z.input<> gives the pre-parse type — what callers provide before defaults are applied.

Export z.input<> when callers construct partial instances via .parse(); skip it when the schema is only consumed internally (env loading, action spec satisfies).

This is a systematic pattern in zzz:

// zzz — every Cell schema exports both types export const ChatJson = CellJson.extend({ name: z.string().default(''), thread_ids: z.array(Uuid).default(() => []), selected_thread_id: Uuid.nullable().default(null), }).meta({cell_class_name: 'Chat'}); export type ChatJson = z.infer<typeof ChatJson>; // all fields present export type ChatJsonInput = z.input<typeof ChatJson>; // defaults omittable // a schema extending a base + literal discriminant, exporting an input type export const PackageResource = ResourceBase.extend({ type: z.literal('package'), from: PackageMapping, check: z.string().optional(), }); export type PackageResource = z.infer<typeof PackageResource>; export type PackageResourceInput = z.input<typeof PackageResource>;

Use z.input<> for: constructor/factory parameters (Cell instantiation, resource builders), config file shapes (before defaults applied), form inputs and partial data from storage.

Use z.infer<> (the default) for: runtime data after parsing, function return types, validated state.

Factory Functions with Input Types

A systematic factory pattern: accept z.input<> without the discriminant field, parse to get validated output:

export const package_resource = ( config: Omit<PackageResourceInput, 'type'>, ): PackageResource => { return PackageResource.parse({type: 'package', ...config}); }; // usage — type-safe, defaults applied, discriminant injected const pkg = package_resource({id: 'nginx', name: 'nginx', from: {apt: 'nginx'}});

parse applies defaults and validates; Omit<Input, 'type'> lets callers skip the discriminant.

Branded Types

Nominal typing for primitives — a Uuid is not interchangeable with string at the type level:

// fuz_util/id.ts — Zod 4 built-in validators + brand export const Uuid = z.uuid().brand('Uuid'); export type Uuid = z.infer<typeof Uuid>; // fuz_util/datetime.ts export const Datetime = z.iso.datetime().brand('Datetime'); export type Datetime = z.infer<typeof Datetime>; // zzz/diskfile_types.ts — refine + brand for domain validation export const DiskfilePath = z .string() .refine((p) => is_path_absolute(p), {message: 'path must be absolute'}) .brand('DiskfilePath'); export type DiskfilePath = z.infer<typeof DiskfilePath>; // simple string + brand (generic syntax, no runtime format check) export const ResourceId = z.string().min(1).brand<'ResourceId'>(); export type ResourceId = z.infer<typeof ResourceId>; export const FilePath = z.string().min(1).brand<'FilePath'>(); export type FilePath = z.infer<typeof FilePath>;

Use branded types for values that should not be accidentally swapped. Dynamic defaults use factory functions (Uuid.default(create_uuid), Datetime.default(get_datetime_now)). For TypeScript-only nominal typing without runtime validation, see Flavored in ./type-utilities.

Defaults and Optionality

// .default() — static or factory count: z.number().int().default(0), thread_ids: z.array(Uuid).default(() => []), // factory for mutable defaults auth: DatabaseAuth.default({method: 'trust', hosts: ['127.0.0.1/32']}), // .optional() — field can be omitted (undefined). For request fields callers may skip. port: z.number().optional(), // .nullable() — field is present but can be null. For database columns and // explicit "no value" semantics. email: Email.nullable(), expires_at: z.string().nullable(), // .nullable().default(null) — present, nullable, defaults to null if omitted. // Common for Cell fields that are optional references. selected_thread_id: Uuid.nullable().default(null), // .nullish() — null | undefined. For flexible inputs that accept either. // Use sparingly — prefer .optional() or .nullable() for clarity. email: Email.nullish(), // fuz_app invite creation // .catch(fallback) — use fallback if present value fails validation. // Different from .default() (missing field). For graceful degradation of // stored data that may have been written by an older schema version. before: PreviousState.nullable().catch(null), // tolerate older stored shapes

Field-Level Validation

Use .shape to validate individual fields without parsing the whole object:

// zzz/part.svelte.ts — reuse a base field's validator via `.shape` // (here a subtype overrides the inherited default) has_xml_tag: PartJsonBase.shape.has_xml_tag.default(true), // or validate a single value against one field's schema PartJsonBase.shape.has_xml_tag.parse(value);

Transform Pipelines

// zzz/zod_helpers.ts export const PathWithTrailingSlash = z.string().transform((v) => ensure_end(v, '/')); export const PathWithoutTrailingSlash = z.string().transform((v) => strip_end(v, '/'));

Transforms run at parse time — output type differs from input type.

Compose with .pipe() for multi-stage validation:

// zzz/diskfile_types.ts — transform then brand export const DiskfileDirectoryPath = PathWithTrailingSlash.pipe(DiskfilePath).brand('DiskfileDirectoryPath');

Zod 4 Primitives

z.uuid() // UUID validation (used with .brand('Uuid')) z.iso.datetime() // ISO 8601 datetime (used with .brand('Datetime')) z.email() // email validation z.url() // URL validation z.coerce.number() // string-to-number coercion (env vars) z.looseObject({...}) // accepts unknown keys (external data) z.toJSONSchema(schema) // export schema as JSON Schema z.prettifyError(error) // format ZodError for display (CLI args) z.instanceof(MyClass) // runtime class instance check (Cell class schemas in zzz) z.void() // no value — action specs with no input/output z.record(K, V) // key-value maps (env vars, resource maps) z.custom<T>(check?) // escape hatch for complex types without full Zod validation
  • z.null() vs z.void() — z.null() for HTTP input (JSON null, e.g. input: z.null() for no request body in route specs); z.void() / z.void().optional() for action specs with no input or output value
  • z.custom<T>(check?) — embeds complex types without full Zod validation; use sparingly (e.g., z.custom<z.ZodType>(...) in fuz_app action specs)
  • z.instanceof(MyClass) — runtime class instance check; used in zzz so action specs can reference Cell instances as typed values

Schema Introspection

When inspecting schema types at runtime, prefer instanceof checks and the public .def property:

// instanceof — type detection without internal APIs schema instanceof z.ZodNull schema instanceof z.ZodObject schema instanceof z.ZodArray // .def — public getter for the type definition (same as _zod.def) const def = schema.def; def.type // 'string', 'object', 'null', etc. // WRONG: ._zod.def — internal API, same value but not public schema._zod.def // works but prefer schema.def

See @fuzdev/fuz_util/zod.ts for unwrapping utilities (zod_unwrap_def, zod_get_base_type, zod_to_subschema, zod_get_innermost_type, zod_unwrap_to_object) that handle wrappers like optional, nullable, default, transform, and pipe; and object-field helpers (zod_get_schema_keys, zod_get_field_schema, zod_maybe_get_field_schema).

Unions and Enums

Discriminated Unions

Use z.discriminatedUnion() when a type field determines the shape; members use z.strictObject():

// zzz/provider_types.ts — discriminate on `available`; members use strictObject export const ProviderStatus = z.discriminatedUnion('available', [ z.strictObject({name: z.string(), available: z.literal(true), checked_at: z.number()}), z.strictObject({ name: z.string(), available: z.literal(false), error: z.string(), checked_at: z.number(), }), ]); export type ProviderStatus = z.infer<typeof ProviderStatus>;

Plain Unions

Use z.union() when there's no single discriminant field, or when mixing shapes with literals:

// zzz/jsonrpc.ts — multiple message shapes export const JsonrpcMessage = z.union([ JsonrpcRequest, JsonrpcNotification, JsonrpcResponse, JsonrpcErrorMessage, ]); // mixed literals + an object shape export const Sort = z.union([ z.literal('asc'), z.literal('desc'), z.strictObject({by: z.string(), dir: z.enum(['asc', 'desc'])}), ]); // union with a literal `false` for opt-out const sudo = z.union([z.enum(['nopasswd', 'password']), z.literal(false)]).optional();

Prefer z.discriminatedUnion() when possible — it gives better error messages.

Enums

export const ActionKind = z.enum(['request_response', 'remote_notification', 'local_call']); export type ActionKind = z.infer<typeof ActionKind>;

For extensible enums, use a factory:

// fuz_app/auth/role_schema.ts — dynamic enum from builtin + app-defined roles export const create_role_schema = (app_roles: Array<string>) => { const all_roles = [...BUILTIN_ROLES, ...app_roles]; const Role = z.enum(all_roles as [string, ...Array<string>]); return {Role, role_options: new Map(/* ... */)}; };

Schema Extension

.extend() adds or overrides fields, preserving strict mode:

// fuz_app/actions/action_spec.ts export const ActionSpec = z.strictObject({ method: z.string(), kind: ActionKind, input: z.custom<z.ZodType>((v) => v instanceof z.ZodType), output: z.custom<z.ZodType>((v) => v instanceof z.ZodType), // ... }); export const RequestResponseActionSpec = ActionSpec.extend({ kind: z.literal('request_response').default('request_response'), auth: RouteAuth.nullable(), // four-axis {account, actor, roles?, credential_types?} async: z.literal(true).default(true), });

Cell Schemas (zzz)

Every Cell class has a schema built with CellJson.extend() (see ChatJson example in Input vs Output Types above). Cell schema conventions:

  • All fields must have .default() for Cell instantiation from partial JSON
  • .meta({cell_class_name}) connects the schema to its Cell class for the registry
  • Every Cell exports both FooJson (output, fully validated) and FooJsonInput (input, defaults omittable for constructors and set_json())
  • The Cell base class is generic over the schema: abstract class Cell<TSchema extends z.ZodType> — validates internally with this.schema.parse()

Metadata

.meta() attaches introspectable metadata. description powers CLI help; other keys are domain-specific:

export const DeployArgs = z.strictObject({ _: z.array(z.string()).max(0).default([]), dry: z.boolean().meta({description: 'preview without deploying'}).default(false), branch: z.string().meta({ description: 'deploy branch', aliases: ['b'], }).default('deploy'), });

Sensitivity Metadata (fuz_app)

SchemaFieldMeta (from @fuzdev/fuz_app/schema_meta.ts) extends .meta() with a sensitivity key:

DATABASE_URL: z.string().min(1).meta({ description: 'Database URL (postgres://, file://, or memory://)', sensitivity: 'secret', }), PORT: z.coerce.number().default(4040) .meta({description: 'HTTP server port'}),

sensitivity: 'secret' masks values in logs and API surface snapshots.

Validation at Boundaries

safeParse for External Input

Use safeParse when invalid data is a normal condition needing a graceful response:

// fuz_app/http/route_spec.ts — input validation middleware const result = input_schema.safeParse(body); if (!result.success) { return c.json({error: ERROR_INVALID_REQUEST_BODY, issues: result.error.issues}, 400); } c.set('validated_input', result.data); // zzz — external API responses const parsed = ApiResponse.safeParse(response);

Route specs declare input/output schemas for auto-generated validation middleware. Input validated via safeParse; output validated in DEV only.

parse for Fail-Fast Contexts

Use parse when invalid data means a bug or fatal misconfiguration:

RoleName.parse(name); // internal assertion const args = RunApplyArgs.parse(raw_args); // CLI args return PackageResource.parse({type: 'package', ...config}); // factory function const parsed = this.schema.parse(v); // Cell field update

safeParse with Custom Error Handling

safeParse + custom throw gives better error context than bare parse; safeParse + return null handles optional data that may be absent or invalid:

// fuz_app/env/load.ts — env loading: safeParse + custom error with raw values const result = schema.safeParse(raw); if (!result.success) { throw new EnvValidationError(raw, result.error); } // fuz_app/cli/config.ts — optional config file: safeParse + return null const result = schema.safeParse(parsed); if (!result.success) { runtime.warn(`Invalid config.json: ${result.error.message}`); return null; }

Formatting Errors

Prefer Zod 4's built-ins over hand-rolled formatters:

z.prettifyError(parsed.error); // multi-line, human-readable (CLI args, error display) z.treeifyError(parsed.error); // nested structure mirroring the schema z.flattenError(parsed.error); // {formErrors, fieldErrors} — flat, for forms

Quick Reference

ConventionCorrectWrong
Object schemas (internal)z.strictObject({...})z.object({...})
Object schemas (external data)z.looseObject({...}) or z.object({...}) with commentz.strictObject({...})
Response/error schemasz.looseObject({...}) — tolerates added fieldsz.strictObject({...})
Discriminated union membersz.strictObject({type: z.literal('a'), ...})z.object({type: z.literal('a'), ...})
Descriptions.meta({description: '...'}).describe('...')
Schema namingconst MyThing = z.strictObject(...)const my_thing, const MyThingSchema
Type inference (output)type MyThing = z.infer<typeof MyThing>separate name from schema
Type inference (input)type MyThingInput = z.input<typeof MyThing>manual partial types
IDs and pathsz.string().brand('MyId')plain z.string()
HTTP/API inputschema.safeParse(data)schema.parse(data)
CLI args/factoriesschema.parse(data)schema.safeParse(data) with unnecessary error handling
Env loadingsafeParse + custom throw (better error context)bare parse (loses raw values)
Optional config filessafeParse + return nullparse (crashes on missing file)
No input/outputz.void() or z.void().optional()z.undefined(), omitting the field
Optional referenceUuid.nullable().default(null)Uuid.optional() (ambiguous undefined vs absent)
Complex embedded typesz.custom<MyType>()hand-rolled validation
Key-value mapsz.record(z.string(), ValueSchema)z.strictObject with dynamic keys