Attributes
The 16 attribute types and every builder method available on them.
Attributes are declared with factory functions, one per type. Every factory takes the same config — { name, label } — and returns a fluent builder that you chain and pass to object().attribute(). The object builder builds them for you, so you never need to call .build() on an individual attribute.
import { object, text, number } from "@stndrds/schema";
const PRODUCT = object({ name: "products", label: "Product" })
.labelExpression("{{ name }}")
.attribute(text({ name: "name", label: "Name" }).required())
.attribute(number({ name: "price", label: "Price" }).decimal(2).min(0));There are 16 types, in four families:
| Family | Types | What they hold |
|---|---|---|
| Values | text, richtext, number, checkbox, date, phone, currency, location | A value typed in or picked by the user. |
| Choices | select, multiselect, status | One or more options you declare up front. |
| References | relation, user, document | A pointer to another record, person, or document. |
| Computed | formula, rollup | A read-only value the server derives. |
Methods available on every builder
| Method | Effect |
|---|---|
.required() / .optional() | Toggle the required flag. Attributes are optional by default. formula and rollup are always read-only: .required() throws on them, and .defaultValue() doesn't apply. |
.hidden() | Hide the attribute in the UI. |
.placeholder(text) | Input placeholder. |
.description(text) | Help text shown with the field. |
.icon(name) | Attribute icon. |
.order(n) | Display order. |
.defaultValue(value) | Default value, typed per attribute. |
.metadata(object) | Arbitrary metadata carried on the definition. |
.runtime() | Mark the attribute as not owned by code, so users can edit or delete it from the UI. Attributes declared in a schema file are code-owned by default and locked against runtime changes — keep it that way unless you are seeding demo or test data. |
Every builder also exposes .$infer ({ value, definition }) for zero-import type extraction, and implements Standard Schema v1 like the object builder.
Several methods take values from a fixed set rather than free strings: .icon() takes an icon name, Option.color a palette color id, .defaultCountry() / .allowedCountries() ISO-3 country codes, .allowedCurrencies() currency codes, and document().accepts() MIME types. Your editor autocompletes each of them.
Text
text({ name: "email", label: "Email" }).email().required();
text({ name: "website", label: "Website" }).url();
text({ name: "slug", label: "Slug" }).slug();
text({ name: "bio", label: "Bio" }).multiline().maxLength(500);| Method | Effect |
|---|---|
.minLength(n) / .maxLength(n) | Length bounds. |
.pattern(source) | Custom validation pattern, as a regex source string. |
.email() / .url() / .slug() | Built-in formats. Enforced on write, and shown as the matching preset in the attribute editor. |
.multiline(value?) | Render as a textarea. |
Value type: string.
When a text attribute is .required(), writes reject the exact empty string "". Whitespace-only strings remain valid unless another text constraint rejects them. Existing rows that already contain "" remain readable and are validated the next time they are written.
Richtext
richtext({ name: "notes", label: "Notes" });No type-specific methods beyond the base. Value type: semantic markdown string, which the editor parses to and from its own document format.
Number
number({ name: "quantity", label: "Qty" }).integer().min(0);
number({ name: "price", label: "Price" }).decimal(2);
number({ name: "score", label: "Score" }).max(5).renderAs("rating");| Method | Effect |
|---|---|
.min(n) / .max(n) | Bounds. |
.integer() | Integer constraint. |
.decimal(decimals?) | Decimal places. |
.percentage() | Percentage semantics and rendering. |
.renderAs("number" | "rating") | Display style. |
Value type: number.
Checkbox
checkbox({ name: "active", label: "Active" }).defaultValue(true);No type-specific methods. Value type: boolean.
Date
date({ name: "dueDate", label: "Due Date" }).minDate("2024-01-01");
date({ name: "meeting", label: "Meeting" }).includeTime().timeFormat("24h");
date({ name: "period", label: "Period" }).endDate(); // value becomes { start, end }
date({ name: "createdOn", label: "Created On" }).defaultValue("today");| Method | Effect |
|---|---|
.format(format) | Date display format: "short", "long", "full", or "relative". |
.minDate(date) / .maxDate(date) | Bounds. Accepts an ISO date or the literal "today". |
.defaultValue(date) | Default date value. Accepts an ISO date or the literal "today", resolved at form-load or record-creation time to the viewer's calendar day (or a full UTC instant when .includeTime() is enabled). |
.endDate() | Turn the value into a { start, end } range — the inferred type follows. |
.includeTime() | Add a time component (stored ISO UTC). |
.timeFormat(format) | Time display format: "12h" or "24h". |
Value type: string | Date, or DateRangeValue ({ start: string; end: string | null }) after .endDate() — an open-ended range leaves end null.
Phone
phone({ name: "phone", label: "Phone" }).defaultCountry("FRA");| Method | Effect |
|---|---|
.defaultCountry(iso3) | Default country for input and formatting. |
Value type: Phone (structured number + country).
Currency
currency({ name: "amount", label: "Amount" })
.defaultCurrency("EUR")
.allowedCurrencies(["EUR", "USD"])
.allowNegative();| Method | Effect |
|---|---|
.defaultCurrency(code) | Default currency. |
.allowedCurrencies(codes) | Restrict selectable currencies. |
.allowNegative(value?) | Permit negative amounts. |
Value type: Currency (amount + currency code).
Select, multiselect, and status
The three choice types share the same option API. Pass the full list to .options() and the builder captures each value as a literal, so the record type infers to a union of your option values rather than plain string.
select({ name: "category", label: "Category" }).options([
{ value: "hardware", label: "Hardware" },
{ value: "software", label: "Software", color: "blue" },
]);
multiselect({ name: "tags", label: "Tags" }).options([
{ value: "urgent", label: "Urgent", color: "red" },
{ value: "review", label: "Review", color: "blue" },
]);
status({ name: "status", label: "Status" }).options([
{ value: "lead", label: "Lead", color: "yellow", group: "idle" },
{ value: "active", label: "Active", color: "green", group: "in_progress" },
{ value: "closed", label: "Closed", color: "blue", group: "finished" },
]);| Method | Effect |
|---|---|
.options(list) | Set all options at once (preserves the literal value union). |
.option(config) | Append a single option. Widens the inferred value type to string — use .options(list) if you want the literal union. |
.renderAs("badges") | Badge rendering (select and multiselect only). |
Option shape: { value, label, color?, description?, group?, inverse?, archived? }. group applies to status and takes "idle" | "in_progress" | "finished". inverse is used when the attribute qualifies a bilateral relation: it names the option to set on the other side, as in "parent" → "child".
Value type: option value union (select, status) or an array of it (multiselect).
Location
location({ name: "address", label: "Address" })
.granularity("full")
.defaultCountry("FRA");| Method | Effect |
|---|---|
.granularity(level) | Precision of the stored address: "full", "address", "city", "state", "country", or "coordinates". |
.defaultCountry(iso3) | Default country. |
.allowedCountries(iso3s) | Restrict countries. |
Value type: Location.
User
user({ name: "assignee", label: "Assignee" }).required();
user({ name: "watchers", label: "Watchers" }).multiple();
user({ name: "handler", label: "Handler" }).types(["user", "agent"]);| Method | Effect |
|---|---|
.multiple() | Reference several users. |
.types(list) | Allowed reference types: "user", "agent". |
Value type: user ID string. .multiple() stores several ids, but the inferred type stays string — annotate it yourself if you need string[].
Relation
// Single relation
relation({ name: "company", label: "Company" }).to("companies").required();
// Many
relation({ name: "contacts", label: "Contacts" }).to("contacts").many();
// Polymorphic — call .to() once per target
relation({ name: "linkedTo", label: "Linked To" })
.to("companies")
.to("contacts")
.many();
// Universal — any object
relation({ name: "related", label: "Related" }).toAny().many();
// Bilateral — the inverse attribute stays in sync
relation({ name: "contact", label: "Client" })
.to("contacts")
.bilateral({ object: "contacts", attribute: "engagements" });
// Qualified — extra fields carried by the relation itself
relation({ name: "members", label: "Members" })
.to("contacts")
.many()
.qualifyWith(
select({ name: "role", label: "Role" })
.options([
{ value: "admin", label: "Admin" },
{ value: "member", label: "Member" },
])
.required(),
number({ name: "shares", label: "Shares" }).min(0),
);| Method | Effect |
|---|---|
.to(objectName, options?) | Add a target object. Call repeatedly for polymorphic relations. Options: { displayTemplate?, filter? }. |
.toAny() | Universal relation — target any object. |
.many() | Switch cardinality to many (returns the multi builder). |
.maxItems(n) | Cap the number of linked records (multi only). |
.qualifyWith(...builders) | Attach qualified properties to the relation. Allowed qualifier types: text, number, checkbox, date, phone, currency, status, select, multiselect, location. |
.bilateral({ object, attribute, cardinality?, storageOwner? }) | Keep an inverse attribute on the target object in sync. Single target only — not available on .toAny() relations. |
Value type: record ID string, or an array of record IDs after .many().
Chain order matters
Call .to() before .qualifyWith() or .bilateral() — they need a target. And call .many() before them too: switching cardinality returns a new builder that does not carry qualifiers, bilateral config, or a default value over.
See Relations & documents for qualified edge properties and bilateral sync walked through in depth.
Formula (read-only)
Server-computed from an expression over the record's own attributes, evaluated when the record is read. Cannot be .required().
formula({ name: "total", label: "Total" })
.expression("price * quantity")
.returns("number")
.decimals(2);
formula({ name: "fullName", label: "Full Name" })
.expression("CONCAT(firstName, ' ', lastName)")
.returns("text");| Method | Effect |
|---|---|
.expression(expr) | The formula expression. |
.returns(type) | Return type: "text" | "number" | "boolean" | "date" | "select" | "multiselect". |
.decimals(n) | Decimal places for numeric results. |
Value type: unknown — the server decides it from .returns(), so annotate the field yourself if you need a narrower type.
Rollup (read-only)
Aggregates a value across related records. Unlike a formula, the result is stored and refreshed when the source records change. Cannot be .required().
rollup({ name: "totalOrders", label: "Total Orders" })
.from("orders") // relation attribute on this object
.aggregate("amount") // attribute on the related object
.using("sum")
.decimals(2);
rollup({ name: "orderCount", label: "Order Count" })
.from("orders")
.aggregate("id")
.using("count");| Method | Effect |
|---|---|
.from(relationName) | Relation attribute to aggregate through. |
.aggregate(attributeName) | Attribute on the related object. |
.using(fn) | Aggregation function (see below). |
.decimals(n) | Decimal places. |
.path(relationPath) | Multi-level traversal with a dot path. |
.targetType(type) / .targetOptions(options) | Rendering hints when .using("original") mirrors a select-like value. |
Functions: sum, avg, earliest, latest, count, countValues, countUniqueValues, countEmpty, percentEmpty, percentNotEmpty, original.
Value type: unknown, like formula — it depends on the aggregation and the source attribute.
Document
References documents — each document is a pack of ordered files.
document({ name: "idDoc", label: "ID Document" })
.accepts(["image/*", "application/pdf"])
.maxFileSize(10 * 1024 * 1024);
// Qualified — per-document properties
document({ name: "mandate", label: "Mandate" }).qualifyWith(
date({ name: "signedDate", label: "Signed on" }),
select({ name: "signatureStatus", label: "Signature" }).options([
{ value: "pending", label: "Pending" },
{ value: "signed", label: "Signed", color: "green" },
]),
);| Method | Effect |
|---|---|
.accepts(mimeTypes) | Restrict every file of the pack to the given MIME prefixes/globs (e.g. "image/*", "application/pdf"). |
.maxFileSize(bytes) | Cap every file of the pack to bytes maximum size. |
.qualifyWith(...builders) | Per-document properties, same qualifier types as relations. |
Value type: document ID string[], or { id, props }[] when qualified.