ObjectStackObjectStack

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

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', 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

TypeFactoryDescription
dateField.date()Date only
datetimeField.datetime()Date and time
timeTime 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

TypeFactoryDescription
selectField.select()Single or multi-select dropdown
radioRadio button group
checkboxesCheckbox 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 [] 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

TypeFactoryDescription
imageField.image()Image file upload
fileField.file()Generic file upload
avatarField.avatar()User avatar/profile image
videoVideo file
audioAudio 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
tagsTag list
progressProgress 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
defaultValueanyDefault value for new records
maxLengthnumberMaximum character length
minLengthnumberMinimum character length
minnumberMinimum numeric value
maxnumberMaximum numeric value

Display

PropertyTypeDefaultDescription
labelstringHuman-readable display name
descriptionstringDeveloper documentation
inlineHelpTextstringHelp 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 (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.
sortablebooleantrueAllow sorting by this field
groupstringGroup 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)
trackHistorybooleanRender 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
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 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. 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.

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