VerificationLesson
Content validation
A rule written in a config file feels enforced, but it protects nothing until some code checks data against it. Give a Nuxt Content collection a strict schema, add a file with a misspelt field, and the build still passes. Terrarium closes that gap with a check of its own.
Know Zod's safeParse already? Skip to In Terrarium.
Outside the box · General
The concept
Zod is a TypeScript library for describing the shape of data in code. That description is a schema: z.object({ title: z.string() }) describes an object whose title is a string. TypeScript can infer a type from a schema, but types vanish when the code runs, so checking real data, such as a file read from disk, takes a call to the schema.
schema.parse(data) checks data and throws on failure. schema.safeParse(data) never throws: it returns { success: true, data } or { success: false, error }, and error.issues lists every problem with the path of the field it concerns. A .strict() object's ban on unknown fields, an enum's list of allowed values and a .refine check all take effect only inside one of these calls.
Other tools read a schema for other jobs. Nuxt Content converts each collection's schema to JSON Schema and uses that to choose the columns of the collection's table and to generate its TypeScript types. Its docs say schemas enforce data consistency, but its build never calls parse or safeParse on a file's data. A value outside an enum goes into the table as written, a field the schema doesn't declare lands in the catch-all meta column, and a .refine, which JSON Schema can't express, is dropped. What the schema does change is the column type: a z.date() field becomes a date column that keeps the day and loses the time.
So the check has to be a step you add: load each schema, parse every file it covers, and fail on any issue.
Full referenceZod 3 docs: .safeParse
In Terrarium
Many schemas in the Tenant manifests are strict. The Blog's pingbacks schema, for one, is .strict() and allows only the Blog's in fromPersona. Neither rule would stop pnpm build. How a manifest becomes collections is in Content collections, and how the build fills their tables is in Content database.
scripts/gate/validate-content-schema.ts runs the schemas instead. It calls the same loadManifests() and expand() from shared/expand.ts that content.config.ts calls: each collection it checks carries the very schema that built its table. Then it reads every file the collection covers, the frontmatter of a Markdown page or the whole of a YAML file, and parses it:
111 for (const col of cols) {
112 const schema = col.schema
113 if (!schema) continue // page Collection with no additional schema — nothing authored to check
⋯ // …
134 const res = schema.safeParse(data)
135 if (!res.success) {
136 messages.push(
137 ...res.error.issues.map((i) => {
138 const where = i.path.join('.')
139 return where ? `${where}: ${i.message}` : i.message
140 }),
141 )
142 }
It lists every failure with its collection key and file, and exits with an error if there is any. A file it can't parse at all is a failure too. pnpm validate:content runs it, then other checks, among them scripts/gate/validate-content-refs.ts, which checks what no single 's schema can see, such as a field that names another Document missing from its Space.
validate:content is a step of the , the part of the that runs on every change, before the build. It needs no build, and also runs alone.
Why it's built this way
- Nuxt Content doesn't parse, so a Gate step does. When this script was written, an out-of-enum Persona and an extra field on a strict Pingback both passed a clean build. So a Floor step parses every Document, and
pnpm validate:contentchecks a content edit in seconds, without a build. The cost: each file is read twice. scripts/gate/validate-content-schema.ts. - The same schemas, not a copy. A separate list of schemas for checking would drift from the ones that build the tables. So the script reuses the manifest expansion that
content.config.tsruns. The cost: it sees only collections declared in a manifest. scripts/gate/validate-content-schema.ts.
What to watch for
- The finding is tied to a version. It holds for the
@nuxt/contentversion Terrarium installs at this revision. Re-check it after an upgrade: a release that starts parsing content at build time would make this step a second check of the same thing. - A file that fails to parse vanishes from the build. Nuxt Content logs a warning, skips a file whose YAML or frontmatter it can't read, and the build goes on. Run
pnpm validate:content, which reports that file instead of skipping it. - Only the fields you declare are checked. A page's built-in fields, such as
titleandbody, come from Nuxt Content and aren't checked here, and apageCollection with no schema of its own is skipped. - A valid shape can still point nowhere. A field holding another Document's slug passes its schema as any string. That's the reference check's job, and it covers only the Tenants it was written for.
Try it in your project
Add a field your schema doesn't declare to one content file and run your build. If it passes, add a script that loads each collection's schema, calls safeParse on every file it covers, and exits non-zero on any failure. Run it in CI before the build.
Where next
- LessonContent collections
- LessonContent database
- LessonMerge gate
- LessonCross-tenant reads
- LessonZod refinements
Walkthroughs through this Lesson
- WalkthroughFrom file to browser
Last updated · Latest source on GitHub ↗