Nakodo has zod 4.6.5 in it, imported by 27 files, declaring 70 z.object schemas. When I counted those files I expected one population. There are two, they do opposite jobs, and I can tell them apart without reading them: the input schemas call safeParse and the model schemas never call anything at all.

Here is the split, measured rather than remembered.

Side one: nine files, 39 safeParse calls

Every Server Action in the app takes its argument as unknown. Seventeen signatures look like this:

export async function replySupportAction(input: unknown): Promise<ActionResult> { 

unknown, not a typed object, because an action is a POST endpoint. The types on the other side of it are a suggestion from the client, and the client is not ours. So the first thing past the session check is a schema:

const replyInput = z.object({ reference: z.string(), body: z.string().trim().min(SUPPORT_LIMITS.messageMin).max(SUPPORT_LIMITS.messageMax), }); const parsed = replyInput.safeParse(input); if (!parsed.success) return fail("Write your reply and send it again."); 

39 of those calls, in nine files: the campaign actions (18 of them), settings, support, the two admin action files, the auth actions, the notice builder, the place targeting parser and one in auth.ts. All 39 are safeParse, never parse. A failure here is not an exceptional condition, it is Tuesday: somebody left a field blank, or a bot posted nonsense at an endpoint. The function returns a result object and the form shows a line of text.

Side two: fifteen files, 26 schemas, no parse call anywhere

The other population, 15 files and 31 schemas, describes what a language model must return. Comment analysis, business analysis, the campaign brief, keyword angles, proposal suggestions, reply classification, the spam check, and so on. Grep those files for parse and you get nothing, which looked like a bug in my grep until I remembered where the parsing lives:

const response = await openai().responses.parse( { // ... text: { format: zodTextFormat(opts.schema, opts.schemaName) }, }, ); // ... if (response.output_parsed == null) { 

One call, in src/lib/ai.ts, shared by every AI task in the app. The schema goes to the provider as the output format, the SDK validates the reply against the same schema, and our code reads output_parsed. So the model side has 31 schemas and exactly one parse site, and a schema failure there raises rather than returns, which is correct: a model that cannot produce its own declared shape is not a user mistake, it is a retryable fault, and the wrapper's job is to retry it and eventually give up.

That is the whole asymmetry, and it falls out of who is at fault when validation fails:

input schemas model schemas
files 9 15
z.object count 36 31
call safeParse, 39 times none, the SDK parses once
failure is a sentence on the form an exception and a retry
optional fields 5 3
nullable fields 20 8

The optional and nullable rows are the part that surprises people who have only used zod on one side. OpenAI's Structured Outputs does not support optional properties, so a model schema cannot say "this field may be absent", only "this field may be null". Three .optional() against eight .nullable() on that side is not a style choice, it is a provider constraint showing up in a type. On the input side the same pair of modifiers is an ordinary design decision, and it goes the other way: 20 nullable against 5 optional, because a form field that was left blank arrives as an empty value rather than not arriving at all.

Thirteen places read the error, and none of them build a field map

This is the bit I would defend hardest if someone reviewed the code.

Thirteen call sites look inside the zod error. Every one of them reads the first issue, and only ever its message or its path:

if (!parsed.success) return fail(parsed.error.issues[0].message); 
const field = parsed.error.issues[0]?.path.at(-1); 
parsed.error.issues[0]?.path[0] === "name" ? "Give it a name of up to 100 characters." : "That address is too long." 

Nothing in the repository calls flatten(), treeifyError, or anything else that turns an error into a map of fields to messages. There is no errors[field] anywhere, and no component that renders one. Every form in the app has a single place for a single sentence.

That is a deliberate position: if somebody submits a form with three problems, telling them about one, at the top, in a sentence they can act on, is kinder than painting the whole form red. It is also a confession that the forms are small. The widest input schema in the app has seven keys and the next widest has six, and when a form grows past that, this decision will need revisiting rather than defending.

The other half of the decision is that when the message comes from the schema, the schema is holding product copy:

const credentials = z.object({ email: z.email("Enter a valid email address"), password: z.string().min(8, "Use at least 8 characters for the password"), }); const newCredentials = credentials.extend({ email: credentials.shape.email.refine( (email) => !isDisposableEmail(email), "That's a temporary email address. Use a permanent one, so you don't lose the account or miss a reply.", ), }); 

Ten custom messages are written like that, inside the schemas, in the voice of the rest of the product. Where the sentence depends on more than one thing, the call site reads the path instead and picks the sentence itself, which is the pattern in the third snippet above. Those two options are the entire error strategy.

newCredentials also shows the one idiom I would copy into any project: a base schema, and a stricter one derived from it with .extend and .refine. Signing in uses credentials. Signing up uses newCredentials, which refuses throwaway addresses. Somebody who signed up with a throwaway address before we started refusing them can still sign in, confirm their email and reset their password, because the refusal lives only on the schema that creates accounts. One .extend() expresses "this rule applies to new things only" better than any if would.

Four counts that each tell a small story

3 z.uuid(). Nearly every id in the app is a uuid, and almost none of them are checked by zod, because they are checked by one function instead:

const uuid = z.uuid(); export const isUuid = (v: unknown): v is string => uuid.safeParse(v).success; 

A type predicate, one compiled schema, called from the route helpers. A page or action that receives an id runs that before any query, because Postgres raises on a malformed uuid rather than returning no rows, and an unhandled database error is a 500 where a 404 was wanted.

2 z.coerce. Both are numbers arriving from FormData, where everything is a string. Nothing else in the app coerces, which keeps "the type was wrong" and "the type needed converting" as different events.

1 z.record. Daily sending allowance can be split between campaigns, so the whole allocation posts as one JSON field:

const shareMap = z.record(z.uuid(), z.number()).refine((v) => Object.keys(v).length <= 200); 

The refinement is the interesting half. A record with uuid keys is unbounded by default, and the body of a POST is whatever somebody sends, so without that line a request could arrive with two hundred thousand keys and we would validate every one of them cheerfully before noticing.

1 .transform(). In a codebase with 70 schemas, exactly one schema changes the shape of what it validates. Validation and transformation stay separate here, which makes the schemas readable as documents: this is what arrives, nothing more.

If you want to see the boundary from outside

The forms that use side one are behind the login, but the pricing page and the sign-up link on it will show you the one error surface the whole app shares: one sentence, in one place, above the button. The output of side two is on how it works, described in product terms rather than schema terms, which is the right way round.

Two populations, one library, and the thing that distinguishes them is not what they validate but who is to blame when validation fails. That question is worth asking of every schema you write, because the answer decides whether you want a result or an exception, and almost everything else follows from it.