Zod schema conventions for @fuzdev TypeScript/Svelte projects.
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
reflectionzod_to_schema_properties), exportable as
JSON Schema (z.toJSONSchema)Datetime), IDs (Uuid),
paths (FilePath) eliminate serialization friction.extend() for hierarchies, .brand() for domain
safety, .default() for partial construction| Layer | Module | Capabilities |
|---|---|---|
| Foundation | @fuzdev/fuz_util/zod.ts | Schema 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.ts | Uuid, Datetime branded types and factories (create_uuid, get_datetime_now, UuidWithDefault, DatetimeNow) |
| Cell helpers | @fuzdev/zzz/zod_helpers.ts | Path-transform schemas (PathWithTrailingSlash, PathWithoutTrailingSlash, PathWithLeadingSlash) |
| CLI | @fuzdev/fuz_app/cli/args.ts, help.ts | Schema-validated CLI arg parsing; schema-driven help text generation |
| HTTP | @fuzdev/fuz_app/http/schema_helpers.ts | schema_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.ts | Schema-driven test data generation — valid bodies, adversarial inputs |
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)..meta({description: '...'}) — not .describe(). .meta() supports
additional keys (aliases, sensitivity).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.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: 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()}),
]);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.
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.
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.
// .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 shapesUse .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);// 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');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 validationz.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 valuez.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 valuesWhen 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.defSee @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).
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>;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.
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(/* ... */)};
};.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),
});Every Cell class has a schema built with CellJson.extend() (see ChatJson
example in Input vs Output Types above). Cell schema conventions:
.default() for Cell instantiation from partial JSON.meta({cell_class_name}) connects the schema to its Cell class for the
registryFooJson (output, fully validated) and
FooJsonInput (input, defaults omittable for constructors and set_json())abstract class Cell<TSchema extends z.ZodType> — validates internally
with this.schema.parse().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'),
});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.
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.
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 updatesafeParse + 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;
}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| 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) |
| CLI args/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 |