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 ~standard | stays home | |
|---|---|---|
shape — the JSON Schema keywords | ✅ | |
role — primary, relations, unique | the DDL, the GraphQL type | |
lifecycle — who writes, and when | the ORM that stamps it | |
boundary — direction, conversions | partly — inbound decode runs | inputFields / 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.