Bring your schema

Adding Fougere to an app leaves your host alone: your routing, your pages, your rendering and your other server routes are untouched. Your domain is different. The entity is the one thing Fougere must own, because it is the one thing everything else is derived from.

So adopting Fougere has exactly one cost, and this page is it: you declare your model in its vocabulary. What follows is how to keep that cost small, bounded, and paid one entity at a time.

One entity at a time

You do not convert a domain. You convert an entity.

fougere migrate --apply only touches the tables your entities declare. It creates them and adds what they lack; it renames or drops a column only when a frozen version of that entity says the field moved or left; and it refuses a type change rather than rewriting one. A table no entity declares is never touched. A Fougere entity therefore creates its own table beside the ones your current storage manages, in the same database, and your existing rows are out of its reach.

The case to watch is a name collision: an entity whose table already exists gets addColumn proposed on your table rather than a fresh one. Rename one of the two, or hand the table over on purpose.

The mapping

PrismaFougere
Stringtext() — with { min, max } when you have a rule
String?optional(text()), or nullable(text()) if the column is truly nullable
Int, Floatnumber()
Booleanbool()
DateTimedate()
Jsonjson()
String @id @default(uuid(7))primary()
String @id @default(cuid(2))primary({ generate: 'cuid2' })
@uniqueunique(field)
@@unique([listId, docId])unique: [['listId', 'docId']], in entity()'s second argument
@indexindexed(field)
a foreign key + @relationref(Author)
the reverse side of that relationmany(Post) — a role, no column
@default(now())created()
@updatedAtupdated()
an enum + @default("draft")oneOf('draft', 'published', { default: 'draft' })
@db.VarChar(160)text({ max: 160 })

The last row is the whole thesis in one line. @db.VarChar(160) names a dialect; text({ max: 160 }) names the rule, and the dialect is derived from it — along with the CHECK, the JSON Schema, the GraphQL type and the form's maxlength.

From Zod

A field is anything that states a shape, and a shape is JSON Schema. Zod writes JSON Schema, so a z.object you already have can give an entity its fields, with no package in between:

import { z } from 'zod';
import { entity, Field, optional, primary, ref, type Shape } from '@fougere/schema';

const PostInput = z.object({
  title: z.string().min(3).max(80),
  body: z.string().optional(),
  tags: z.array(z.string()).max(3),
});

function fieldsOf(object: z.ZodObject) {
  const { properties = {}, required = [] } = z.toJSONSchema(object);

  return Object.fromEntries(
    Object.entries(properties).map(([key, shape]) => {
      const field = new Field({ shape: shape as Shape }, key);

      return [key, required.includes(key) ? field : optional(field)];
    }),
  );
}

export default class Post extends entity({
  id: primary(),
  authorId: ref(Author),
  ...fieldsOf(PostInput),
}) {}

Zod states the shape. The key, the relation and who writes what stay in Fougere's words, beside it. A refusal reads as any other: title: String is too short (2 < 3).

A shape admits every keyword the validator judges, so .positive(), z.literal() and .multipleOf() pass as Zod writes them. Two things are refused when the field is built, naming the keyword: a union of types, which no column can hold, and .default() — a default is the lifecycle's, not the shape's. .transform() and z.date() never reach Fougere: Zod itself cannot write them as JSON Schema. Use oneOf(…) and date() for those fields.

What has no equivalent

A Prisma model says what the column is. That is one of four axes. The other three have no column to map from, and they are why the conversion is not mechanical:

  • lifecycle — who writes the value, and when. created() is not @default(now()) with a nicer name: it also states that the value cannot be rewritten on update. readOnly(oneOf(…)) states that the client never supplies it at all.
  • boundary — who may see it. writeOnly() for the password that goes in and never comes out. readOnly() for the field the server stamps.
  • role — what part it plays. primary, ref, many, unique — read by the DDL for keys and constraints, and by the card for relations.

You enforce all three today. They live in a service method, a DTO, a select: clause, an @Exclude() decorator, a review comment. They are real rules with no single home.

The conversion is not mechanical because you are writing down, for the first time, rules you were already applying.

That is the work, and it is also the return: from that declaration the validator, the table, the GraphQL type, the REST route and the form contract are all projections — see Entities for the four axes in full.

What stays where it is

  • Your storage. Fougere claims no ownership of it. In the routes you have not converted, keep querying exactly as you do now.
  • Unconverted tables. Nothing at boot requires the whole database to be declared. The scan finds what is in entities/, and is silent about the rest.
  • Raw statements on a converted entity. storage.client is the escape hatch on the storage port — the underlying Kysely instance for the SQL adapter. It is the one path the validator does not sit on, and it is not scoped to the entity; see Storage.

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 facade 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.

Built with Fougere and Nuxt, hosted on GitHub Pages.