The identity card

A TypeScript class cannot be serialized directly. Its card is a JSON document that describes the schema and lets a consumer rebuild a validation class.

The two are duals. ~standard hands the live judge to a library in the same process; the card is the serializable description of the same declaration, for exchanges across processes or languages.

import { describe, reconstruct } from '@fougere/schema';

const card = describe(Post);       // schema → JSON document
const Rebuilt = reconstruct(card); // document → living schema (validate + from)

describe produces the canonical document used by adapters. reconstruct rebuilds the schema at the consumer, which can then validate data locally.

State the shape of a row and the rebuilt schema is a class in the full sense — one name carrying the judge and the type of what it judges, exactly like class Post extends entity({…}) {}. This is what fougere sync writes:

class Post extends reconstruct<{ id: string; title: string }>(card) {}

The structure

The card is a valid JSON Schema document. The shape axis is that vocabulary, so its keywords sit at the field's top level; the three other axes live under the x-fougere extension key.

{
  "title": "post",
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "x-fougere": {
        "role": { "primary": true },
        "lifecycle": { "create": { "generate": "cuid2" } }
      }
    },
    "title": { "type": "string", "minLength": 3, "maxLength": 120 },
    "status": { "type": "string", "enum": ["draft", "published"] },
    "publishedAt": {
      "type": ["string", "null"],
      "format": "date-time",
      "x-fougere": { "lifecycle": { "update": "forbidden" } }
    },
    "secret": { "type": "string", "x-fougere": { "boundary": { "out": "closed" } } }
  },
  "required": ["title", "status"],
  "x-fougere-version": 1,
  "x-fougere-vendor": "fougere"
}

A nullable field folds into the union type: ["T", "null"] — nullability is shape, hence plain JSON Schema. An embedded object (json(Entity)) carries its nested properties and required, themselves shape-only.

The three axes under x-fougere

The values under x-fougere are names and JSON structures. No separate descriptor is required.

keyvalueswhat it states
role.primarytrueidentity — the system owns it
role.unique[["slug"], ["listId","docId"]]the unique constraints this field is a member of — one member list each, members named. A lone field is the degenerate case: a constraint of one. The DDL emits them
role.indextruestorage constraint — the DDL emits it
role.relation{ to, kind: 'one'|'many', onDelete? }to is a name, not an inlined schema
lifecycle.create{ value } | 'now' | { generate } | 'optional'who writes at creation, and when
lifecycle.update'now' | 'forbidden'who writes on update
boundary.in'closed' | { decode }read-only, or a named inbound conversion
boundary.out'closed' | { encode }write-only, or a named outbound conversion

An absent key means open: no lifecycle.create means the caller must supply the value; no boundary means identity in both directions.

Only an explicitly declared boundary is emitted. Behaviour inferred from the shape, such as decoding a format: date-time field into a Date, is derived again during reconstruction.

role.unique always names its members in the document, including a single-field constraint ([["slug"]]). In memory a lone unique() does not yet know its key; describe resolves it from the object passed to entity({...}). A compound constraint such as (listId, docId) appears in full on each member.

required semantics

In a Fougere card, required has a narrower meaning than in JSON Schema alone.

required lists the names a caller must supply at creation — those no lifecycle.create rule answers the absence of, and that are not a many relation (absent → []). It is narrower than JSON Schema's required, which is context-free and answers "what is always present when read?".

A nullable field therefore remains required: the caller may supply null, but may not omit the key. Presence and nullability remain separate concepts.

The bundle — several entities, relations included

describeSet produces a self-contained $defs document. Each entity uses its lowercased name, matching the value carried by relation.to.

const bundle = describeSet({ post: Post, author: Author });
const { post, author } = reconstructSet(bundle);

A relation is a pointer rather than an inlined sub-schema: Post → Author → Post produces two references instead of recursive nesting. reconstruct keeps the names; reconstructSet resolves them to the target entities expected by adapters.

Serialization limits

A card can contain only serializable data:

  • a relation becomes a name. reconstructSet can resolve the () => Author thunk within a bundle.
  • a custom generator or conversion is referenced by name, then resolved against the consumer's registry. An unknown name produces a local unknown generator error.

The axes therefore do not accept JavaScript closures, which cannot be serialized.

rpc.discover — a whole app's card

A host answers the reserved rpc.discover operation with what it hosts. Each frond publishes two lists, and they are duals: what you may call, and what leaves on its own.

{
  "fronds": [{
    "name": "blog",
    "doors": [{
      "name": "post",
      "ops": [
        { "name": "list",     "kind": "query",   "output": { /* a card */ } },
        { "name": "findById", "kind": "query",   "output": { /* a card */ } },
        { "name": "create",   "kind": "command", "input": { /* a card */ },
                                                 "output": { /* a card */ } },
        { "name": "delete",   "kind": "command" },
        { "name": "publish",  "kind": "command", "description": "Make the post public." }
      ],
      "schema": { /* a card */ }
    }],
    "facts": [
      { "name": "postPublished", "schema": { /* a card */ } }
    ]
  }]
}

An operation always says its name and its kindquery reads, command writes, which is the same call REST turns into GET versus POST. The rest is present when there is something to say: input and output when the contract names a view, and description when the method carries a doc sentence. delete names neither view above because a boolean is not a shape.

schema is optional on both lists, and its absence says something different on each. A door with no schema stores nothing — a health check, a computation, a search across several shapes. A fact with no schema announces a type the host does not store.

Why facts are a separate list

Hosting means answering: an entity with no façade is deliberately absent from doors, or the card would publish the shape of tables nobody can reach — the auth tables, typically.

A fact is the opposite case. It carries no operation either, but someone wrote that it leaves (Emit<PostPublished>), so publishing its shape honours a statement instead of leaking one. Without this list a fact stopped at the repository boundary: colocation gives the contract, remotes: gives only the location, and across two repositories there is no colocation. The subscriber had to hand-copy the emitter's declaration.

facts is the same on every surface — a door has an audience, a fact does not.

The doublure router reads doors only, and uses them to associate entities with the addresses declared in remotes. A fact is not routable: nobody calls it, it arrives.

Writing a Frond elsewhere

None of this assumes TypeScript. A frond written in another language joins the topology if it honours three things:

  1. the wirePOST /_fougere/call, JSON-RPC 2.0, method = "entity.op"; see The gradient.
  2. the cardrpc.discover returns the envelope above, x-fougere-version: 1.
  3. the error vocabulary — a whole FougereError in error.data, its code a semantic code; the -32000 integer stays the JSON-RPC spec's.

The consumer then rebuilds the schema and validates data locally with the same rules.

demos/rust-frond/ is one, in Rust — one entity, three operations, no entity({...}) class anywhere.

Next: Handlers — operations, binding rules, output scoping.

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