Objects
Define objects with the fluent builder, infer their types, and register them.
An object is a typed data model — the schema equivalent of a table. Objects are declared in code with the object() factory, attributes are chained on, and the result is registered so the platform syncs it automatically (API, validation, permissions, views all derive from it).
import { object, relation, status, text } from "@stndrds/schema";
export const CONTACT = object({ name: "contacts", label: "Contact" })
.icon("user")
.pluralLabel("Contacts")
.labelExpression("{{ firstName }} {{ lastName }}")
.attribute(text({ name: "firstName", label: "First Name" }).required())
.attribute(text({ name: "lastName", label: "Last Name" }).required())
.attribute(text({ name: "email", label: "Email" }).email())
.attribute(
relation({ name: "company", label: "Company" })
.to("companies")
.bilateral({ object: "companies", attribute: "contacts" }),
)
.attribute(
status({ name: "status", label: "Status" }).options([
{ value: "lead", label: "Lead", color: "yellow", group: "idle" },
{ value: "active", label: "Active", color: "green", group: "in_progress" },
{ value: "churned", label: "Churned", color: "red", group: "finished" },
]),
);Three rules apply to every object:
nameis kebab-case, 63 characters max ("contacts","order-items").labelExpressionis required. It controls how records display across the platform.idis optional. Set it when fixtures or tests need a deterministic identifier.
Builder methods
| Method | Effect |
|---|---|
.attribute(builder) | Add one attribute. Each call refines the inferred record type. Pass the builder directly — no .build() needed. |
.attributes(list) | Add several attributes at once. These do not appear in the inferred record type — prefer chaining .attribute(). |
.labelExpression(template) | Required. Template that renders a record's title. See Label expressions. |
.description(text) | Object description. |
.pluralLabel(text) | Plural display label. |
.icon(name) | Object icon. |
.order(n) | Sidebar display order. |
.sealed() | Make the code definition exhaustive: on the next sync, any attribute not declared in code is deleted, along with the values stored in it — including attributes end-users added through the UI. See Sealing an object. |
.tolerate(names) | On a sealed object, keep these attribute names even though the code doesn't declare them. standards pull then treats them as expected instead of reporting them as drift. |
.runtime() | Mark the object as editable and deletable at runtime (system: false). Use it for fixtures and seeds only. |
.embeddingExpression(template) | Text template used to compute the record's search embedding. Same syntax as labelExpression, so it must interpolate at least one attribute. Omit to opt the object out of vector search. |
.documentLayout(layout) | Folder conventions for the object's documents ({ variants?, presets? }). |
.migration(version, cb) | Declare a schema migration (versions start at 2 and must be sequential). |
.metadata(object) | Merge arbitrary metadata. |
.build() | Return the final ObjectDefinition. Rarely needed: registry.register() accepts builders as-is. |
Sealing an object
By default an object is extensible: end-users can add custom attributes through the UI, and sync leaves them alone. It only removes system attributes — ones your code declared and then dropped.
.sealed() changes that. On a sealed object, sync treats the code definition as the complete list and deletes every attribute missing from it — including custom ones users created, and the values those attributes held. Use it when an object must stay exactly as written, and use .tolerate() to keep specific names that predate the seal:
object({ name: "contacts", label: "Contact" })
.labelExpression("{{ firstName }}")
.sealed()
.tolerate(["legacyRef"]);standards pull reports unexpected attributes on a sealed object as drift and exits non-zero, so it works as a CI gate — run it before sync rather than discovering the deletion afterwards.
Label expressions
A label expression interpolates attributes with {{ attribute }}. Pipes transform the value: UPPER, LOWER, capitalize, and trim take no argument; prefix:"…", suffix:"…", wrap:"…", and default:"…" take one.
.labelExpression('{{ title }}{{ status | prefix:" · " }}');Type inference
The whole point of defining schemas in TypeScript: record types are derived, never written by hand.
import type {
ExtractRecord,
ExtractRecordInput,
ExtractRecordUpdate,
} from "@stndrds/schema";
type Contact = ExtractRecord<typeof CONTACT>;
// { firstName: string; lastName: string; email?: string;
// status?: "lead" | "active" | "churned";
// id: string; createdAt: Date; updatedAt: Date; metadata: Record<string, unknown> }
type ContactInput = ExtractRecordInput<typeof CONTACT>; // for create — no system fields
type ContactUpdate = ExtractRecordUpdate<typeof CONTACT>; // for update — all optional| Helper | Produces |
|---|---|
ExtractRecord<T> | Full record with system fields and custom-attribute support. |
ExtractRecordStrict<T> | Same, without custom attributes. |
ExtractRecordInput<T> | Create payload — omits system fields (id, createdAt, updatedAt, createdBy, lastUpdatedBy). metadata stays, since you set it yourself. |
ExtractRecordInputStrict<T> | Create payload without custom attributes. |
ExtractRecordUpdate<T> | Update payload — everything optional, no system fields. |
ExtractRecordUpdateStrict<T> | Update payload without custom attributes. |
InferAttributeValue<A> | Value type of a single attribute. |
InferQualifiedProps<T> | Props shape of a qualified relation or document. |
Zero-import alternative: every builder exposes .$infer — CONTACT.$infer.record, CONTACT.$infer.create, CONTACT.$infer.update (and on attributes, .$infer.value and .$infer.definition).
Object builders also implement Standard Schema v1, so they plug directly into React Hook Form, tRPC, and anything else that speaks the spec.
Registries
Objects, views, and forms go into three global registries. The server module reads them at startup and syncs the schema automatically.
import { formRegistry, registry, viewRegistry } from "@stndrds/schema";
registry.register(CONTACT);
registry.register(COMPANY);
viewRegistry.register(CONTACT_DETAIL_VIEW);
formRegistry.register(ONBOARDING_FORM);registry.register accepts a builder, a built definition, or an array of either. To inspect what is registered, use getByName, getByNameOrThrow, getAll, listNames, has, size, and summary().
Migrations
Renaming an attribute or changing its type is ambiguous from a plain diff, so declare it explicitly with .migration(version, cb):
object({ name: "contacts", label: "Contact" })
.labelExpression("{{ firstName }} {{ lastName }}")
.migration(2, (m) => m.renameAttribute("fullName", "displayName"));Versions start at 2 and must be sequential — .build() rejects gaps. See Migrations for the full set of operations, what's applied automatically versus destructively, and how boot-time sync resolves each case.
Validation helpers
You can validate records outside the server, against the same rules the API enforces:
import { validateObjectOrThrow, validateDraft } from "@stndrds/schema";
validateObjectOrThrow(CONTACT.build(), values); // throws on invalid input
const result = validateDraft(CONTACT.build(), partialValues); // lenient, for draftsFor finer control, import per-type Zod validators from @stndrds/schema/validators/*.