Zod schemas are the source of truth for fuz_app's app data and the apps
built on it — JSON shape, TypeScript type (z.infer), defaults, metadata
(.meta() → runtime reflection), and serialization. They're
runtime-inspectable (walkable via @fuzdev/fuz_app/zod.ts, exportable via
z.toJSONSchema) and JSON-native —
branded strings for timestamps (Datetime), IDs (Uuid), and paths avoid
serialization friction. Everything else — the packages below fuz_app, CLI
and task args (parsed by fuz_util's args_parse, ./task-patterns),
tooling, fuz_app's CLI config — is @fuzdev/fuz_schema.
| Layer | Module | Capabilities |
|---|---|---|
| Spine | @fuzdev/fuz_app/zod.ts | Walkers — unwrap wrappers (zod_unwrap_def, zod_get_base_type, zod_get_innermost_type, zod_unwrap_to_object); field helpers (zod_get_schema_keys, zod_get_field_schema); zod_extract_fields → ZodFieldInfo (name, base_type, required, has_default, nullable) |
| Spine | @fuzdev/fuz_app/primitive_schemas.ts | Uuid, Datetime, Blake3Hash, FactHashSchema (output FactHash, a fuz_schema Branded from @fuzdev/fuz_util/hash_schemas.ts) + factories (create_uuid, get_datetime_now, UuidWithDefault, DatetimeNow) |
| Cell helpers | @fuzdev/zzz/zod_helpers.ts | Path-transform schemas (PathWithTrailingSlash, PathWithoutTrailingSlash, PathWithLeadingSlash) |
| HTTP | @fuzdev/fuz_app/http/schema_helpers.ts | schema_to_surface() — JSON Schema via z.toJSONSchema() for snapshot-testable API surfaces |
| Testing | @fuzdev/fuz_app/testing/schema_generators.ts | Schema-driven test data — valid bodies, adversarial inputs |
z.strictObject() for all object schemas, including members of
z.discriminatedUnion() / z.union(). Exceptions, each with a comment
saying why: external data (z.looseObject(), or z.object(); e.g. GitHub
adds fields to API responses); client-consumed response/error schemas
(z.looseObject() so fields can be added without breaking clients);
protocol shapes the other side may extend per spec (JSON-RPC messages).-Schema suffix, no
snake_case..meta({description: '...'}), not .describe() — .meta() supports
additional keys (sensitivity).safeParse for external input, parse for fail-fast — §Validation at
Boundaries.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>;Schemas with .default() or .transform() have different input and output
types. z.infer<> is the output (post-parse); z.input<> is what callers
provide before defaults. Export z.input<> as FooInput when callers
construct partial instances via .parse() — constructor/factory parameters
(Cell instantiation, resource builders), config file shapes, form inputs,
partial data from storage. Skip it for internally-consumed schemas (env
loading, action spec satisfies).
// zzz — every Cell schema exports both
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 omittableFactory functions accept z.input<> minus the discriminant and parse to
validated output:
// PackageResource = ResourceBase.extend({type: z.literal('package'), …}); PackageResourceInput = z.input<…>
export const package_resource = (config: Omit<PackageResourceInput, 'type'>): PackageResource =>
PackageResource.parse({ type: 'package', ...config });// fuz_app/primitive_schemas.ts — Zod 4 validators + brand
export const Uuid = z.uuid().brand('Uuid');
export const Datetime = z.iso.datetime().brand('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');
// simple string + brand (no runtime format check)
export const ResourceId = z.string().min(1).brand<'ResourceId'>();Each pairs with export type X = z.infer<typeof X>. Dynamic defaults use
factories (Uuid.default(create_uuid), Datetime.default(get_datetime_now)).
For compile-time-only nominal typing without validation, use Flavored from
@fuzdev/fuz_schema/schema.ts (./fuz-util §Type utilities).
count: z.number().int().default(0),
thread_ids: z.array(Uuid).default(() => []), // factory for mutable defaults
port: z.number().optional(), // may be omitted — request fields callers skip
email: Email.nullable(), // present but null — DB columns, explicit "no value"
selected_thread_id: Uuid.nullable().default(null), // optional reference (Cell fields)
email: Email.nullish(), // null | undefined — sparingly; prefer optional/nullable
before: PreviousState.nullable().catch(null), // fallback when a *present* value fails — older stored shapes.catch() differs from .default() (missing field) — it's graceful
degradation for data written by an older schema version.
.shape reuses one field's validator without parsing the whole object
(PartJsonBase.shape.has_xml_tag.parse(value), or .default(true) to
override an inherited default in a subtype).
Transforms run at parse time; compose with .pipe():
export const PathWithTrailingSlash = z.string().transform((v) => ensure_end(v, '/'));
export const DiskfileDirectoryPath = PathWithTrailingSlash.pipe(DiskfilePath).brand('DiskfileDirectoryPath');z.uuid() / z.iso.datetime() (paired with brands); z.coerce.number()
(env vars); z.toJSONSchema(schema) (API surface snapshots);
z.prettifyError(error) (error messages); z.record(K, V) (env vars, resource
maps).
z.null() for HTTP input with no body (input: z.null() in route specs);
z.void() / z.void().optional() for action specs with no input or outputz.custom<T>(check?) embeds complex types without full validation — sparingly
(z.custom<z.ZodType>(...) in fuz_app action specs)z.instanceof(MyClass) — zzz action specs reference Cell instances this wayPrefer instanceof (schema instanceof z.ZodObject) and the public .def
getter (schema.def.type) — not ._zod.def (same value, internal API).
@fuzdev/fuz_app/zod.ts unwraps optional/nullable/default/transform/pipe
wrappers (zod_unwrap_def, zod_get_base_type, zod_get_innermost_type,
zod_unwrap_to_object) and reads object fields (zod_get_schema_keys,
zod_get_field_schema). zod_extract_fields returns a ZodFieldInfo per
field (required, has_default, nullable) — use it for
optional/nullable/default checks. fuz_util itself is zod-free.
z.discriminatedUnion() when a field determines the shape (better errors);
z.union() when there's no single discriminant or shapes mix with literals
(fuz_app's JsonrpcMessage; a union of an enum with z.literal(false) for
an opt-out). Members are z.strictObject().
Extensible enums use a factory that merges builtins with app-defined entries and validates at construction — fail at server init, not request time:
// fuz_app auth/role_schema.ts
const { Role, role_specs } = create_role_schema(
[{ name: 'teacher', description: '…', grant_paths: ['admin'] }], // ReadonlyArray<RoleSpec>
{ credential_types, scope_kinds, grant_paths } // optional registries for cross-axis validation
);
// Role: z.ZodType<string> for I/O boundaries; role_specs: ReadonlyMap<string, RoleSpec>Throws on invalid/duplicate names, builtin collisions, unregistered cross-axis entries.
.extend() adds or overrides fields, preserving strict mode:
export const RequestResponseActionSpec = ActionSpec.extend({
kind: z.literal('request_response').default('request_response'),
auth: RouteAuth, // four-axis {account, actor, roles?, credential_types?}
async: z.literal(true).default(true)
});Cell schemas (zzz) are CellJson.extend(): every field has .default()
(instantiation from partial JSON); .meta({cell_class_name}) links schema to
class for the registry; both FooJson and FooJsonInput (constructors and
set_json()) are exported; the base class is generic over the schema
(abstract class Cell<TSchema extends z.ZodType>) and validates with
this.schema.parse().
description documents the field and carries into z.toJSONSchema output;
other keys are domain-specific. fuz_app's SchemaFieldMeta
(@fuzdev/fuz_app/schema_meta.ts) adds sensitivity: 'secret', which masks
values in logs and API surface snapshots
(DATABASE_URL: z.string().min(1).meta({description: '…', sensitivity: 'secret'})).
safeParse for external input where invalid data is a normal condition —
route-spec input middleware (parsed data stored as
c.set('validated_input', result.data); dev_only(result.error.issues)
strips issue details from production responses), external API responses. Route
specs validate input via safeParse and output in DEV only.parse for fail-fast where invalid data is a bug or fatal misconfig —
internal assertions (RoleName.parse(name)), factories, Cell field updates.safeParse + custom throw when the error needs context — env loading
throws EnvValidationError(raw, result.error) carrying the raw values.safeParse + return null for optional data that may be absent or invalid
— an optional config file (runtime.warn(...); return null).Format errors with Zod 4's built-ins: z.prettifyError (multi-line, error
messages), z.treeifyError (nested, mirrors the schema), z.flattenError
({formErrors, fieldErrors}, forms).
| Convention | Correct | Wrong |
|---|---|---|
| Object schemas (internal) | z.strictObject({...}) | z.object({...}) |
| Object schemas (external data) | z.looseObject({...}) or z.object({...}) with comment | z.strictObject({...}) |
| Response/error schemas | z.looseObject({...}) — tolerates added fields | z.strictObject({...}) |
| Discriminated union members | z.strictObject({type: z.literal('a'), ...}) | z.object({type: z.literal('a'), ...}) |
| Descriptions | .meta({description: '...'}) | .describe('...') |
| Schema naming | const 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 paths | z.string().brand('MyId') | plain z.string() |
| HTTP/API input | schema.safeParse(data) | schema.parse(data) |
| Factories | schema.parse(data) | schema.safeParse(data) with unnecessary error handling |
| Env loading | safeParse + custom throw (better error context) | bare parse (loses raw values) |
| Optional config files | safeParse + return null | parse (crashes on missing file) |
| No input/output | z.void() or z.void().optional() | z.undefined(), omitting the field |
| Optional reference | Uuid.nullable().default(null) | Uuid.optional() (ambiguous undefined vs absent) |
| Complex embedded types | z.custom<MyType>() | hand-rolled validation |
| Key-value maps | z.record(z.string(), ValueSchema) | z.strictObject with dynamic keys |