Field types — the complete visual gallery
Complete reference for every ObjectStack field type with per-type configuration properties
ObjectStack provides a comprehensive set of field types covering every data modeling need — from basic text and numbers to AI vectors and rich media. This guide organizes them by category with per-type configuration details.
Source: packages/spec/src/data/field.zod.ts
Import: import { FieldType, FieldSchema } from '@objectstack/spec/data'
Text Types
text
Single-line plain text input.
| Property | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Maximum character length |
minLength | number | — | Minimum character length |
format | string | — | Display hint, not validation — the server runs no check from it. The UI's cell-renderer resolver reads a small set of words and renders the cell as the richer type: phone / tel / telephone (a tel: link), email (a mailto: link), url / uri / link (a clickable link), currency / money, percent / percentage; any other word renders as plain text. To reject a malformed email, URL or phone number, use that field type instead, or a format validation rule |
valueDomain | 'iana_time_zone' | 'iso_4217_currency' | 'iso_3166_alpha2' | — | Standard the written value must be a member of (IANA time zone, ISO 4217 currency code, ISO 3166-1 alpha-2 country code); text only |
{ name: 'first_name', label: 'First Name', type: 'text', maxLength: 100 }textarea
Multi-line text input for longer content.
| Property | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Maximum character length |
minLength | number | — | Minimum character length |
{ name: 'description', label: 'Description', type: 'textarea', maxLength: 5000 }email
Email address with built-in format validation.
| Property | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Maximum character length |
{ name: 'email', label: 'Email', type: 'email', required: true, unique: 'organization' }url
URL with format validation.
| Property | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Maximum character length |
{ name: 'website', label: 'Website', type: 'url' }phone
Phone number field.
| Property | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Maximum character length |
{ name: 'phone', label: 'Phone', type: 'phone' }password
Masked password input. On a generic object the value is plaintext at rest but masked to •••••••• on read (ADR-0100) — it is not one-way hashed. One-way hashing is owned by the auth subsystem and applies only to its identity tables, never to an authored password field. For reversible encrypted-at-rest secrets (API keys, tokens, DB passwords), use the secret type instead; for login credentials, model them on the auth user object.
{ name: 'password', label: 'Password', type: 'password', required: true }secret
Reversible, encrypted-at-rest value (DB password, API key, token). Unlike password (masked on read but plaintext at rest, or one-way hashed inside the auth subsystem), a secret is encrypted on write via the registered crypto provider, stored as an opaque ref on the row, and masked on read. Fail-closed: with no provider configured, writes throw rather than persist cleartext.
{ name: 'api_key', label: 'API Key', type: 'secret' }Rich Content Types
markdown
Markdown-formatted text with preview support.
| Property | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Maximum character length |
{ name: 'readme', label: 'README', type: 'markdown' }html
Rich HTML content.
| Property | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Maximum character length |
{ name: 'body', label: 'Body', type: 'html' }richtext
WYSIWYG rich text editor content.
| Property | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Maximum character length |
{ name: 'content', label: 'Content', type: 'richtext' }Number Types
number
Numeric value with optional precision.
| Property | Type | Default | Description |
|---|---|---|---|
precision | number | — | Total digits |
scale | number | — | Decimal places |
min | number | — | Minimum value |
max | number | — | Maximum value |
{ name: 'quantity', label: 'Quantity', type: 'number', min: 0, max: 99999 }currency
Monetary value. The stored value is a bare number in both modes; the currency
code lives in currencyConfig, never in the value.
| Property | Type | Default | Description |
|---|---|---|---|
precision | number | — | Total digits |
min | number | — | Minimum value |
max | number | — | Maximum value |
currencyConfig.currencyMode | 'dynamic' | 'fixed' | 'dynamic' | Whether the column is pinned to defaultCurrency (fixed) or displays in the tenant default currency, the localization.currency setting (dynamic); the value is a bare number either way |
currencyConfig.defaultCurrency | string | 'CNY' | The column's currency code under fixed; not the displayed currency under dynamic |
{ name: 'price', label: 'Price', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }A currency amount's decimal places are its currency's ISO 4217 minor unit
(2 for USD, 0 for JPY, 3 for KWD), not a field setting, and they are declared
nowhere. A currency field takes no scale, and currencyConfig has no
decimal-places key: publish-time validation refuses scale on the field and
currencyConfig.precision (removed in @objectstack/spec 17.5.0 — no renderer
or runtime ever read it), each with a prescription to delete the key. The
field-level precision is the amount's total digit count — a DECIMAL(18,2)
amount declares precision: 18 — and is never compared with the currency.
percent
Percentage value (0–100 or decimal).
| Property | Type | Default | Description |
|---|---|---|---|
precision | number | — | Total digits |
scale | number | — | Decimal places |
min | number | — | Minimum value |
max | number | — | Maximum value |
{ name: 'discount', label: 'Discount', type: 'percent', min: 0, max: 100 }Date & Time Types
date
Date value (no time component).
{ name: 'birth_date', label: 'Birth Date', type: 'date' }datetime
Date and time value with timezone support.
{ name: 'created_at', label: 'Created At', type: 'datetime', readonly: true }time
Time-only value (no date component).
{ name: 'start_time', label: 'Start Time', type: 'time' }Boolean Types
boolean
Standard true/false field.
{ name: 'is_active', label: 'Active', type: 'boolean', defaultValue: true }toggle
Toggle switch (functionally identical to boolean, different UI).
{ name: 'notifications_enabled', label: 'Notifications', type: 'toggle' }Selection Types
select
Single-choice dropdown.
| Property | Type | Default | Description |
|---|---|---|---|
options | SelectOption[] | required (or picklist) | List of available options — non-empty. Name a shared list with picklist: 'NAME' instead, never both. With neither, a select (or radio) is refused at parse. |
SelectOption properties:
| Property | Type | Required | Description |
|---|---|---|---|
label | string | ✅ | Display label |
value | string | ✅ | Machine value (snake_case) |
color | string | — | Badge color |
default | boolean | — | Pre-selected option |
{
name: 'status', label: 'Status', type: 'select',
options: [
{ label: 'Open', value: 'open', color: 'blue', default: true },
{ label: 'In Progress', value: 'in_progress', color: 'yellow' },
{ label: 'Done', value: 'done', color: 'green' }
]
}multiselect
Multi-choice dropdown. Same options property as select.
{
name: 'tags', label: 'Tags', type: 'multiselect',
options: [
{ label: 'Bug', value: 'bug' },
{ label: 'Feature', value: 'feature' },
{ label: 'Enhancement', value: 'enhancement' }
]
}radio
Radio button group (single choice). Same options property as select.
{
name: 'priority', label: 'Priority', type: 'radio',
options: [
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium', default: true },
{ label: 'High', value: 'high' }
]
}checkboxes
Checkbox group (multi-choice). Same options property as select.
{
name: 'features', label: 'Features', type: 'checkboxes',
options: [
{ label: 'Email', value: 'email' },
{ label: 'SMS', value: 'sms' },
{ label: 'Push', value: 'push' }
]
}Relational Types
lookup
Reference to a record in another object (foreign key).
| Property | Type | Default | Description |
|---|---|---|---|
reference | string | required | Target object name (snake_case) |
referenceFilters | string[] | — | Removed (#2377, ADR-0049) — no longer a recognized field property. FieldSchema is a strict object, so an unknown key is rejected with guidance, not silently stripped (ADR-0078): the error echoes the offending key and prescribes the replacement. Use structured lookupFilters + dependsOn instead; see Relationships |
deleteBehavior | 'restrict' | 'cascade' | 'set_null' | 'set_null' | Behavior when referenced record is deleted. On a required lookup set_null is escalated to restrict, since a NOT NULL foreign key cannot be cleared — whether the set_null was defaulted or written out explicitly. On a multiple: true required lookup the escalation is judged per referencing row: only a row the member removal would leave EMPTY is refused. cascade and restrict are the values honored as written. Where set_null does run, a multiple: true lookup loses only the deleted member — the other members are kept, and a set emptied that way is stored as [], never null |
{ name: 'company', label: 'Company', type: 'lookup', reference: 'account' }user
Person picker — a lookup specialized to the built-in sys_user object. Stored identically to lookup (foreign key to sys_user.id; multiple: true stores an array) and resolved through the same $expand machinery. Supports defaultValue: 'current_user' to stamp the acting user's id on insert.
Field.user({ label: 'Assignee' })
Field.user({ label: 'Watchers', multiple: true })
Field.user({ label: 'Owner', defaultValue: 'current_user' })master_detail
Parent-child relationship (cascading delete by default).
| Property | Type | Default | Description |
|---|---|---|---|
reference | string | required | Target (master) object name |
referenceFilters | string[] | — | Removed (#2377, ADR-0049) — no longer a recognized field property. FieldSchema is a strict object, so an unknown key is rejected with guidance, not silently stripped (ADR-0078): the error echoes the offending key and prescribes the replacement. Use structured lookupFilters + dependsOn instead; see Relationships |
deleteBehavior | 'restrict' | 'cascade' | 'cascade' | Behavior when parent is deleted. restrict refuses the parent delete while children exist; cascade (and omitting the key) deletes the children with the parent. An explicit set_null is a parse-time rejection on this type (#9689) — a detail row cannot outlive its master; use a lookup if children must survive the parent |
inlineEdit | boolean | 'grid' | 'form' | — | Edit child records inline on the parent create/edit form (true = auto-pick, 'grid', or 'form') |
inlineColumns | array | — | Optional explicit inline grid columns |
inlineAmountField | string | — | Numeric child field used for the inline running total |
{
name: 'order',
label: 'Order',
type: 'master_detail',
reference: 'order',
inlineEdit: true,
inlineAmountField: 'line_total',
}tree
Self-referential hierarchy (e.g., categories, org chart).
| Property | Type | Default | Description |
|---|---|---|---|
reference | string | — | Optional. If given, must be this object (self-reference) — any other object is refused at parse |
{ name: 'parent_category', label: 'Parent Category', type: 'tree', reference: 'category' }Media Types
Media fields store a reference to an uploaded file. They take the universal field properties (multiple is honored for file and image). Two more field properties apply to the media family (file, image, avatar, video and audio) and constrain the referenced file: accept, the permitted upload types as MIME types or extensions (e.g. ["image/*", ".pdf"]), offered to the file picker and enforced on write; and maxSize, the maximum permitted file size in bytes, enforced on write against the stored file size, not just checked in the browser. A create or update whose referenced file violates either is refused with 400 ERR_FILE_CONSTRAINT, and the error message names the field and the constraint. Storage itself is configured at the storage-provider layer, not on the field.
image
Image file with optional validation.
{ name: 'photo', label: 'Photo', type: 'image' }file
Generic file attachment.
{ name: 'attachment', label: 'Attachment', type: 'file', multiple: true }avatar
Profile image (typically square, small).
{ name: 'avatar', label: 'Avatar', type: 'avatar' }video
Video file attachment.
{ name: 'demo_video', label: 'Demo Video', type: 'video' }audio
Audio file attachment.
{ name: 'recording', label: 'Recording', type: 'audio' }Computed Types
formula
Calculated field using a CEL expression (see Expressions).
| Property | Type | Default | Description |
|---|---|---|---|
expression | string | Expression | required | CEL calculation expression |
returnType | 'number' | 'text' | 'boolean' | 'date' | 'currency' | — | Declared value type of the computed result. currency: an amount of money, in the currency the formula's own currencyConfig declares (none: dynamic) |
{ name: 'total', label: 'Total', type: 'formula', expression: 'record.quantity * record.unit_price', returnType: 'number' }summary
Roll-up summary from child records.
| Property | Type | Default | Description |
|---|---|---|---|
summaryOperations.object | string | required | Child object to aggregate |
summaryOperations.field | string | required | Child field to aggregate; ignored for count |
summaryOperations.function | enum | required | count, sum, avg, min, or max |
summaryOperations.relationshipField | string | auto | Child FK back to the parent when it cannot be inferred |
summaryOperations.filter | FilterCondition | — | Only child rows matching this where predicate are aggregated |
{
name: 'task_count',
label: 'Task Count',
type: 'summary',
summaryOperations: {
object: 'task',
field: 'id',
function: 'count',
relationshipField: 'project',
},
}Add a filter to aggregate only a subset of children — this is how one child
object feeds several different totals. The engine ANDs the predicate with the
parent-FK match and recomputes on the child's next write, so a row moving in or
out of the filter (a status change) keeps the parent current:
// Sum only received receipt lines (3-way match), not every line.
{
name: 'received_amount',
type: 'summary',
summaryOperations: {
object: 'procurement_receipt',
field: 'amount',
function: 'sum',
filter: { status: 'received' },
},
}
// One `engagement` child → distinct totals, differentiated only by the filter.
{
name: 'total_signups',
type: 'summary',
summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: 'signup' } },
}autonumber
Auto-incrementing number with format template.
| Property | Type | Default | Description |
|---|---|---|---|
autonumberFormat | string | — | Format template (e.g., 'INV-{0000}') |
{ name: 'ticket_number', label: 'Ticket #', type: 'autonumber', autonumberFormat: 'TKT-{0000}' }An autonumber is unique and monotonic per scope — it is not gapless. Any
write that gets rejected after the number was reserved — a unique violation on
another field, a failed validation rule, a beforeInsert hook that throws —
still consumes that number, and it is never reissued. TKT-0001 succeeding and
TKT-0002 failing means the next row is TKT-0003; TKT-0002 will not exist.
This is a decided property of the counter, not a bug to work around. If you are
building an invoice, contract, or receipt sequence on an autonumber field,
design for gaps: an autonumber guarantees a unique, ever-increasing business
identifier, but it is not a legally gapless document number. Treat that
guarantee as a separate requirement to satisfy explicitly, not something
autonumber already gives you.
Embedded Structured Types
These types store structured values as JSON on the parent row — no separate table or foreign key.
composite
Single embedded sub-object with declared sub-fields (similar to a Strapi component / ACF group).
{ name: 'dimensions', label: 'Dimensions', type: 'composite' }repeater
Repeating embedded sub-object array with declared sub-fields (similar to a Strapi repeatable component / ACF repeater).
{ name: 'line_items', label: 'Line Items', type: 'repeater' }record
Name-keyed map of embedded sub-objects (Record<string, SubObject>). Insertion order is display order. Used for collections where each item has a stable machine name. See ADR-0007.
{ name: 'settings', label: 'Settings', type: 'record' }Enhanced Types
location
Geographic coordinates. Stored as { lat, lng, altitude?, accuracy? } (lat −90..90, lng −180..180). No per-type config properties. The key names are lat/lng, not latitude/longitude — see LocationValueSchema in field-value.zod.ts (ADR-0104 D1). The schema is strict (#13802): an undeclared key is refused by name, not silently dropped.
{ name: 'headquarters', label: 'Location', type: 'location' }address
Structured postal address. Stored as { street, city, state, postalCode, country, countryCode, formatted } (all parts optional). No per-type config properties. The schema is strict (#13802): an undeclared key such as postal_code or zipCode is refused with a rename to postalCode, not silently dropped.
{ name: 'billing_address', label: 'Billing Address', type: 'address' }code
Source code with syntax highlighting.
| Property | Type | Default | Description |
|---|---|---|---|
language | string | — | Programming language for highlighting |
{ name: 'snippet', label: 'Code Snippet', type: 'code', language: 'typescript' }json
Raw JSON data.
{ name: 'metadata', label: 'Metadata', type: 'json' }color
Color picker. Stores the color value as a string. No per-type config properties.
{ name: 'brand_color', label: 'Brand Color', type: 'color' }rating
Star rating input.
| Property | Type | Default | Description |
|---|---|---|---|
max | number | 5 | Maximum rating value (set by the Field.rating(max) factory) |
{ name: 'satisfaction', label: 'Rating', type: 'rating', max: 5 }
// or with the factory:
// satisfaction: Field.rating(5, { label: 'Rating' })slider
Range slider input.
| Property | Type | Default | Description |
|---|---|---|---|
min | number | — | Minimum value |
max | number | — | Maximum value |
step | number | 1 | Step increment |
{ name: 'confidence', label: 'Confidence', type: 'slider', min: 0, max: 100, step: 5 }signature
Digital signature capture.
{ name: 'approval_signature', label: 'Signature', type: 'signature' }qrcode
QR/barcode value rendered as a scannable code. No per-type config properties.
{ name: 'asset_tag', label: 'Asset Tag', type: 'qrcode' }progress
Progress bar (0–100).
{ name: 'completion', label: 'Completion', type: 'progress' }tags
Tag input with autocomplete.
{ name: 'labels', label: 'Labels', type: 'tags' }AI / ML Types
vector
Vector embeddings for semantic search and similarity.
| Property | Type | Default | Description |
|---|---|---|---|
dimensions | number | required | Vector dimensionality (e.g., 1536 for OpenAI embeddings) |
An earlier nested vectorConfig object was removed — the field builder no longer emits it and it is ignored. Set the flat dimensions property instead.
{ name: 'embedding', label: 'Embedding', type: 'vector', dimensions: 1536 }
// or with the factory:
// embedding: Field.vector(1536, { label: 'Embedding' })Universal Field Properties
These properties are available on all field types:
| Property | Type | Default | Description |
|---|---|---|---|
name | string | required | Machine name (snake_case) |
label | string | — | Human-readable display label |
description | string | — | Field description / help text |
type | FieldType | required | One of the supported field types |
required | boolean | false | Whether the field is required |
unique | boolean | 'global' | 'organization' | false ('organization' on autonumber) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization; true = that same per-organization scope (positional synonym — prefer the explicit spelling in new code); 'global' = one holder across the whole installation; false = no constraint. Omitted ⇒ false, except on an autonumber field, where omitted ⇒ 'organization' — an auto-number is a business identifier, so the platform makes it unique per organization by default; write unique: false explicitly to opt out. 'tenant'/'org' are rejected — the word is 'organization'. Full rule in the unique doc block in FieldSchema |
multiple | boolean | false | Allow array of values (multi-record lookup, multi-select, multi-file). An emptied multi-value lookup reads back as [], never null — the multiple doc block in FieldSchema |
searchable | boolean | false | Include in search index |
sortable | boolean | true | Allow sorting by this field |
hidden | boolean | false | Hide from default views |
readonly | boolean | false | Server contract — non-system writes to this field are stripped on both INSERT and UPDATE (the value falls back to defaultValue), not just hidden in the UI (#2948 / #3043) |
externalId | boolean | false | External system identifier |
defaultValue | any | — | Default value for new records |
group | string | — | Field grouping / section |
inlineHelpText | string | — | Inline help tooltip |
trackHistory | boolean | — | Render this field's value changes as entries on the record activity timeline |
requiredPermissions | string[] | — | Capabilities required to read/edit this field (masked on read, denied on write) |
dependsOn | string[] | — | Fields whose values scope this field's options (lookup candidate query; select/radio option gating) |
visibleWhen | Expression | — | Show field only when predicate is true |
readonlyWhen | Expression | — | Make field read-only when predicate is true |
requiredWhen | Expression | — | Require field when predicate is true |
Choosing a Field Type
Not sure which type fits your data? Follow the Field Type Decision Tree — a flowchart, quick-reference tables, and a use-case mapping for every type in this gallery.