ObjectStackObjectStack

Field metadata — every option explained

Configure field types and properties — text, numbers, dates, relationships, files, and more

A Field defines an individual property within an Object. ObjectStack provides a comprehensive set of field types covering text, numbers, dates, selections, relationships, files, calculations, and specialized types like vectors and QR codes.

Basic Usage

Fields are defined inside an Object's fields map using Field.* factory methods:

import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Contact = ObjectSchema.create({
  name: 'contact',
  sharingModel: 'private',
  label: 'Contact',
  fields: {
    first_name: Field.text({ label: 'First Name', required: true }),
    last_name: Field.text({ label: 'Last Name', required: true }),
    email: Field.email({ label: 'Email', unique: 'organization' }),
    phone: Field.phone({ label: 'Phone' }),
    birth_date: Field.date({ label: 'Date of Birth' }),
    is_active: Field.boolean({ label: 'Active', defaultValue: true }),
    account: Field.lookup('account', { label: 'Account', required: true }),
  },
});

Field Types

Text Types

TypeFactoryDescription
textField.text()Single-line text
textareaField.textarea()Multi-line text
emailField.email()Email address with validation
urlField.url()URL with validation
phoneField.phone()Phone number
passwordField.password()Masked password input
markdownField.markdown()Markdown editor
htmlField.html()HTML content
richtextField.richtext()Rich text (WYSIWYG) editor
name: Field.text({ label: 'Name', required: true, maxLength: 255 }),
bio: Field.textarea({ label: 'Bio', maxLength: 5000 }),
website: Field.url({ label: 'Website' }),
notes: Field.markdown({ label: 'Notes' }),

Number Types

TypeFactoryDescription
numberField.number()Integer or decimal
currencyField.currency()Monetary value with currency support
percentField.percent()Percentage value
quantity: Field.number({ label: 'Quantity', min: 0, max: 10000, step: 1 }),
price: Field.currency({ label: 'Price', min: 0 }),
// percent stores a fraction: 0.15 = 15% (the UI renders it as a percentage)
discount: Field.percent({ label: 'Discount', scale: 2, min: 0, max: 1 }),

Currency Configuration:

price: Field.currency({
  label: 'Price',
  currencyConfig: {
    currencyMode: 'fixed',      // 'fixed' pins the column to defaultCurrency;
                                // 'dynamic' (default) shows the tenant default
    defaultCurrency: 'USD',     // the column's currency under 'fixed'
  },
}),

The stored value is a bare number in both modes — never { value, currency }. Under dynamic (the default) the amount displays in the tenant's localization.currency setting and defaultCurrency is not the displayed currency; fixed pins the column to defaultCurrency.

Date & Time Types

TypeFactoryDescription
dateField.date()Date only
datetimeField.datetime()Date and time
time—Time only
start_date: Field.date({ label: 'Start Date', required: true }),
created_at: Field.datetime({ label: 'Created At', readonly: true }),

Deadlines: dueLike and settledWhen

A date is a deadline only when its field says so. dueLike: true on a date or datetime field declares one: once the date has passed, a renderer may show it as overdue, with relative wording ("Overdue 3d") and an overdue colour. Without the key the field is not a deadline, and nothing is inferred from its name — a due_date that is not declared is just a date.

A deadline usually stops mattering when the record is finished, and only the object knows what finished means. settledWhen says it: a CEL predicate over the record, in the same family as visibleWhen, and while it is TRUE no overdue wording or colour applies to that record.

import { P } from '@objectstack/spec';
import { Field } from '@objectstack/spec/data';

due_date: Field.date({
  label: 'Due Date',
  dueLike: true,
  settledWhen: P`record.status == 'done'`,
}),

Both keys are judged when the metadata is authored, not when a view renders it:

  • Either key on any other field type is refused, and so is settledWhen without dueLike: true — a settle condition on a date that is no deadline would change nothing.
  • settledWhen is checked exactly like visibleWhen: it must parse, read fields as record.<field>, name fields the object declares, and read no root but record (plus previous, and parent on a master-detail line item). objectstack validate, objectstack build and saving the object all refuse anything else, naming the field.

Both are display semantics: nothing on the write path reads them, and a past deadline never blocks a save.

Boolean Type

is_active: Field.boolean({ label: 'Active', defaultValue: true }),

Selection Types

