Standard Schema

An entity is a Standard Schema. It is the smallest way to use Fougere: one package, one class, inside the framework you already have.

import { entity, primary, text, bool } from '@fougere/schema';

export default class Post extends entity({
  id: primary(),
  title: text({ min: 1, max: 200 }),
  draft: bool({ default: false }),
}) {}

Post['~standard'];
// → { version: 1, vendor: 'fougere', validate }

~standard is a static getter on the class, so Post itself is the schema — there is nothing to wrap and no adapter package to install.

Where it is accepted

Any library that reads the spec takes an entity directly: tRPC, Hono, TanStack Form, and — announced for NestJS 12 — a Standard Schema in @Body, @Query and @Param, as the successor to class-validator.

Derivations carry the interface too, which is what makes it useful at a route boundary — the shape a caller may propose is rarely the whole row:

export class PostDraft extends Post.pick('title') {}
export class PostPatch extends Post.omit('id').partial() {}

PostDraft['~standard'].validate({ title: 'Hello' });  // → { value: { title: 'Hello' } }

pick, omit, partial and extend all produce a class that is itself a Standard Schema. See Views.

The result shape

Validation is synchronous — the spec allows a promise, this vendor never returns one.

const result = Post['~standard'].validate({ id: 'abc', title: '' });

if (result.issues) {
  result.issues[0].message;  // 'String is too short (0 < 1).'
  result.issues[0].path;     // [{ key: 'title' }]
} else {
  result.value;              // the validated, decoded row
}

path is a list of { key } segments, per the spec. An error about the input as a whole — a null, a non-object — carries no path at all, rather than an empty one.

The value is the decoded row, not the raw input: a field declaring a Date comes back as a Date, because the boundary axis runs its inbound conversion as part of the verdict.

Defaults are not filled

One behaviour differs from most vendors, and it is the doctrine rather than an omission.

const { value } = Post['~standard'].validate({ id: 'abc', title: 'Hello' });
'draft' in value;  // false — even though draft declares { default: false }

The validator decides whether an absent draft is legal. It is, so the input passes. But filling the hole belongs to the storage, at the point of persistence — a value that would appear from nowhere during validation is a value the caller never sent and cannot be held to. Validation and realization stay separate, so the same input validated twice never gains a field on the way through.

If you are replacing a Zod schema whose .default() you relied on at the route, that line has to move to wherever the row is written.

What does not cross

Standard Schema is one function: validate(value) → value | issues. It carries the shape axis and nothing else.

crosses ~standardstays home
shape — the JSON Schema keywords✅
role — primary, relations, uniquethe DDL, the GraphQL type
lifecycle — who writes, and whenthe storage that stamps it
boundary — direction, conversionspartly — inbound decode runsVisibility.input / Visibility.output

So a consumer outside Fougere gets the validator, in the browser or on its server, with the same rules the façade applies. It does not get the table, the surfaces or the form contract — those read Post.getFields(), which is a Fougere call.

For the serializable form of the same declaration — the one that crosses a process or a language rather than a library boundary — see the identity card.

Next: How a schema moves — what a shape was, and the migration that follows.

Built with Fougere and Nuxt, hosted on GitHub Pages.