Field Types API Reference
Complete API reference for all built-in field types in Stack. For usage examples and guides, see the Field Types guide.
Reads in the examples below use the composed read described in Queries; every terminal on it can answer null or [] for an access denial, so results are null-checked before use (Access Control). List- and config-level schema options — db.timestamps, db.indexes, db.idField — are in the Config API.
Core Field Types
text()
String field with validation and indexing options.
import { text } from '@opensaas/stack-core/fields'
text(options?: {
validation?: {
isRequired?: boolean
length?: { min?: number; max?: number }
}
isIndexed?: boolean | 'unique'
db?: {
map?: string
}
ui?: {
displayMode?: 'input' | 'textarea'
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
defaultValue?: string
})
Options
validation
Validation rules for the text field.
Type: object
Properties:
isRequired?: boolean- Field is required on createlength?: object- Length constraintsmin?: number- Minimum character length (default: 1 when required)max?: number- Maximum character length
Example:
title: text({
validation: {
isRequired: true,
length: { min: 3, max: 100 },
},
})
isIndexed
Database index configuration.
Type: boolean | 'unique'
Values:
true- Create non-unique index for faster queries'unique'- Create unique index (enforces uniqueness)falseor omitted - No index
isIndexed is sugar for an unnamed single-column index or unique constraint. For a named constraint (e.g. adopting a live table's existing constraint name), or one spanning more than one field, use the list's db.indexes instead — the two must not both target the same column. An index column carries no sort direction.
Example:
email: text({
isIndexed: 'unique',
validation: { isRequired: true },
})
db.map
Custom database column name.
Type: string
Purpose:
Rename the underlying column without changing the field name. Useful for:
- Legacy databases: Match existing column names
- Naming conventions: Use snake_case in database, camelCase in code
- Migration compatibility: Maintain existing column names
Example:
firstName: text({
db: { map: 'first_name' },
})
emailAddress: text({
isIndexed: 'unique',
db: { map: 'email' },
})
Generated contract module:
firstName: field.text().optional().column('first_name')
emailAddress: field.text().optional().column('email').unique()
The field names in your code (firstName, emailAddress) remain unchanged, and so do the keys the Where vocabulary accepts. Only the database columns use the mapped names.
ui.displayMode
UI display mode for the field.
Type: 'input' | 'textarea' Default: 'input'
Example:
description: text({
ui: { displayMode: 'textarea' },
})
Database column
Postgres text (field.text()).
TypeScript Type
string, or string | null when the column is nullable.
integer()
Numeric field for whole numbers.
import { integer } from '@opensaas/stack-core/fields'
integer(options?: {
validation?: {
isRequired?: boolean
min?: number
max?: number
}
isIndexed?: boolean | 'unique'
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
defaultValue?: number
})
Options
validation
Validation rules for the integer field.
Type: object
Properties:
isRequired?: boolean- Field is required on createmin?: number- Minimum value (inclusive)max?: number- Maximum value (inclusive)
Example:
age: integer({
validation: {
isRequired: true,
min: 0,
max: 150,
},
})
defaultValue
Default value when creating new items.
Type: number
Example:
score: integer({ defaultValue: 0 })
isIndexed
Database index configuration.
Type: boolean | 'unique'
Values:
true- Create non-unique index for faster queries'unique'- Create unique index (enforces uniqueness)falseor omitted - No index
isIndexed is sugar for an unnamed single-column index or unique constraint. For a named constraint (e.g. adopting a live table's existing constraint name), or one spanning more than one field, use the list's db.indexes instead — the two must not both target the same column. An index column carries no sort direction.
Example:
rank: integer({
isIndexed: true,
})
Database column
Postgres int4 (field.int()) — a 32-bit integer. Use bigInt() beyond that range.
TypeScript Type
number, or number | null when the column is nullable.
bigInt()
64-bit integer field for values that overflow integer()'s 32-bit Int — e.g. a millisecond epoch (Date.now()), a Snowflake ID, or any value beyond Number.MAX_SAFE_INTEGER.
import { bigInt } from '@opensaas/stack-core/fields'
bigInt(options?: {
validation?: {
isRequired?: boolean
min?: bigint
max?: bigint
}
defaultValue?: bigint | number | string
db?: {
map?: string
isNullable?: boolean
nativeType?: string
}
isIndexed?: boolean | 'unique'
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
})
Options
validation
Validation rules for the bigInt field.
Type: object
Properties:
isRequired?: boolean- Field is required on createmin?: bigint- Minimum value (inclusive)max?: bigint- Maximum value (inclusive)
Example:
occurredAtMs: bigInt({
validation: {
isRequired: true,
min: 0n,
},
})
defaultValue
Default value when creating new items.
Type: bigint | number | string
Example:
sequence: bigInt({ defaultValue: 0n })
isIndexed
Database index configuration.
Type: boolean | 'unique'
Values:
true- Create non-unique index for faster queries'unique'- Create unique index (enforces uniqueness)falseor omitted - No index
isIndexed is sugar for an unnamed single-column index or unique constraint. For a named constraint (e.g. adopting a live table's existing constraint name), or one spanning more than one field, use the list's db.indexes instead — the two must not both target the same column. An index column carries no sort direction.
Database column
Postgres int8 (field.bigint()).
TypeScript Type
bigint, or bigint | null when the column is nullable.
Write coercion
Create/update accept bigint, an integer number, or a numeric string, and always coerce to bigint:
- A non-integer value (e.g.
1.5) is rejected. - A
numberaboveNumber.MAX_SAFE_INTEGERis rejected rather than silently coerced — by the time anumberreaches that range it has already lost precision, so accepting it would reintroduce the exact defect this field exists to prevent. Pass abigintliteral or a numeric string for values beyond that range.
A bigint literal is always safe; a number is safe below Number.MAX_SAFE_INTEGER; a numeric string is safe at any size.
const event = await context.db.Event.create({
data: { occurredAtMs: 9007199254740993n },
})
if (!event) throw new Error('Not permitted to create an Event')
Wire representation over MCP
bigint is not JSON-serialisable, so an MCP CRUD tool result renders a bigInt field's value as a decimal string, not a bare bigint. This is deliberate (see ADR-0029): the field's TypeScript type stays bigint in application code, and the MCP wire form is the closest JSON-compatible representation an MCP client can consume.
{ "id": "1", "occurredAtMs": "9007199254740993" }
decimal()
Precise decimal field for currency, financial calculations, and measurements.
import { decimal } from '@opensaas/stack-core/fields'
decimal(options?: {
precision?: number
scale?: number
validation?: {
isRequired?: boolean
min?: string
max?: string
}
defaultValue?: string
db?: {
map?: string
isNullable?: boolean
}
isIndexed?: boolean | 'unique'
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
})
Options
precision
Maximum number of digits in the decimal number.
Type: number Default: 18
Example:
price: decimal({
precision: 10, // Max 10 digits total
scale: 2, // 2 decimal places
})
// Can store: 12345678.90 (10 digits, 2 decimals)
scale
Maximum number of decimal places.
Type: number Default: 4
Example:
coordinates: decimal({
precision: 18,
scale: 8, // 8 decimal places for GPS precision
})
validation
Validation rules for the decimal field.
Type: object
Properties:
isRequired?: boolean- Field is required on createmin?: string- Minimum value (as string for precision)max?: string- Maximum value (as string for precision)
Example:
price: decimal({
precision: 10,
scale: 2,
validation: {
isRequired: true,
min: '0',
max: '999999.99',
},
})
Always use string values for min, max, and defaultValue to maintain precision. Using JavaScript numbers may introduce floating-point errors.
defaultValue
Default value when creating new items.
Type: string
Example:
balance: decimal({
defaultValue: '0.0000',
precision: 18,
scale: 4,
})
db.map
Custom database column name.
Type: string
Example:
latitude: decimal({
db: { map: 'lat' },
precision: 18,
scale: 8,
})
db.isNullable
Override nullability independent of isRequired.
Type: boolean Default: Based on validation.isRequired
Example:
price: decimal({
validation: { isRequired: true },
db: { isNullable: false }, // Enforce NOT NULL at database level
})
isIndexed
Database index configuration.
Type: boolean | 'unique'
Values:
true- Create non-unique index for faster queries'unique'- Create unique index (enforces uniqueness)falseor omitted - No index
isIndexed is sugar for an unnamed single-column index or unique constraint. For a named constraint (e.g. adopting a live table's existing constraint name), or one spanning more than one field, use the list's db.indexes instead — the two must not both target the same column. An index column carries no sort direction.
Example:
accountNumber: decimal({
isIndexed: 'unique',
precision: 20,
scale: 0,
})
Database column
Postgres numeric carrying the declared precision and scale.
Generated contract module:
price: field.column(numericColumn(10, 2))
TypeScript Type
A decimal string, in both directions — the exact digits, never a JavaScript number. Reading 19.99 back gives '19.99'.
Usage Example
Arithmetic is the caller's, with a decimal library constructed from the string. decimal.js is one such library; the stack does not depend on it and does not return its type.
import { Decimal } from 'decimal.js'
const product = await context.db.Product.create({
data: { name: 'Widget', price: '19.99' },
})
if (product) {
const subtotal = new Decimal(product.price).times(3)
const total = subtotal.plus(subtotal.times('0.1')).toDecimalPlaces(2)
await context.db.Order.create({ data: { total: total.toFixed(2) } })
}
The create returns null on an access denial, so the result is checked before the value is read.
The write face is a string. Passing a JavaScript number where a price belongs reintroduces exactly the rounding error this field exists to prevent — the literal 19.99 is already inexact before the field sees it.
Key Features
- Precision: No floating-point errors — the value never becomes a JavaScript
number - Configurable: Set precision and scale for your use case
- Validation: String-based min/max for precise bounds
- Database-native: A real Postgres
numericcolumn, not a text approximation
Use Cases
- Currency: Prices, balances, payments
- Financial: Interest rates, exchange rates, percentages
- Measurements: GPS coordinates, scientific data
- Accounting: Monetary calculations requiring precision
checkbox()
Boolean field for true/false values.
import { checkbox } from '@opensaas/stack-core/fields'
checkbox(options?: {
defaultValue?: boolean
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
})
Options
defaultValue
Default boolean value.
Type: boolean
Example:
isPublished: checkbox({ defaultValue: false })
emailVerified: checkbox({ defaultValue: true })
Database column
Postgres bool (field.boolean()).
TypeScript Type
boolean, or boolean | null when the column is nullable (a defaultValue makes it non-nullable).
timestamp()
Date/time field stored as a Postgres timestamptz.
import { timestamp } from '@opensaas/stack-core/fields'
timestamp(options?: {
defaultValue?: { kind: 'now' } | Date
isIndexed?: boolean | 'unique'
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
})
Options
defaultValue
Default timestamp value.
Type: { kind: 'now' } | Date
Values:
{ kind: 'now' }- Automatically set to current time on createDate- Specific date/time value
Example:
openedAt: timestamp({
defaultValue: { kind: 'now' },
})
publishedAt: timestamp()
There is no db.updatedAt option — a field never maintains itself on write. Automatic createdAt/updatedAt columns come from db.timestamps on the list or the config; see the Config API.
isIndexed
Database index configuration.
Type: boolean | 'unique'
Values:
true- Create non-unique index for faster queries'unique'- Create unique index (enforces uniqueness)falseor omitted - No index
isIndexed is sugar for an unnamed single-column index or unique constraint. For a named constraint (e.g. adopting a live table's existing constraint name), or one spanning more than one field, use the list's db.indexes instead — the two must not both target the same column. An index column carries no sort direction.
timestamp does not default to indexed — an explicit isIndexed: true is required, even for a field commonly used as a sort key, so an existing config's generated schema never changes without an intentional edit.
Example:
publishedAt: timestamp({
isIndexed: true,
})
Database column
Postgres timestamptz (field.column(timestamptzStringColumn)).
TypeScript Type
An ISO 8601 string, not a Date. The column's codec renders timestamptz as text in both directions, so a value survives the round trip byte for byte instead of through a local-timezone Date. Nullable unless defaultValue: { kind: 'now' } is set.
const post = await context.db.Post.where({ id }).first()
if (post?.publishedAt) {
const when = new Date(post.publishedAt)
}
Validation
The declared write type is the ISO 8601 string. The runtime validator also accepts a Date object, so a value that reaches a write through untyped code still passes.
calendarDay()
Date-only field (no time component) stored in ISO8601 format (YYYY-MM-DD).
import { calendarDay } from '@opensaas/stack-core/fields'
calendarDay(options?: {
validation?: {
isRequired?: boolean
}
defaultValue?: string
isIndexed?: boolean | 'unique'
db?: {
map?: string
isNullable?: boolean
}
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
})
Options
validation
Validation rules for the calendar day field.
Type: object
Properties:
isRequired?: boolean- Field is required on create
Example:
birthDate: calendarDay({
validation: { isRequired: true },
})
defaultValue
Default date value in ISO8601 format (YYYY-MM-DD).
Type: string
Format: YYYY-MM-DD (e.g., '2025-01-15')
Example:
startDate: calendarDay({
defaultValue: '2025-01-01',
})
isIndexed
Database index configuration.
Type: boolean | 'unique'
Values:
true- Create non-unique index for faster date queries'unique'- Create unique index (enforces uniqueness)falseor omitted - No index
isIndexed is sugar for an unnamed single-column index or unique constraint. For a named constraint (e.g. adopting a live table's existing constraint name), or one spanning more than one field, use the list's db.indexes instead — the two must not both target the same column. An index column carries no sort direction.
Example:
eventDate: calendarDay({
isIndexed: true,
})
db.map
Custom database column name.
Type: string
Example:
publishDate: calendarDay({
db: { map: 'publish_date' },
})
db.isNullable
Override nullability independent of isRequired.
Type: boolean
Default: Based on validation.isRequired (required fields are non-nullable)
Example:
endDate: calendarDay({
db: { isNullable: false },
})
Database column
A native Postgres date — the date only, with no time and no timezone.
Generated contract module:
birthDate: field.column(dateStringColumn)
startDate: field.column(dateStringColumn).optional().default('2025-01-01')
TypeScript Type
A YYYY-MM-DD string in both directions, nullable if not required. Passing a Date is a compile error, deliberately: a Date carries a time and a timezone a calendar day does not have, and the conversion between them is where off-by-one bugs live.
const person = await context.db.Person.where({ id }).first()
if (person) {
const birthDate: string = person.birthDate
}
Validation
Format: ISO8601 date string (YYYY-MM-DD)
Validation Rules:
- Must match regex:
/^\d{4}-\d{2}-\d{2}$/ - Examples:
'2025-01-15','2024-12-31','2023-07-04'
Error Messages:
- Invalid format: "Field name must be in YYYY-MM-DD format"
- Required but missing: "Field name is required"
Use Cases
- Birth dates, anniversaries, or other personal dates
- Event dates (conferences, meetings, deadlines)
- Publication dates or scheduled dates
- Any date where the time component is not relevant
Comparison with timestamp()
| Feature | calendarDay() | timestamp() |
|---|---|---|
| Time component | No (date only) | Yes (date + time + zone) |
| Database column | date | timestamptz |
| TypeScript type | YYYY-MM-DD string | ISO 8601 string |
| Use case | Birth dates, events | Points in time |
| Storage size | Smaller (date only) | Larger (includes time) |
password()
String field with automatic bcrypt hashing and secure handling.
import { password } from '@opensaas/stack-core/fields'
password(options?: {
validation?: {
isRequired?: boolean
}
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
})
Security Features
- Automatic Hashing: Plaintext passwords are automatically hashed using bcrypt with cost factor 10
- Idempotent: Already-hashed passwords are not re-hashed
- Secure Output: Query results return
HashedPasswordinstances with acompare()method - No Exposure: Only sends
{ isSet: boolean }to client (not the hash) - Excluded from default admin columns: sets
ui.listView.defaultColumn: falseby default (a presentation default, not an access control — seeui.listView); override withui: { listView: { defaultColumn: true } }to show it anyway
Options
validation
Validation rules for the password field.
Type: object
Properties:
isRequired?: boolean- Field is required on create
Example:
password: password({
validation: { isRequired: true },
})
Database column
Postgres text (field.text()), holding the hash.
TypeScript Type
string for input, HashedPassword for output — declared with the field's outputType, since the column's own codec would type it as a plain string.
Usage Example
The write is hashed by the field's own resolveInput; the read is wrapped by its resolveOutput. Neither is something the caller arranges.
await context.db.User.create({
data: { email: 'user@example.com', password: 'plaintextPassword' },
})
const user = await context.db.User.where({ email: 'user@example.com' }).first()
if (user && (await user.password.compare('plaintextPassword'))) {
// the password matches
}
.first() answers null both for a row that does not exist and for one this session cannot read, so the result is checked before password is touched.
HashedPassword API
class HashedPassword extends String {
/**
* Compare plaintext password with hashed password
* @param plaintext - The plaintext password to verify
* @returns Promise resolving to true if password matches
*/
compare(plaintext: string): Promise<boolean>
}
Important:
- Never compare password strings directly
- Always use
await password.compare(input)for verification - Empty strings and undefined values skip hashing (allows partial updates)
select()
Enum-like field with predefined options.
import { select } from '@opensaas/stack-core/fields'
select(options: {
options: Array<{ label: string; value: string }>
validation?: {
isRequired?: boolean
}
defaultValue?: string
isIndexed?: boolean | 'unique'
ui?: {
displayMode?: 'select' | 'segmented-control' | 'radio'
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
})
Options
options (required)
Array of available options.
Type: Array<{ label: string; value: string }>
Properties:
label- Display text shown to usersvalue- Actual value stored in database
Example:
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
{ label: 'Archived', value: 'archived' },
],
})
defaultValue
Default selected value (must match one of the option values).
Type: string
Example:
status: select({
options: [/* ... */],
defaultValue: 'draft',
})
validation.isRequired
Whether the field is required.
Type: boolean
ui.displayMode
UI component to use for selection.
Type: 'select' | 'segmented-control' | 'radio' Default: 'select'
Values:
'select'- Dropdown select menu'segmented-control'- Button group (good for 2-4 options)'radio'- Radio button group
isIndexed
Database index configuration.
Type: boolean | 'unique'
Values:
true- Create non-unique index for faster queries'unique'- Create unique index (enforces uniqueness)falseor omitted - No index
isIndexed is sugar for an unnamed single-column index or unique constraint. For a named constraint (e.g. adopting a live table's existing constraint name), or one spanning more than one field, use the list's db.indexes instead — the two must not both target the same column. An index column carries no sort direction. Well-defined under both the default string column and a native-enum column (db: { type: 'enum' }).
Example:
status: select({
options: [/* ... */],
isIndexed: true,
})
Database column
Postgres text by default. With db: { type: 'enum' }, a native Postgres enum type named <List><Field> (override with db.enumName) whose values must be valid identifiers.
TypeScript Type
Union of option values (e.g., 'draft' | 'published' | 'archived'), declared through the field's outputType/inputType so it holds under either column type.
relationship()
Foreign key relationship to another list.
import { relationship } from '@opensaas/stack-core/fields'
relationship(options: {
ref: string
many?: boolean
isIndexed?: boolean | 'unique'
db?: {
foreignKey?: boolean | { map?: string }
isNullable?: boolean
onDelete?: ReferentialAction
onUpdate?: ReferentialAction
}
ui?: {
displayMode?: 'select' | 'cards'
[key: string]: unknown
}
access?: FieldAccess
})
Options
ref (required)
Reference to related list in format 'ListName.fieldName' (bidirectional) or 'ListName' (list-only).
Type: string
Format: 'ListName.fieldName' where:
ListName- The target list (PascalCase)fieldName- The field on the target list that references back (optional)
Example:
// Bidirectional relationship
User: list({
fields: {
posts: relationship({
ref: 'Post.author',
many: true,
}),
},
})
Post: list({
fields: {
author: relationship({
ref: 'User.posts',
}),
},
})
// List-only relationship
Post: list({
fields: {
category: relationship({
ref: 'Category', // No field specified - creates synthetic field
}),
},
})
many
Whether this is a one-to-many relationship.
Type: boolean Default: false
Values:
true- One-to-many (e.g., User has many Posts)false- Many-to-one or one-to-one (e.g., Post has one Author)
db.foreignKey
Controls which side stores the foreign key in one-to-one relationships.
Type: boolean Default: undefined (uses alphabetical ordering)
Constraints:
- Only valid on single relationships (
many: falseor undefined) - Only valid on bidirectional relationships (ref includes target field)
- Cannot be
trueon both sides of a one-to-one relationship
Example:
// Explicit foreign key placement
User: list({
fields: {
account: relationship({
ref: 'Account.user',
db: { foreignKey: true }, // User table stores accountId
}),
},
}),
Account: list({
fields: {
user: relationship({
ref: 'User.account', // No foreign key on this side
}),
},
})
Generated contract module:
accountId: field.uuidNative().optional().unique()
account: rel.belongsTo(() => models.Account, { from: 'accountId', to: 'id' })
Account gets the matching rel.hasOne(() => models.User, { by: 'accountId' }) and no column of its own.
Default behavior: If db.foreignKey is not specified on either side, the foreign key is placed on the alphabetically first list. For example:
User ↔ Profile: Profile storesuserIdAccount ↔ Billing: Account storesbillingId
The db.foreignKey option is only needed for one-to-one relationships where you want explicit control over foreign key placement. One-to-many and many-to-one relationships automatically place the foreign key on the correct side.
Setting db.foreignKey: true on both sides of a one-to-one relationship will cause a validation error. Only one side can store the foreign key.
db.isNullable
Controls DB-level nullability of the foreign key column and its relation field, together — they can never disagree.
Type: boolean Default: true (nullable, matching every relationship generated before this option existed)
Constraints:
- Only valid on the FK-owning (single) side of a relationship — the many side has no foreign key column of its own and rejects this option
Example:
Every session genuinely belongs to a user, so the foreign key is made required:
Session: list({
fields: {
user: relationship({
ref: 'User.sessions',
db: { isNullable: false },
}),
},
})
Generated contract module:
userId: field.uuidNative()
user: rel.belongsTo(() => models.User, { from: 'userId', to: 'id' })
db.onDelete / db.onUpdate
What the database does to this row when the referenced row is deleted, or when its id changes.
Type: ReferentialAction
type ReferentialAction = 'cascade' | 'restrict' | 'noAction' | 'setNull' | 'setDefault'
Constraints:
- Only meaningful on the foreign-key-owning side — the many side owns no foreign key to act on
'setNull'writes NULL into the foreign key column, so it describes a constraint Postgres cannot satisfy alongsidedb: { isNullable: false }
Example:
Profile: list({
fields: {
user: relationship({
ref: 'User.profile',
db: { foreignKey: true, onDelete: 'cascade' },
}),
},
})
Generated contract module:
constraints.foreignKey(cols.userId, model_User.refs.id, { index: false, onDelete: 'cascade' })
ui.displayMode
UI component for selecting related items.
Type: 'select' | 'cards' Default: 'select'
Values:
'select'- Dropdown select for choosing related items'cards'- Card-based UI for managing relationships
Relationship Patterns
One-to-Many
User: list({
fields: {
posts: relationship({ ref: 'Post.author', many: true }),
},
})
Post: list({
fields: {
author: relationship({ ref: 'User.posts' }),
},
})
Many-to-One
Post: list({
fields: {
author: relationship({ ref: 'User.posts' }),
},
})
User: list({
fields: {
posts: relationship({ ref: 'Post.author', many: true }),
},
})
One-to-One
User: list({
fields: {
profile: relationship({ ref: 'Profile.user' }),
},
})
Profile: list({
fields: {
user: relationship({ ref: 'User.profile' }),
},
})
Database column
A uuid foreign key column on the owning side, plus a relation on both sides. The many side contributes no column.
TypeScript Type
Arity decides what a read hands back, not foreign-key nullability:
many: false— the related row ornull, once the read names it with.include()many: true— an array of related rows, empty when there are none
A relation is not on the row unless the read asked for it, and the arity above decides the face's nullability rather than the foreign key's — see what an include costs.
Write face
A relationship is written by connecting an id, on the foreign-key-owning side only:
await context.db.Post.create({
data: { title: 'Hello', author: { connect: { id: authorId } } },
})
await context.db.Post.update({
where: { id: postId },
data: { author: null },
})
null clears the edge — there is no disconnect, and nested create/update/delete/connectOrCreate/set are refused. Spelling one edge both ways (author and authorId in the same write) is an error. A connect target the caller cannot read makes the whole write return null, one indistinguishable answer. See Queries.
json()
Field for storing arbitrary JSON data.
import { json } from '@opensaas/stack-core/fields'
json(options?: {
validation?: {
isRequired?: boolean
}
ui?: {
placeholder?: string
rows?: number
formatted?: boolean
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<TTypeInfo>
defaultValue?: unknown
})
Options
validation
Validation rules for the JSON field.
Type: object
Properties:
isRequired?: boolean- Field is required on create
ui.placeholder
Placeholder text for the JSON input.
Type: string
ui.rows
Number of rows for textarea display.
Type: number
ui.formatted
Whether to format JSON with indentation.
Type: boolean Default: true
Usage Example
metadata: json({
validation: { isRequired: false },
ui: {
placeholder: 'Enter JSON data...',
rows: 10,
formatted: true,
},
})
A write takes a JSON value; a read returns the parsed value, not a string.
await context.db.Item.create({
data: {
metadata: {
tags: ['tag1', 'tag2'],
settings: { theme: 'dark', notifications: true },
},
},
})
const item = await context.db.Item.where({ id: itemId }).first()
if (item && typeof item.metadata === 'object' && item.metadata !== null) {
// narrow the JSON value before reading into it
}
Database column
Postgres jsonb (field.json()).
TypeScript Type
A JSON value — object, array, string, number, boolean or null. It is deliberately not narrowed to your shape, so narrow it with a type guard or a schema at the point of use rather than asserting.
A required json field means non-null: an update may omit the key, but may not set it to null.
virtual()
Computed field that is not stored in the database.
import { virtual } from '@opensaas/stack-core/fields'
virtual(options: {
type: TypeDescriptor
needs?: string[]
hooks: FieldHooks<TTypeInfo> & {
resolveOutput: NonNullable<FieldHooks<TTypeInfo>['resolveOutput']>
}
ui?: {
[key: string]: unknown
}
access?: FieldAccess
})
type is converted into the field's outputType; a virtual field has no column for the contract to type it from, so it is required. resolveOutput is required too — virtual() throws at config time without it.
Options
type (required)
TypeScript type for the virtual field output. Supports three formats:
Type: string | TypeDescriptor
Format 1: Primitive type strings (for built-in JavaScript types):
fullName: virtual({
type: 'string', // or 'number', 'boolean', 'Date', etc.
needs: ['firstName', 'lastName'],
hooks: {
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
},
})
Format 2: Import strings (for custom types):
import Decimal from 'decimal.js'
totalPrice: virtual({
type: "import('decimal.js').Decimal",
needs: ['price', 'quantity'],
hooks: {
resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity),
},
})
Format 3: Type descriptor objects (recommended for custom types):
import Decimal from 'decimal.js'
totalPrice: virtual({
type: { value: Decimal, from: 'decimal.js' },
needs: ['price', 'quantity'],
hooks: {
resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity),
},
})
// With custom name (when constructor name doesn't match export)
customField: virtual({
type: {
value: MyClass,
from: '@myorg/types',
name: 'MyExportedType', // Optional
},
needs: ['data'],
hooks: {
resolveOutput: ({ item }) => new MyClass(item.data),
},
})
TypeDescriptor interface:
type TypeDescriptor =
| string // Primitive or import string
| {
value: new (...args: any[]) => any // Constructor/class
from: string // Import path
name?: string // Optional custom name
}
Examples:
'string'- For string values'number'- For numeric values'boolean'- For boolean values'string[]'- For arrays"import('decimal.js').Decimal"- For Decimal type{ value: Decimal, from: 'decimal.js' }- Type descriptor for Decimal
The TypeScript type generator automatically collects and generates the necessary import statements when using import strings or type descriptors.
Use cases:
- Financial calculations: Use
Decimalfromdecimal.jsfor precise currency calculations - Custom data structures: Return domain-specific types from virtual fields
- Third-party libraries: Integrate types from any npm package
needs
The columns and relations the computation reads. See needs under Field Builder Contract for the full rules; on a virtual field it is the ordinary way to widen the read.
Type: string[]
Example:
byline: virtual({
type: 'string',
needs: ['title', 'author'],
hooks: {
resolveOutput: ({ item }) => `${item.title} by ${item.author?.name ?? 'unknown'}`,
},
})
opensaas generate refuses an entry that names nothing on the list, names a computed field, or reaches through a relation with a dotted path — and refuses a needs on a field with no resolveOutput hook.
hooks.resolveOutput (required)
Compute the field value.
Type: Function
Parameters:
operation- Always'query'for virtual fieldsvalue- Database value (alwaysundefinedfor virtual fields)item- Exactly this field's declared dependency set plus the list's system fields. Reading a column the field did not declare inneedsis a compile errorlistKey- The list name (e.g.,'User')fieldName- The field name (e.g.,'fullName')context- The access context
Returns: Computed value of the type specified in type option
Example:
displayName: virtual({
type: 'string',
needs: ['name', 'email'],
hooks: {
resolveOutput: ({ item }) => `${item.name} (${item.email})`,
},
})
hooks.resolveInput (optional)
Perform side effects during create/update operations. The return value is ignored — a virtual field stores nothing.
Type: Function (optional)
Parameters: the ordinary field resolveInput arguments — { listKey, fieldKey, operation, inputData, item, resolvedData, context }. See Hooks.
Use cases:
- Sync data to external API
- Trigger webhooks
- Update related records
Example:
syncToExternal: virtual({
type: 'boolean',
hooks: {
resolveInput: async ({ operation, item }) => {
if (operation === 'update') {
await syncToExternalAPI(item)
}
return undefined
},
resolveOutput: () => true,
},
})
Key Characteristics
- No database storage: a virtual field contributes no column — its contract descriptor is
kind: 'computed' - Computed wherever it is returned: at the root of a read and at every nested level alike, not only when named
- Type safety: the TypeScript face comes from
type, which becomes the field'soutputType - Explicit dependencies:
needsis the only way the computation widens the read it runs inside
Usage Examples
Read-Only Computed Field
User: list({
fields: {
firstName: text(),
lastName: text(),
fullName: virtual({
type: 'string',
needs: ['firstName', 'lastName'],
hooks: {
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
},
}),
},
})
Reading it back:
const user = await context.db.User.where({ id }).first()
if (user) {
console.log(user.fullName)
}
Computed From a Relation
Order: list({
fields: {
lineItems: relationship({ ref: 'LineItem.order', many: true }),
itemCount: virtual({
type: 'number',
needs: ['lineItems'],
hooks: {
resolveOutput: ({ item }) => item.lineItems.length,
},
}),
},
})
lineItems is fetched because itemCount declared it, scoped by the Access Filter exactly as a caller-named include would be, and stripped from the result unless the caller named it too.
Write Side Effects
Post: list({
fields: {
title: text(),
content: text(),
searchIndexSync: virtual({
type: 'boolean',
hooks: {
resolveInput: async ({ operation, item }) => {
if (operation === 'create' || operation === 'update') {
await updateSearchIndex(item)
}
return undefined
},
resolveOutput: () => true,
},
}),
},
})
Database column
None — a virtual field creates no column.
TypeScript Type
The type given in type, which the builder converts into the field's outputType.
Validation
Virtual fields do not accept input and cannot be validated. The getZodSchema method returns z.never().
Common Field Options
All field types support these common options:
access
Field-level access control rules.
Type: FieldAccess
type FieldAccess = {
read?: FieldAccessControl
create?: FieldAccessControl
update?: FieldAccessControl
}
type FieldAccessControl = (args: {
session: Session | null
item: T // present on `read`/`update`; absent on `create`
inputData: TInput // present on `create`/`update`
context: AccessContext
operation: 'read' | 'create' | 'update'
}) => boolean | Promise<boolean>
A field rule returns a boolean only, and the runtime enforces it: a non-boolean result throws InvalidFieldAccessResultError rather than defaulting to allow. This is what separates FieldAccessControl from the operation-level AccessControl, which may also return a filter to scope rows — a filter is meaningless per field, so an operation rule is not reusable in these slots.
Example:
internalNotes: text({
access: {
read: ({ session }) => session?.role === 'admin',
create: ({ session }) => session?.role === 'admin',
update: ({ session }) => session?.role === 'admin',
},
})
See: Access Control guide for details
hooks
Field-level hooks for data transformation and side effects.
Type: FieldHooks<TTypeInfo, TFieldKey> — the field's slot is typed from the generated Lists.<List>.TypeInfo, so item and resolvedData are the real shapes for that list.
type FieldHooks<TTypeInfo, TFieldKey> = {
resolveInput?: (args: FieldResolveInputHookArgs<TTypeInfo, TFieldKey>) => ValueOrUndefined
validate?: (args: FieldValidateHookArgs<TTypeInfo, TFieldKey>) => Promise<void> | void
beforeOperation?: (
args: FieldBeforeOperationHookArgs<TTypeInfo, TFieldKey>,
) => Promise<void> | void
afterOperation?: (args: FieldAfterOperationHookArgs<TTypeInfo, TFieldKey>) => Promise<void> | void
beforeTransaction?: (
args: FieldBeforeTransactionHookArgs<TTypeInfo, TFieldKey>,
) => Promise<void> | void
afterTransaction?: (
args: FieldAfterTransactionHookArgs<TTypeInfo, TFieldKey>,
) => Promise<void> | void
resolveOutput?: (args: FieldResolveOutputHookArgs<TTypeInfo, TFieldKey>) => ValueOrUndefined
}
Every write-side hook receives { listKey, fieldKey, operation, inputData, item, resolvedData, context } — the field's own value is at resolvedData[fieldKey], not a separate inputValue argument. resolveOutput receives { operation, value, item, listKey, fieldName, context }. Execution order, the transaction-boundary pair, and what each argument holds per operation are in Hooks.
Hook Types
resolveInput
Transform field value before database write.
When called: During create and update operations, after list-level resolveInput
Use cases: Hash passwords, normalize data, compute derived values
Example:
Deriving a slug from the title when the write did not supply one:
slug: text({
hooks: {
resolveInput: ({ resolvedData, fieldKey }) => {
const value = resolvedData[fieldKey]
if (value) return value
return resolvedData.title?.toLowerCase().replace(/\s+/g, '-')
},
},
})
resolveOutput
Transform field value after database read.
When called: During query operations, after field-level access control
Use cases: Wrap sensitive data, format values, compute client-safe representations
Example:
profileImage: text({
hooks: {
resolveOutput: ({ value }) => (value ? `https://cdn.example.com/${value}` : null),
},
})
beforeOperation
Side effects before the database operation. Does NOT modify data.
When called: Before create, update, or delete, after validation, inside the write's transaction
Use cases: Logging, pre-operation checks
The argument is a union discriminated by operation — create carries no item, delete carries no resolvedData — so narrow before destructuring:
status: select({
options: [{ label: 'Draft', value: 'draft' }],
hooks: {
beforeOperation: async (args) => {
if (args.operation !== 'update') return
const { fieldKey, item, resolvedData } = args
const next = resolvedData[fieldKey]
if (item[fieldKey] !== next) {
await auditLog.record({ event: 'status_change', from: item[fieldKey], to: next })
}
},
},
})
afterOperation
Side effects after the database operation. Does NOT modify data.
When called: After create, update or delete, inside the write's transaction
Use cases: Cache invalidation, webhooks, cleanup
Also a union discriminated by operation: create carries no originalItem, delete carries no item. Cleaning up a replaced file by comparing the row before and after:
thumbnail: text({
hooks: {
afterOperation: async (args) => {
if (args.operation === 'delete') {
await deleteFromCDN(args.originalItem[args.fieldKey])
return
}
if (args.operation === 'update') {
const previous = args.originalItem[args.fieldKey]
if (previous && previous !== args.item[args.fieldKey]) {
await deleteFromCDN(previous)
}
}
},
},
})
beforeTransaction and afterTransaction are the pair that run outside the write's transaction, for side effects that must not hold one open and cannot be rolled back with it.
See: Hooks guide for execution order and patterns
ui
UI-specific configuration passed to field components.
Type: object
Common properties:
component?: React.Component- Custom field component (per-field override)fieldType?: string- Reference to globally registered field typevalueForClientSerialization?: (args) => unknown- Transform value before sending to browser- Additional field-type-specific options
Example:
content: text({
ui: {
displayMode: 'textarea',
placeholder: 'Enter your content...',
rows: 10,
// Custom UI options
spellcheck: true,
autocomplete: 'off',
},
})
Custom Component Example:
import { SlugField } from './components/SlugField'
slug: text({
ui: {
component: SlugField, // Use custom component for this field only
},
})
See: Custom Fields guide for creating custom components
defaultValue
Default value when creating new items.
Type: Varies by field type
Example:
status: select({
options: [/* ... */],
defaultValue: 'draft',
})
score: integer({ defaultValue: 0 })
isPublished: checkbox({ defaultValue: false })
createdAt: timestamp({ defaultValue: { kind: 'now' } })
Field Builder Contract
Every field configuration object declares what the generators and validators delegate to it for. opensaas generate refuses a config where a field leaves out a member it owes, naming the list, the field and the member.
getZodSchema(fieldName, operation)
Returns Zod schema for input validation.
Signature:
getZodSchema(
fieldName: string,
operation: 'create' | 'update'
): z.ZodTypeAny
Parameters:
fieldName- Field name (used in error messages)operation- Whether this is a create or update operation
Returns: Zod schema for validating input
Example implementation:
getZodSchema: (fieldName, operation) => {
const baseSchema = z.string({
message: `${fieldName} must be text`,
})
const withValidation = options?.validation?.isRequired
? baseSchema.min(1, { message: `${fieldName} is required` })
: baseSchema.optional()
return withValidation
}
getContractField(fieldName, listKey, config)
Describes what the field contributes to the generated contract (ADR-0040, ADR-0049): its stored column or columns, its relation and foreign key, or nothing at all.
Signature:
getContractField(
fieldName: string,
listKey: string,
config: OpenSaasConfig,
): ContractFieldDescriptor
Parameters:
fieldName- The field's config keylistKey- The owning list's keyconfig- The full config (a relationship resolves its target and foreign-key ownership from it)
Returns: one of four descriptor shapes:
kind | For | Carries |
|---|---|---|
'column' | a stored field backed by one column | the column descriptor, inline |
'columns' | one field spanning several columns | columns: ContractColumnDescriptor[] |
'relation' | a relationship | the relation and the foreign key it owns |
'computed' | a virtual field, which stores nothing | nothing |
A column descriptor carries name, a pack-qualified type ({ pack, type, args? }), nullable, and optionally nativeType, map, unique, index, default and enum.
Example implementation:
getContractField: (fieldName) => ({
kind: 'column',
name: fieldName,
type: { pack: 'pg', type: 'text' },
nullable: !options?.validation?.isRequired,
})
outputType
The field's TypeScript read face, when it differs from the type its contract column's codec already gives it (ADR-0052). Not a method — a value on the field config, a TypeDescriptor.
Signature:
outputType?: TypeDescriptor
TypeDescriptor is itself string | { value: SomeClass; from: 'pkg'; name?: 'Exported' }. A type string is used as written; import('pkg').Name is inlined, so no separate import declaration is needed. The object form is normalised to the same thing.
Required on a virtual field and on a field whose descriptor is kind: 'columns': neither has a single column to be typed from, so without it the field would type its consumers as unknown. opensaas generate refuses a config where one is missing, naming the list and field.
Optional on a single-column stored field, where it is an override of the codec's own type.
Example:
outputType: "import('@opensaas/stack-storage').ImageMetadata | null"
inputType
The field's TypeScript write face, when it differs from the read face — an image() accepts a File but reads back metadata.
Signature:
inputType?: TypeDescriptor
opensaas generate never requires it. On a single-column field, absence means the column's own input type, which is correct for every field that reads and writes the same shape. A kind: 'columns' field has no single column for that to name, so declare inputType alongside outputType there.
needs
The immediate columns and relations this field's resolveOutput hook cannot compute without.
Signature:
needs?: string[]
Entries are column keys on the same list: stored columns and immediate relationship fields, spelled by their config key.
The rules:
- Same list only. No dotted paths, no reaching through a relation, and never a computed field.
opensaas generaterefuses an entry that names none of the above. - Requires a
resolveOutputhook. Aneedson a field without one is dead config and is refused at generate time, since nothing could consume the declaration. - One hop, non-transitive. A declared relation is fetched; what that relation's own computed fields need is that relation's business.
- Private plumbing, not an implicit include. Each dependency is fetched wherever the field is computed — at the root of a read and at every nested level alike — and then stripped from the result unless the caller named it too. Adding or removing one changes the field's implementation, never the shape of every read of the list.
- Scoped like any other read. A relation dependency goes through the Access Filter exactly as a caller-named include does. A session that cannot query it does not get it fetched, and the hook sees nothing in its place.
The generated Lists.<List>.TypeInfo narrows the item a resolveOutput receives to exactly the declared set plus the list's system fields, so reading an undeclared column is a compile error in a config annotated with it.
Example:
lineItems: relationship({ ref: 'LineItem.order', many: true }),
itemCount: virtual({
type: 'number',
needs: ['lineItems'],
hooks: {
resolveOutput: ({ item }) => item.lineItems.length,
},
}),
Multi-column members
A field whose descriptor is kind: 'columns' owes three more members, because the engine cannot derive the mapping between one logical value and several physical columns:
| Member | Direction | Purpose |
|---|---|---|
getColumnNames(fieldName) | — | The physical columns this field owns, so the read can strip the raw parts |
assembleColumns(fieldName, row) | read | Build the logical value from the row's per-part columns |
splitColumns(fieldName, value) | write | Split the logical value back into per-part columns for the write payload |
Both transforms must stay pure. assembleColumns runs before field visibility; splitColumns runs after resolveInput.
getVectorColumn(fieldName)
The opt-in that makes a field reachable by nearest(): it describes the field's stored vector column, its dimensions, and the distance function a search measures with. A field that does not answer this is not searchable, which is what makes nearest('title', …) a refusal rather than a query the database rejects.
getFilterSpec(fieldName, listKey, config)
Optional and additive: the field's filtering capability for the admin UI's filter builder. A field that omits it is simply not filterable and is never suggested — password, json and virtual all omit it. The returned toCondition mapper must stay pure; its output is ANDed with the access filter.
Creating Custom Field Types
Custom field types must implement the BaseFieldConfig interface:
import type {
BaseFieldConfig,
ContractFieldDescriptor,
FieldKeys,
TypeInfo,
} from '@opensaas/stack-core/extend'
import { z } from 'zod'
export type EmailField<
TTypeInfo extends TypeInfo = TypeInfo,
TKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
> = BaseFieldConfig<TTypeInfo, TKey> & {
type: 'email'
validation?: { isRequired?: boolean }
requireVerification?: boolean
}
export function email<
TTypeInfo extends TypeInfo = TypeInfo,
TKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
>(options?: Omit<EmailField<TTypeInfo, TKey>, 'type'>): EmailField<TTypeInfo, TKey> {
return {
type: 'email',
...options,
getZodSchema: (fieldName, operation) => {
const schema = z.string().email({
message: `${fieldName} must be a valid email`,
})
return options?.validation?.isRequired ? schema : schema.optional()
},
getContractField: (fieldName): ContractFieldDescriptor => ({
kind: 'column',
name: fieldName,
type: { pack: 'pg', type: 'text' },
nullable: !options?.validation?.isRequired,
}),
}
}
Key principles:
- Extend
BaseFieldConfigwith your field's options - Implement
getZodSchemaandgetContractField; addoutputTypewhen the field has no single column to be typed from - Use field-level hooks for data transformation
- Field types are self-contained (no switch statements in core)
See: Custom Fields guide for complete tutorial
Third-Party Field Types
Rich Text (@opensaas/stack-tiptap)
import { richText } from '@opensaas/stack-tiptap/fields'
content: richText({
ui: {
minHeight: 300,
maxHeight: 800,
placeholder: 'Write your content...',
},
})
See: Tiptap package documentation
Image & File (@opensaas/stack-storage)
storage names a provider declared in the config's storage block, not a provider type.
import { image, file } from '@opensaas/stack-storage/fields'
avatar: image({
storage: 'images',
validation: {
maxFileSize: 5 * 1024 * 1024,
acceptedMimeTypes: ['image/jpeg', 'image/png'],
},
transformations: {
thumbnail: { width: 150, height: 150 },
large: { width: 1200, height: 1200 },
},
})
document: file({
storage: 'documents',
validation: {
maxFileSize: 10 * 1024 * 1024,
acceptedMimeTypes: ['application/pdf', 'text/plain'],
},
})
Both write a File and read back metadata, so they declare their own outputType and inputType. In multi-column mode their descriptor is kind: 'columns' and they carry the three multi-column members above.
See: Storage package documentation
Validation System
Validation Rules
Field validation is defined in the validation object:
text({
validation: {
isRequired: true,
length: { min: 3, max: 100 },
},
})
integer({
validation: {
isRequired: true,
min: 0,
max: 100,
},
})
Validation Errors
Validation errors include:
- Field name (formatted for display)
- Error message
- Validation rule that failed
Example error:
{
"field": "title",
"message": "Title must be at least 3 characters"
}
Validation Execution Order
- List-level and field-level
resolveInputhooks - List-level and field-level
validatehooks - Field-level Zod schema validation (from
getZodSchema()) - Field-level access control (filter writable fields)
See: Hooks for the full order, including the transaction-boundary pair.
Best Practices
1. Choose Appropriate Field Types
// ✅ Good: Use integer for numbers
age: integer({ validation: { min: 0, max: 150 } })
// ❌ Bad: Don't use text for numbers
age: text({ validation: { length: { max: 3 } } })
2. Always Add Validation
// ✅ Good: Validate required fields and constraints
email: text({
isIndexed: 'unique',
validation: {
isRequired: true,
length: { max: 255 },
},
})
// ❌ Bad: No validation
email: text()
3. Use Relationships for Foreign Keys
// ✅ Good: Use relationship field
author: relationship({ ref: 'User.posts' })
// ❌ Bad: Don't manually manage IDs with text
authorId: text()
4. Add Indexes for Query Performance
// ✅ Good: Index fields used in queries
email: text({
isIndexed: 'unique',
validation: { isRequired: true },
})
slug: text({
isIndexed: true,
})
5. Use Hooks for Transformation
// ✅ Good: normalise in a field hook, where every write path goes through it
email: text({
hooks: {
resolveInput: ({ resolvedData, fieldKey }) => resolvedData[fieldKey]?.toLowerCase().trim(),
},
})
// ❌ Bad: normalise at each call site, where one path will eventually forget
6. Declare What a Computed Field Reads
// ✅ Good: the read fetches the dependency wherever the field is computed
itemCount: virtual({
type: 'number',
needs: ['lineItems'],
hooks: { resolveOutput: ({ item }) => item.lineItems.length },
})
// ❌ Bad: works in the admin UI, returns nothing from a narrowed read
itemCount: virtual({
type: 'number',
hooks: { resolveOutput: ({ item }) => item.lineItems.length },
})
Next Steps
- Field Types Guide - Usage examples and patterns
- Queries - Reading and writing field values
- Hooks System - Field-level data transformation
- Access Control - Field-level security
- Custom Fields Guide - Create custom field types
- Config API - Complete configuration reference