TypeFactoryDescription
selectField.select()Single or multi-select dropdown
radio—Radio button group
checkboxes—Checkbox group
// Single select
status: Field.select({
  label: 'Status',
  options: [
    { label: 'New', value: 'new', color: '#999', default: true },
    { label: 'Active', value: 'active', color: '#0A0' },
    { label: 'Closed', value: 'closed', color: '#A00' },
  ],
}),

// Multi-select
skills: Field.select({
  label: 'Skills',
  multiple: true,
  options: [
    { label: 'JavaScript', value: 'js' },
    { label: 'Python', value: 'python' },
    { label: 'Go', value: 'go' },
  ],
}),

Options must use { label, value } objects. Values are stored in the database and must be lowercase.

Relationship Types

TypeFactoryDescription
lookupField.lookup()Many-to-one reference
master_detailField.masterDetail()Parent-child with cascade delete
// Simple lookup
account: Field.lookup('account', {
  label: 'Account',
  required: true,
}),

// Filtered lookup — structured filters, cascading from another field
primary_contact: Field.lookup('contact', {
  label: 'Primary Contact',
  dependsOn: ['account'],
  lookupFilters: [
    { field: 'is_active', operator: 'eq', value: true },
  ],
}),

// Master-detail (cascade delete)
project: Field.masterDetail('project', {
  label: 'Project',
  required: true,
}),

// Inline line items on the parent form
order: Field.masterDetail('order', {
  label: 'Order',
  required: true,
  inlineEdit: true,
  inlineTitle: 'Lines',
  inlineAmountField: 'line_total',
}),
PropertyTypeDescription
reference (1st arg)stringTarget object name
lookupFilters{ field, operator, value }[]Base filters restricting which records are selectable (structured, picker-honored)
deleteBehaviorenum'set_null', 'cascade', 'restrict'
inlineEditboolean | 'grid' | 'form'Render child records inline on the parent create/edit form (true = auto-pick, 'grid', or 'form')
inlineColumnsarrayOptional explicit columns for the inline grid
inlineAmountFieldstringOptional numeric child field for the inline running total

Referential integrity

reference is enforced on write. A create or update that sets a lookup / master_detail to an id with no matching row in the target object is rejected with 400 VALIDATION_FAILED and a fields[] entry whose code is reference_not_found (see the error catalog). The same check runs on bulk updates.

Clearing a relationship is not a dangling reference: null, "" and [] all mean "no link". Which of them deleteBehavior: 'set_null' writes when the referenced record goes away depends on the field: a single-value lookup is set to null, while a multiple: true lookup keeps its other members and loses only the deleted one — and a set emptied that way is written as [], never null (the multiple doc block in packages/spec/src/data/field.zod.ts, rendered in the Field reference).

deleteBehavior governs what happens to this record when the referenced record is deleted; the integrity check above governs what may be written here in the first place. They are two halves of the same relationship contract.

File & Media Types

TypeFactoryDescription
imageField.image()Image file upload
fileField.file()Generic file upload
avatarField.avatar()User avatar/profile image
video—Video file
audio—Audio file
photo: Field.image({ label: 'Photo' }),
resume: Field.file({ label: 'Resume' }),
profile_image: Field.avatar({ label: 'Profile Image' }),

Calculation Types

TypeFactoryDescription
formulaField.formula()Calculated from other fields
summaryField.summary()Roll-up from child records
autonumberField.autonumber()Sequential auto-generated number
// Formula
total: Field.formula({
  label: 'Total',
  expression: 'record.quantity * record.price * (1 - record.discount / 100)',
}),

// Summary (roll-up)
total_amount: Field.summary({
  label: 'Total Amount',
  summaryOperations: {
    object: 'order_item',
    field: 'amount',
    function: 'sum',
    relationshipField: 'order',
  },
}),

// AutoNumber
case_number: Field.autonumber({
  label: 'Case Number',
  autonumberFormat: 'CASE-{0000}',
}),

Summary fields are server-owned. The engine recomputes them when child records are inserted, updated, or deleted. relationshipField is optional when the child has only one lookup/master_detail field pointing back to the parent; set it when multiple relationships target the same parent object.

Because the roll-up is engine-derived state rather than a user write, the recompute runs under a system context: a user who may create a child does not also need edit access on the parent, and the aggregate is computed over the whole child collection rather than the writer's visible subset. The permission decision that governs the write is the one on the child. The parent's own row is unaffected — a user still needs edit access to change any other field on it — and the summary column stays subject to the parent's field-level security on read.

Specialized Types

