standards
SDK

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:

FamilyTypesWhat they hold
Valuestext, richtext, number, checkbox, date, phone, currency, locationA value typed in or picked by the user.
Choicesselect, multiselect, statusOne or more options you declare up front.
Referencesrelation, user, documentA pointer to another record, person, or document.
Computedformula, rollupA read-only value the server derives.

Methods available on every builder

MethodEffect
.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);
MethodEffect
.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");
MethodEffect
.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");
MethodEffect
.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");
MethodEffect
.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();
MethodEffect
.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" },
]);
MethodEffect
.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");
MethodEffect
.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"]);
MethodEffect
.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),
  );
MethodEffect
.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");
MethodEffect
.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");
MethodEffect
.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" },
  ]),
);
MethodEffect
.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.