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 judged, 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 judgement.

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 judge 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. Judge and realize stay separate, so the same input judged 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 ORM that stamps it
boundary — direction, conversionspartly — inbound decode runsinputFields / outputFields

So a consumer outside Fougere gets the judge, 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: Handlers — operations, binding rules, output scoping.

Built with Fougere — this site runs on the framework it documents.