TypeFactoryDescription
addressField.address()Structured address (street, city, state, country)
locationField.location()Geographic coordinates (lat/lng)
colorField.color()Color picker (hex/rgb/hsl)
ratingField.rating()Star rating (1-5)
sliderField.slider()Range slider
signatureField.signature()Digital signature capture
qrcodeField.qrcode()QR/barcode generator
codeField.code()Code editor with syntax highlighting
jsonField.json()JSON data
vectorField.vector()AI embedding vectors
tags—Tag list
progress—Progress bar
office: Field.address({ label: 'Office Address' }),
coordinates: Field.location({ label: 'Location' }),
brand_color: Field.color({ label: 'Color' }),
satisfaction: Field.rating(5, { label: 'Rating' }),
approval_signature: Field.signature({ label: 'Signature' }),
embedding: Field.vector(1536, { label: 'Embedding' }),

Common Properties

These properties are available on all field types:

Constraints

PropertyTypeDefaultDescription
requiredbooleanfalseField must have a value
uniquebooleanfalseValues must be unique across records
defaultValueany—Default value for new records
maxLengthnumber—Maximum character length
minLengthnumber—Minimum character length
valueDomain'iana_time_zone' | 'iso_4217_currency' | 'iso_3166_alpha2'—Standard the written value must be a member of (text only): an IANA time zone, an ISO 4217 currency code or an ISO 3166-1 alpha-2 country code. The same closed vocabulary a settings specifier's valueDomain uses; membership, not a pattern, is what a write is judged against
minnumber—Minimum numeric value
maxnumber—Maximum numeric value

Display

PropertyTypeDefaultDescription
labelstring—Human-readable display name
descriptionstring—Developer documentation
inlineHelpTextstring—Help text shown in UI
hiddenbooleanfalseHide from default views
readonlybooleanfalsePrevent editing — hidden from create/edit forms AND server-enforced on both write paths: a non-system write to the field is silently dropped on UPDATE and, since the maintainer ruling of 2026-09-03, on INSERT — both in the engine, so a direct engine.insert caller (a flow's create_record, a hook body's ctx.api) is covered exactly like the data API's REST/MCP/import faces. A stripped field still falls back to its defaultValue; seeding a readonly column at create requires a system context (import/migration/programmatic seed). Platform (sys_/managedBy) objects are governed by their own write policy instead — the resolved-affordance write guard keyed off the object's lifecycle bucket (ADR-0103), not this field-level flag.
sortablebooleantrueAllow sorting by this field
groupstring—Group name for organizing in forms (e.g. 'billing')

Search & Index

PropertyTypeDefaultDescription
searchablebooleanfalseInclude in full-text search
externalIdbooleanfalseMark as external system identifier

Database indexes are declared at the object level in the object's indexes[] array — there is no per-field index flag. See Indexing.

Security

PropertyTypeDefaultDescription
requiredPermissionsstring[]—Capabilities required to read/edit this field — masked on read, denied on write unless the caller holds all of them (ADR-0066 D3)
trackHistoryboolean—Render this field's value changes as entries on the record activity timeline

For values that must be encrypted at rest (API keys, tokens, DB passwords), use the secret field type — see the Field Type Gallery.

Conditional Logic

PropertyTypeDescription
visibleWhenstring | ExpressionCEL predicate; field is shown only when TRUE
readonlyWhenstring | ExpressionCEL predicate; field is read-only when TRUE
requiredWhenstring | ExpressionCEL predicate; field is required when TRUE
settledWhenstring | ExpressionCEL predicate on a dueLike date; the deadline is settled when TRUE — see Deadlines
conditionalFormatting{ condition, style }[]Cell formatting rules; the first rule whose CEL condition is TRUE styles this field's cell — see Cell formatting
multiplebooleanAllow multiple values (for select, lookup, file)
import { P } from '@objectstack/spec';
import { Field } from '@objectstack/spec/data';

close_reason: Field.select({
  label: 'Close Reason',
  visibleWhen: P`record.status == 'closed'`,
  requiredWhen: P`record.status == 'closed'`,
  options: [
    { label: 'Won', value: 'won' },
    { label: 'Lost', value: 'lost' },
    { label: 'Cancelled', value: 'cancelled' },
  ],
}),

invoice_total: Field.currency({
  label: 'Invoice Total',
  readonlyWhen: P`record.status == 'paid'`,
}),

