Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

VerificationLesson

Zod refinements

A config file is data, and data can be wrong. If nothing checks it, a typo in a key is silently ignored and the mistake turns up much later, somewhere far from the file. Terrarium checks each Tenant's manifest with a Zod schema before anything is built from it.

Know Zod refinements already? Skip to In Terrarium.

Outside the box · General

The concept

Zod describes the shape of data as a schema in TypeScript, then checks a value against it at runtime with parse or safeParse; a .strict() object also rejects keys it doesn't know. Content validation explains both.

A shape alone can't state every rule a config needs. Two tools cover the rest:

  • .refine and .superRefine. Some rules span several fields, such as "this field is required when that one has this value". A refinement is a function that runs after the shape has parsed. .refine returns true or false; .superRefine gets a ctx and may call ctx.addIssue as often as it needs, each issue with its own path and message.
  • z.custom. For a value Zod has no schema for, z.custom takes your own test function. With no function it accepts anything.

Two ways combine object schemas. A.extend({ … }) adds fields to A. A.merge(B) does the same with B's fields, and the result also takes B's policy for unknown keys.

Full referenceZod 3 docs: .superRefine

Looking inside

In Terrarium

Each declares its and in one . Content collections says what the checks catch; this Lesson shows how they're built. shared/manifest.ts holds the schema for one Collection entry:

shared/manifest.ts ↗77–131as of
77const zodSchemaField = z.custom<ZodObject<ZodRawShape>>(
78  (v) => typeof (v as { safeParse?: unknown })?.safeParse === 'function',
79  'schema is not a zod schema',
80)
⋯// …
93const collectionDefSchema = z
94  .object({
95    type: z.enum(['page', 'data'], { message: 'has an invalid type (expected "page" or "data")' }),
96    source: z.string().optional(),
97    schema: zodSchemaField.optional(),
98    kind: z.string().optional(),
99  })
100  .strict()
101  .superRefine((val, ctx) => {
⋯    // …
124    if (val.type === 'data' && val.schema === undefined && val.kind === undefined) {
125      ctx.addIssue({
126        code: z.ZodIssueCode.custom,
127        path: ['schema'],
128        message: 'data collection requires a schema (or a `kind` whose contract supplies one)',
129      })
130    }
131  })

A schema in a manifest is itself a Zod schema object, and z.custom passes any value with a safeParse method. The .superRefine adds one issue per broken rule that spans fields: a data Collection without a schema or a , a kind missing from shared/kinds.ts, or a kind whose type differs from the Collection's. A second .superRefine, on the whole set of Collections, ties the page type to the name pages.

validateManifest() runs safeParse and turns every issue into one line, led by its path when it has one. expand() in shared/expand.ts calls it on each manifest and throws with all of that manifest's lines at once. A manifest with a misspelt source key on a data Collection that has no schema stops the build with:

invalid manifest "demo":
- collections.notes: Unrecognized key(s) in object: 'sorce'
- collections.notes.schema: data collection requires a schema (or a `kind` whose contract supplies one)

A Collection that names a kind gets the kind's contract plus its own schema. effectiveSchema() in shared/expand.ts combines them by extending the local schema with the contract fields it lacks:

shared/expand.ts ↗111–114as of
111  const additions = Object.fromEntries(
112    Object.entries(contract.shape).filter(([field]) => !(field in local.shape)),
113  )
114  return local.extend(additions)

local.extend(additions) keeps the local schema's own unknown-keys policy and adds only the contract fields it doesn't declare, instead of merging the two: on a shared name, the local field wins. The doc comment on effectiveSchema() in shared/expand.ts records how contract.merge(local) fails when the two schemas come from two copies of Zod.

Why it's built this way

  • Checked before it's built. A manifest mistake found late fails far from its cause; Content collections gives this reason and its cost. ADR-0002 (docs/adr/0002-manifest-driven-config-generation.md).
  • A kind is a minimum. A Collection that names a kind keeps its own fields on top of the kind's contract; Cross-tenant reads gives the reason and its cost. ADR-0025 (docs/adr/0025-cross-tenant-catalog-and-collection-kinds.md).
  • Many problems per run. Fixed one safeParse at a time, a manifest with several mistakes takes several runs. So each rule adds its own issue with a path, and validateManifest() returns one line for each issue a run reaches. shared/manifest.ts.
  • A refinement, not a union. A plain optional schema can't require a data Collection to name a schema or a kind. A discriminated union could, but its built-in error doesn't fit the file's labelled messages, so a refinement on one object does it. shared/manifest.ts.
  • Extend, don't merge. A Tenant's schema and a kind's contract can come from two copies of Zod, where .merge breaks the Tenant's unknown-keys policy. So Terrarium extends the Tenant's schema instead, which keeps its own copy and policy. shared/expand.ts.

What to watch for

  • A refinement waits for the shape. Zod runs a refinement only once the object it sits on has parsed, and a failed z.custom test stops that parse too. On an entry with an invalid type or a schema that isn't a Zod schema, that entry's own kind and schema rules don't run, and neither does the pages rule for any Collection, so fixing it can bring new errors on the next run. An unknown key doesn't hold them back, since .strict() reports it and parsing goes on.
  • z.custom checks what you test, nothing more. The schema field passes any object with a safeParse method, whichever copy of Zod made it. That loose test is what lets two copies meet in effectiveSchema().
  • The type repeats part of the schema. CollectionDef in shared/manifest.ts is a TypeScript union that, like the schema, requires a data Collection to name a schema or a kind, and its kind field accepts only a name from shared/kinds.ts. The schema's other rules, such as lowercase slug names, a kind matching its Collection's type and the pages rule, run only at build. The schema-or-kind rule is written by hand in both, so a change to it needs the same change in both.

Try it in your project

If your app reads a config file, give it a strict schema and check it before you use it, listing every problem it finds:

import { z } from 'zod'

const config = z
  .object({ mode: z.enum(['static', 'server']), port: z.number().optional() })
  .strict()
  .superRefine((c, ctx) => {
    if (c.mode === 'server' && c.port === undefined) {
      ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['port'], message: 'a server needs a port' })
    }
  })

const result = config.safeParse(loadedConfig)
if (!result.success) {
  throw new Error(result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n'))
}

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/verification/schema-validation