Field Metadata
Configure field types and properties — text, numbers, dates, relationships, files, and more
Field Metadata
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',
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', scale: 2, 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: {
precision: 2,
currencyMode: 'dynamic', // 'dynamic' | 'fixed'
defaultCurrency: 'USD',
},
}),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 }),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 [] mean
"no link" — exactly what deleteBehavior: 'set_null' writes when the parent
goes away.
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 |
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 (in the engine) and on INSERT through the data API (REST/GraphQL/MCP/import, at the DataProtocol ingress). 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 |
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 rewrites
existing sources automatically.
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.
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