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
| Type | Factory | Description |
|---|---|---|
text | Field.text() | Single-line text |
textarea | Field.textarea() | Multi-line text |
email | Field.email() | Email address with validation |
url | Field.url() | URL with validation |
phone | Field.phone() | Phone number |
password | Field.password() | Masked password input |
markdown | Field.markdown() | Markdown editor |
html | Field.html() | HTML content |
richtext | Field.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
| Type | Factory | Description |
|---|---|---|
number | Field.number() | Integer or decimal |
currency | Field.currency() | Monetary value with currency support |
percent | Field.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
| Type | Factory | Description |
|---|---|---|
date | Field.date() | Date only |
datetime | Field.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
settledWhenwithoutdueLike: true— a settle condition on a date that is no deadline would change nothing. settledWhenis checked exactly likevisibleWhen: it must parse, read fields asrecord.<field>, name fields the object declares, and read no root butrecord(plusprevious, andparenton a master-detail line item).objectstack validate,objectstack buildand 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
| Type | Factory | Description |
|---|---|---|
select | Field.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
| Type | Factory | Description |
|---|---|---|
lookup | Field.lookup() | Many-to-one reference |
master_detail | Field.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',
}),| Property | Type | Description |
|---|---|---|
reference (1st arg) | string | Target object name |
lookupFilters | { field, operator, value }[] | Base filters restricting which records are selectable (structured, picker-honored) |
deleteBehavior | enum | 'set_null', 'cascade', 'restrict' |
inlineEdit | boolean | 'grid' | 'form' | Render child records inline on the parent create/edit form (true = auto-pick, 'grid', or 'form') |
inlineColumns | array | Optional explicit columns for the inline grid |
inlineAmountField | string | Optional 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
| Type | Factory | Description |
|---|---|---|
image | Field.image() | Image file upload |
file | Field.file() | Generic file upload |
avatar | Field.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
| Type | Factory | Description |
|---|---|---|
formula | Field.formula() | Calculated from other fields |
summary | Field.summary() | Roll-up from child records |
autonumber | Field.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
| Type | Factory | Description |
|---|---|---|
address | Field.address() | Structured address (street, city, state, country) |
location | Field.location() | Geographic coordinates (lat/lng) |
color | Field.color() | Color picker (hex/rgb/hsl) |
rating | Field.rating() | Star rating (1-5) |
slider | Field.slider() | Range slider |
signature | Field.signature() | Digital signature capture |
qrcode | Field.qrcode() | QR/barcode generator |
code | Field.code() | Code editor with syntax highlighting |
json | Field.json() | JSON data |
vector | Field.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
| Property | Type | Default | Description |
|---|---|---|---|
required | boolean | false | Field must have a value |
unique | boolean | false | Values must be unique across records |
defaultValue | any | — | Default value for new records |
maxLength | number | — | Maximum character length |
minLength | number | — | 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 |
min | number | — | Minimum numeric value |
max | number | — | Maximum numeric value |
Display
| Property | Type | Default | Description |
|---|---|---|---|
label | string | — | Human-readable display name |
description | string | — | Developer documentation |
inlineHelpText | string | — | Help text shown in UI |
hidden | boolean | false | Hide from default views |
readonly | boolean | false | Prevent 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. |
sortable | boolean | true | Allow sorting by this field |
group | string | — | Group name for organizing in forms (e.g. 'billing') |
Search & Index
| Property | Type | Default | Description |
|---|---|---|---|
searchable | boolean | false | Include in full-text search |
externalId | boolean | false | Mark 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
| Property | Type | Default | Description |
|---|---|---|---|
requiredPermissions | string[] | — | Capabilities required to read/edit this field — masked on read, denied on write unless the caller holds all of them (ADR-0066 D3) |
trackHistory | boolean | — | 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
| Property | Type | Description |
|---|---|---|
visibleWhen | string | Expression | CEL predicate; field is shown only when TRUE |
readonlyWhen | string | Expression | CEL predicate; field is read-only when TRUE |
requiredWhen | string | Expression | CEL predicate; field is required when TRUE |
settledWhen | string | Expression | CEL 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 |
multiple | boolean | Allow 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.
parentresolves only when the object declares exactly onemaster_detailrelationship. With none — or with two, where the metadata does not say which one is "the parent" —objectstack compilerejects the predicate with an error naming the object. - An unresolvable
parentmeans 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, andstylemaps 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 butvalueandrecord.objectstack validate,objectstack buildand 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_orhas_prefix:is_active,has_children - Lookup fields use the object name directly:
account(notaccount_id) - Configuration keys use
camelCase:maxLength,defaultValue
Related
- Object Metadata — Define business entities
- Validation Metadata — Field-level and cross-field validation rules
- View Metadata — Control how fields are displayed in views