Conditional field rules are evaluated in both tiers. ObjectUI forms update visibility, read-only state, and required markers live as the draft record changes, including row-scoped rules inside inline master-detail grids. The server enforces requiredWhen on submit and ignores writes to fields whose readonlyWhen predicate is TRUE, so client-side affordances are not the only guard. Use requiredWhen. The deprecated conditionalRequired alias was removed in protocol 17 (#3855): authoring it is rejected with an error naming the replacement, not silently stripped, and os migrate meta --from 16 lists the mechanical edits for existing sources.

Who the lock applies to — and the derived-field write path

A TRUE readonlyWhen predicate locks the field for every API-boundary caller. Unlike static readonly, passing { context: { isSystem: true } } does not exempt it: a state lock any system-context write could bypass would not be a state lock, and the frozen-paid-invoice case depends on it.

What the lock does not cover is the server's own trusted code. A value a beforeUpdate hook derives — or writes over a key the caller also sent — is a server value, not a caller write, and is never stripped. That is what makes "visible on the form but locked, and recomputed by a hook" a spelling the platform supports on one field:

// The field renders locked, and only the hook may move it.
next_maintenance_date: Field.date({
  label: 'Next Maintenance',
  readonlyWhen: P`true`,
}),
// The hook is the write path.
beforeUpdate: (ctx) => {
  if ('period_days' in ctx.input.data) {
    ctx.input.data.next_maintenance_date = addDays(today, ctx.input.data.period_days);
  }
},

A client cannot use this to launder a write. The engine snapshots the caller's payload at entry, before any middleware or hook runs, and judges a key only while it still holds that value — so echoing the locked key back never exempts it, and if a hook overwrites it, the value that persists is the hook's. Either way the caller's value does not land.

Locking a detail from its master: parent

On a detail object — one that declares a master_detail relationship — a readonlyWhen predicate may read the header record as parent. The canonical case is "once the invoice is Paid, its lines are frozen":

quantity: Field.number({
  label: 'Qty',
  required: true,
  // `parent` = the record on the other end of this object's master_detail field.
  readonlyWhen: P`parent.status == 'paid'`,
}),

The server binds parent on the update path by reading the master the row points at (a repointing write is judged against the master it lands on), so the lock holds against a direct API call, not only in the grid.

Two rules make this predictable:

  • Exactly one master. parent resolves only when the object declares exactly one master_detail relationship. With none — or with two, where the metadata does not say which one is "the parent" — objectstack compile rejects the predicate with an error naming the object.
  • An unresolvable parent means locked, not open. If the header cannot be read at write time, the field is treated as read-only rather than written. A lock the platform could not evaluate is never waived: that is what makes the declaration a guarantee instead of a hint.

Cell formatting: conditionalFormatting

conditionalFormatting styles a field's cell by a rule: an ordered list of { condition, style }, the same rule a list view's conditionalFormatting declares for its rows. The first rule whose CEL condition is TRUE applies its CSS style map to this field's cell wherever the field renders — a grid, a kanban card, the record page, a related list or a report cell. A list view's row rules style the row, and both may apply to one record.

The condition reads two roots: value, this field's value on the record, and record, the row.

import { P } from '@objectstack/spec';
import { Field } from '@objectstack/spec/data';

amount: Field.currency({
  label: 'Amount',
  conditionalFormatting: [
    { condition: P`value < 0`, style: { color: '#b91c1c' } },
  ],
}),

The rules are checked when the metadata is authored, like visibleWhen:

  • A rule is exactly { condition, style }, the condition is not blank, and style maps CSS property names to string values. Anything else is refused at parse.
  • The condition must parse, read other fields as record.<field>, name fields the object declares, and read no root but value and record. objectstack validate, objectstack build and saving the object all refuse anything else, naming the field and the rule.

The rules are for presentation: an amount below zero in red, stock under its safety level in amber, a priority cell coloured by its value. A state with a meaning of its own belongs in a declaration instead — a deadline's overdue state is dueLike with settledWhen, from which the wording and the colour both follow. Nothing on the write path reads the rules.

The console's cell renderers read these rules in a later ObjectUI release. Until then a rule is validated when it is authored and styles no cell.

Naming Conventions

  • Field names use snake_case: first_name, annual_revenue, is_active
  • Boolean fields use is_ or has_ prefix: is_active, has_children
  • Lookup fields use the object name directly: account (not account_id)
  • Configuration keys use camelCase: maxLength, defaultValue

On this page