Field Types
Stack provides a comprehensive set of field types for building your schema. Each field type includes validation, access control, and UI configuration options.
A field is the unit the whole stack delegates to. It decides its own validation schema, the column (or columns) it contributes to the generated contract, and the TypeScript faces a read and a write see — so adding a field type never means editing core. The exact members it owes are in The Field Builder Contract below, and described member by member in the Fields API reference.
Field values are read through the composed read described in Queries and gated by Access Control; options that shape the generated schema at the list or config level live in the Config API reference.
Core Field Types
Text Field
String field with validation options:
import { text } from '@opensaas/stack-core/fields'
fields: {
title: text({
validation: {
isRequired: true,
length: { min: 3, max: 100 },
},
ui: {
displayMode: 'input', // or 'textarea'
},
}),
description: text({
ui: {
displayMode: 'textarea',
},
}),
}
Options:
validation.isRequired: Booleanvalidation.length.min: Minimum lengthvalidation.length.max: Maximum lengthui.displayMode:'input'or'textarea'
Integer Field
Number field with validation:
import { integer } from '@opensaas/stack-core/fields'
fields: {
age: integer({
validation: {
isRequired: true,
min: 0,
max: 150,
},
}),
score: integer({
validation: {
min: 0,
max: 100,
},
defaultValue: 0,
}),
rank: integer({
isIndexed: true,
}),
}
Options:
validation.isRequired: Booleanvalidation.min: Minimum valuevalidation.max: Maximum valuedefaultValue: Default integer valueisIndexed: Boolean or'unique'for indexing
BigInt Field
64-bit integer field for values that overflow integer()'s 32-bit Int — a millisecond epoch (Date.now()), a Snowflake ID, or anything beyond Number.MAX_SAFE_INTEGER:
import { bigInt } from '@opensaas/stack-core/fields'
fields: {
occurredAtMs: bigInt({
validation: { isRequired: true, min: 0n },
}),
sequence: bigInt({ defaultValue: 0n }),
}
Options:
validation.isRequired: Booleanvalidation.min: Minimum value (asbigint)validation.max: Maximum value (asbigint)defaultValue: Default value (bigint | number | string)isIndexed: Boolean or'unique'for indexing
Create/update accept a bigint, an integer number, or a numeric string, and always coerce to bigint. A number above Number.MAX_SAFE_INTEGER is rejected rather than coerced — pass a bigint or a string for values beyond that range.
TypeScript type is bigint; bigint isn't JSON-serialisable, so an MCP CRUD tool renders the value as a decimal string instead (see ADR-0029).
Decimal Field
Precise decimal field ideal for currency, financial calculations, and measurements:
import { decimal } from '@opensaas/stack-core/fields'
fields: {
price: decimal({
precision: 10,
scale: 2,
validation: {
isRequired: true,
min: '0',
max: '999999.99',
},
}),
latitude: decimal({
precision: 18,
scale: 8,
db: {
map: 'lat',
isNullable: false,
},
}),
balance: decimal({
precision: 18,
scale: 4,
defaultValue: '0.0000',
isIndexed: true,
}),
}
Options:
precision: Maximum number of digits (default: 18)scale: Maximum decimal places (default: 4)validation.isRequired: Booleanvalidation.min: Minimum value (as string for precision)validation.max: Maximum value (as string for precision)defaultValue: Default value as stringdb.map: Custom database column namedb.isNullable: Override nullability (default: based onisRequired)isIndexed: Boolean or'unique'for indexing
Database column:
A Postgres numeric column carrying the declared precision and scale. In the generated contract module that is:
price: field.column(numericColumn(10, 2))
TypeScript type:
A decimal string, in both directions. The value never passes through JavaScript's number, which is the whole point of the field: 0.1 + 0.2 is a rounding error, '0.1' is not. Arithmetic is yours to do with a decimal library, constructed from the string the read hands back:
import { Decimal } from 'decimal.js'
const product = await context.db.Product.create({
data: { name: 'Widget', price: '19.99' },
})
if (product) {
const unit = new Decimal(product.price)
const withTax = unit.times('1.1').toDecimalPlaces(2)
}
The create returns null when access is denied, so the value is null-checked before it is read — see Access Control.
Always use string values for validation.min, validation.max, and defaultValue. A JavaScript number literal has already lost precision before the field sees it.
Checkbox Field
Boolean field:
import { checkbox } from '@opensaas/stack-core/fields'
fields: {
isPublished: checkbox({
defaultValue: false,
}),
emailVerified: checkbox(),
}
Options:
defaultValue: Boolean default value
Timestamp Field
Date/time field, stored as a Postgres timestamptz:
import { timestamp } from '@opensaas/stack-core/fields'
fields: {
publishedAt: timestamp(),
openedAt: timestamp({
defaultValue: { kind: 'now' },
}),
scheduledAt: timestamp({
isIndexed: true,
}),
}
Options:
defaultValue:{ kind: 'now' }for a database-side current timestamp, or a fixedDateisIndexed: Boolean or'unique'for indexing (not indexed by default)
Reads and writes exchange an ISO 8601 string, not a Date — the column's codec renders timestamptz as text so the value survives a round trip unchanged. Pass new Date().toISOString() where you would have passed a Date.
Automatic createdAt / updatedAt
There is no per-field auto-update option. A list gets both timestamps together, from db.timestamps in the Config API — set at config level for every list, or per list to override it. It is off by default. createdAt takes a database now() default; updatedAt is maintained by the stack on write. Declaring your own createdAt or updatedAt field replaces the automatic column of that name.
Calendar Day Field
Date-only field (no time component) stored in ISO8601 format:
import { calendarDay } from '@opensaas/stack-core/fields'
fields: {
birthDate: calendarDay({
validation: { isRequired: true },
}),
startDate: calendarDay({
defaultValue: '2025-01-01',
db: { map: 'start_date' },
}),
eventDate: calendarDay({
isIndexed: true,
}),
endDate: calendarDay({
db: { isNullable: false },
}),
}
Options:
validation.isRequired: Boolean - require the fielddefaultValue: Default date string in ISO8601 format (YYYY-MM-DD)db.map: Custom database column namedb.isNullable: Override nullability (default: based onisRequired)isIndexed: Boolean or'unique'for indexing
Database column:
A native Postgres date column — the date only, with no time and no timezone. In the generated contract module:
birthDate: field.column(dateStringColumn)
TypeScript type:
A YYYY-MM-DD string, in both directions. Passing a Date is a compile error, which is deliberate: a Date carries a time and a timezone that a calendar day does not have, and converting one is where off-by-one bugs come from.
Usage Example:
const event = await context.db.Event.create({
data: {
name: 'Annual Conference',
startDate: '2025-06-15',
endDate: '2025-06-17',
},
})
const upcoming = await context.db.Event.where({ startDate: { gte: '2025-01-01' } })
.orderBy({ startDate: 'asc' })
.limit(20)
.all()
create returns null when access is denied and .all() returns [] — see Queries for the full set of terminals and what each returns on denial.
The calendarDay field is ideal for dates without time components like birth dates, event dates, deadlines, or publish dates. Use the timestamp field when you need both date and time information.
Always use ISO8601 date format (YYYY-MM-DD) when setting values. The field validates the format and will reject invalid dates.
When to use:
- 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
When not to use:
- Timestamps with specific times (use
timestampfield instead) - Date ranges that need precise time boundaries
- Audit trails that need exact timestamps
Password Field
String field that hashes on write and never serialises its hash:
import { password } from '@opensaas/stack-core/fields'
fields: {
password: password({
validation: {
isRequired: true,
},
}),
}
Options:
validation.isRequired: Boolean
That is the whole of PasswordField.validation. There is no length here — a minimum password length is an auth concern, set through authPlugin({ emailAndPassword: { minPasswordLength } }).
A read returns a HashedPassword — a string subclass with a compare(plaintext) method — and serialisation (JSON.stringify, the admin UI) redacts it to { isSet: boolean }, so the hash never reaches a browser.
Redaction on serialisation is not an access denial. A password field is still readable by any caller context.db lets through; if the hash should never reach application code either, deny it with field-level access.read — see Access Control.
Select Field
Enum field with predefined options:
import { select } from '@opensaas/stack-core/fields'
fields: {
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
{ label: 'Archived', value: 'archived' },
],
defaultValue: 'draft',
validation: {
isRequired: true,
},
ui: {
displayMode: 'select', // or 'radio', 'segmented-control'
},
}),
category: select({
options: [{ label: 'News', value: 'news' }],
isIndexed: true,
}),
}
Options:
options: Array of{ label, value }pairsdefaultValue: Default selected valuevalidation.isRequired: Booleanui.displayMode:'select'|'radio'|'segmented-control'isIndexed: Boolean or'unique'for indexing — well-defined under both the default string column and a native-enum column (db: { type: 'enum' })
Relationship Field
Foreign key relationship:
import { relationship } from '@opensaas/stack-core/fields'
fields: {
// One-to-many (User has many posts)
posts: relationship({
ref: 'Post.author',
many: true,
}),
// Many-to-one (Post belongs to one user)
author: relationship({
ref: 'User.posts',
}),
// One-to-one with explicit foreign key placement
account: relationship({
ref: 'Account.user',
db: { foreignKey: true }, // This side stores the foreign key
}),
}
Options:
ref: String in format'ListName.fieldName'(bidirectional) or'ListName'(list-only)many: Boolean - true for one-to-many relationshipsdb.foreignKey: Boolean, or{ map }to rename the foreign key column — controls which side stores the foreign key in one-to-one relationshipsdb.isNullable: Boolean - makes the foreign key column required (FK-owning side only)db.onDelete/db.onUpdate:ReferentialAction- what the database does to this row when the referenced row is deleted or its id changes
A relationship is written by connecting an id, not by nesting a create: { author: { connect: { id } } } on the foreign-key-owning side, or null to clear the edge. See Queries.
One-to-One Relationships
For one-to-one relationships, only one side should store the foreign key. Use db.foreignKey to control placement:
// Explicit foreign key placement
User: list({
fields: {
account: relationship({
ref: 'Account.user',
db: { foreignKey: true }, // User stores accountId
}),
},
}),
Account: list({
fields: {
user: relationship({
ref: 'User.account', // No foreign key on this side
}),
},
}),
User gets a accountId column and a belongsTo relation; Account gets the matching hasOne.
Default behavior: If db.foreignKey is not specified, the foreign key is placed on the alphabetically first list. For example, in a User ↔ Profile relationship, Profile would store the userId.
You cannot set db.foreignKey: true on both sides of a one-to-one relationship. opensaas generate refuses the config if you do.
Referential Actions
What the database does when the referenced row goes is declared on the foreign-key-owning side, as a typed db.onDelete / db.onUpdate — there is no escape hatch that edits generated schema text. A self-referential parent link that should detach its children rather than take them with it:
fields: {
parent: relationship({
ref: 'Category.children',
db: { foreignKey: true, onDelete: 'setNull', onUpdate: 'cascade' },
}),
children: relationship({ ref: 'Category.parent', many: true }),
}
The five actions are 'cascade', 'restrict', 'noAction', 'setNull' and 'setDefault', and they are re-emitted verbatim onto the generated foreign key. 'setNull' writes NULL into the foreign key column, so pairing it with db: { isNullable: false } describes a constraint Postgres cannot satisfy.
Constraints spanning more than one field — a unique pair, a composite index — are a list-level concern rather than a field one: see db.indexes in the Config API.
Virtual Field
Computed field that is not stored in the database:
import { virtual } from '@opensaas/stack-core/fields'
fields: {
firstName: text(),
lastName: text(),
// Computed from other fields
fullName: virtual({
type: 'string', // TypeScript output type
needs: ['firstName', 'lastName'],
hooks: {
resolveOutput: ({ item }) => {
return `${item.firstName} ${item.lastName}`
},
},
}),
// External API sync example
syncStatus: virtual({
type: 'boolean',
hooks: {
resolveInput: async ({ item }) => {
// Side effect: sync to external API
await syncToExternalAPI(item)
return undefined // Don't store anything
},
resolveOutput: () => true,
},
}),
}
Options:
type: TypeScript type string, import string, or type descriptor (see Custom Scalar Types below)needs: The columns and relations the computation reads (see Declared Dependencies below)hooks.resolveOutput: Required - Compute field value from other fieldshooks.resolveInput: Optional - Side effects during create/update
Declared Dependencies (needs)
A resolveOutput hook only sees what the read actually fetched. needs is how a computed field says what that must include, so the computation does not silently work in the admin UI (which reads everything) and return undefined from a narrowed read.
fields: {
title: text({ validation: { isRequired: true } }),
lineItems: relationship({ ref: 'LineItem.order', many: true }),
summary: virtual({
type: 'string',
needs: ['title', 'lineItems'],
hooks: {
resolveOutput: ({ item }) => `${item.title} (${item.lineItems.length} items)`,
},
}),
}
The rules are narrow on purpose:
- Entries are column keys on this same list — stored columns and immediate relationship fields. No dotted paths, no reaching through a relation, never another computed field.
opensaas generaterefuses an entry that names none of those, and refuses aneedson a field with noresolveOutputhook, since nothing could consume it. - One hop, and non-transitive. A declared relation is fetched; what that relation's own computed fields need is that relation's business.
- A dependency is private plumbing, not an implicit include. It 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 this field's implementation, never the shape of every read of the list.
- A relation dependency is scoped by the Access Filter exactly like a caller-named one. A session that cannot query the relation does not get it fetched, and the hook sees nothing in its place — so write the hook to tolerate that.
The cost of computing on every read, and the shape of the item a hook is handed, are covered in Hooks.
Custom Scalar Types
Virtual fields support custom scalar types (like Decimal for financial precision) through three approaches:
1. Primitive type strings (for built-in JavaScript types):
fullName: virtual({
type: 'string',
needs: ['firstName', 'lastName'],
hooks: {
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
},
})
2. Import strings (for custom types, explicit format):
import Decimal from 'decimal.js'
totalPrice: virtual({
type: "import('decimal.js').Decimal",
needs: ['price', 'quantity'],
hooks: {
resolveOutput: ({ item }) => {
return new Decimal(item.price).times(item.quantity)
},
},
})
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 }) => {
return new Decimal(item.price).times(item.quantity)
},
},
})
The TypeScript type generator automatically collects and generates the necessary import statements. This enables precise financial calculations and integration with third-party types while maintaining full type safety.
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
Key Features:
- Does not create a database column
- Computed via
resolveOutputon every read - Can combine data from multiple fields
- Useful for derived values, computed properties, and external sync
- Supports custom scalar types for financial precision
Usage Example:
const user = await context.db.User.where({ id }).first()
if (user) {
console.log(user.fullName)
}
.first() returns null when the row does not exist or the session cannot read it — one indistinguishable answer, so the result is null-checked before the computed value is read.
.select() narrows a read to named fields of this list, computed ones included, and the engine honours it exactly — selecting a computed field widens the underlying query to fetch that field's declared dependencies, then strips them back out. Relations are not selectable; they arrive through .include(). See Queries.
JSON Field
Field for storing arbitrary JSON data:
import { json } from '@opensaas/stack-core/fields'
fields: {
metadata: json({
validation: { isRequired: false },
ui: {
placeholder: 'Enter JSON data...',
rows: 10,
formatted: true,
},
}),
settings: json({
validation: { isRequired: true },
}),
}
Options:
validation.isRequired: Booleanui.placeholder: Placeholder textui.rows: Number of textarea rowsui.formatted: Format JSON with indentation
Stored in a Postgres jsonb column; reads and writes exchange a JSON value (object, array, string, number, boolean or null). A required json field means non-null: an update may omit the key, but may not set it to null.
Third-Party Field Types
Rich Text Field
From @opensaas/stack-tiptap:
import { richText } from '@opensaas/stack-tiptap/fields'
fields: {
content: richText({
ui: {
minHeight: 300,
maxHeight: 800,
},
}),
}
See the Tiptap package documentation for more details.
Image Field
From @opensaas/stack-storage:
import { image } from '@opensaas/stack-storage/fields'
fields: {
avatar: image({
storage: 'images',
validation: {
maxFileSize: 5 * 1024 * 1024,
acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
}),
}
storage names a provider declared in the config's storage block, not a provider type. See the Storage package documentation for more details.
Common Field Options
All field types support these common options:
Database Configuration
Control how fields are mapped to database columns:
text({
db: {
map: 'custom_column_name', // Custom database column name
},
})
The db.map option renames the underlying column without touching the field name. This is useful for:
- Legacy database compatibility: Match existing column naming conventions
- Database naming standards: Use snake_case in the database while using camelCase in code
Example:
fields: {
firstName: text({
validation: { isRequired: true },
db: { map: 'first_name' }, // Database column: first_name
}),
emailAddress: text({
isIndexed: 'unique',
db: { map: 'email' }, // Database column: email
}),
}
Generated contract module:
firstName: field.text().column('first_name')
emailAddress: field.text().optional().column('email').unique()
The db.map option affects only the database column name. Your application code continues to use the field name defined in the config (e.g., firstName, emailAddress), and so does the Where vocabulary.
Relationship Foreign Key Mapping
For relationship fields, use db.foreignKey to control foreign key placement and column naming:
author: relationship({
ref: 'User.posts',
db: {
foreignKey: { map: 'author_user_id' }, // Custom foreign key column name
},
})
Generated contract module:
authorId: field.uuidNative().optional().column('author_user_id')
The relation field stays author in your code and in the Where vocabulary; only the physical column moves.
Default behavior: When db.foreignKey is true (without map), the foreign key column defaults to the field name:
author: relationship({
ref: 'User.posts',
db: { foreignKey: true },
})
Generated contract module:
authorId: field.uuidNative().optional().column('author')
For list-only relationships (ref without field name), the foreign key column automatically maps to the field name for consistency with Keystone's behavior.
Access Control
text({
access: {
read: ({ session }) => !!session,
create: ({ session }) => !!session,
update: ({ session, item }) => session?.userId === item.authorId,
},
})
Hooks
text({
hooks: {
resolveInput: async ({ resolvedData, fieldKey }) => {
// Transform input data
return resolvedData[fieldKey]?.toLowerCase()
},
// `resolveOutput` names the field `fieldName`; every other field hook
// names it `fieldKey`.
resolveOutput: async ({ item, fieldName }) => {
// Transform output data
return item[fieldName]?.toUpperCase()
},
},
})
UI Configuration
text({
ui: {
label: 'Custom Label',
description: 'Help text shown below the field',
placeholder: 'Enter text here...',
// Field-type-specific options
},
})
Creating Custom Field Types
You can create custom field types by implementing the BaseFieldConfig interface:
import type {
BaseFieldConfig,
ContractFieldDescriptor,
FieldKeys,
TypeInfo,
} from '@opensaas/stack-core/extend'
import { z } from 'zod'
export type SlugField<
TTypeInfo extends TypeInfo = TypeInfo,
TKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
> = BaseFieldConfig<TTypeInfo, TKey> & {
type: 'slug'
from?: string // Field to generate slug from
}
export function slug<
TTypeInfo extends TypeInfo = TypeInfo,
TKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
>(options?: Omit<SlugField<TTypeInfo, TKey>, 'type'>): SlugField<TTypeInfo, TKey> {
return {
type: 'slug',
...options,
getZodSchema: (fieldName, operation) => {
return z
.string()
.regex(/^[a-z0-9-]+$/)
.optional()
},
getContractField: (fieldName): ContractFieldDescriptor => ({
kind: 'column',
name: fieldName,
type: { pack: 'pg', type: 'text' },
nullable: true,
}),
}
}
See the Custom Fields guide for a complete tutorial.
Field Validation
Validation rules are defined in the validation object:
text({
validation: {
isRequired: true,
length: { min: 3, max: 100 },
},
})
integer({
validation: {
isRequired: true,
min: 0,
max: 100,
},
})
Validation errors are thrown during create/update operations and include:
- Field name
- Error type
- Validation rule that failed
The Field Builder Contract
Every field config object declares what the generator delegates to it for.
getZodSchema(fieldName, operation)
Returns a Zod schema for validation:
getZodSchema: (fieldName, operation) => {
let schema = z.string()
if (validation?.length) {
if (validation.length.min) schema = schema.min(validation.length.min)
if (validation.length.max) schema = schema.max(validation.length.max)
}
return validation?.isRequired ? schema : schema.optional()
}
getContractField(fieldName, listKey, config)
Returns what the field contributes to the generated contract — its column, its columns, its relation, or nothing:
getContractField: (fieldName) => ({
kind: 'column',
name: fieldName,
type: { pack: 'pg', type: 'text' },
nullable: true,
})
The four descriptor kinds are 'column' (one stored column, inline), 'columns' (several), 'relation' (a relationship and the foreign key it owns), and 'computed' (a virtual field, which stores nothing).
outputType
The field's TypeScript read face, when its contract column's codec does not already give it the right one. A value, not a method — either a type string or a TypeDescriptor:
outputType: "import('@opensaas/stack-storage').ImageMetadata | null"
Required on a virtual field and on a kind: 'columns' field: neither has a single column to be typed from, so opensaas generate refuses one that omits it. Optional everywhere else, where it is an override.
inputType follows the same shape for the write face. opensaas generate never requires it: on a single-column field, absence means the column's own input type. A kind: 'columns' field has no single column for that to name, so declare it alongside outputType — every multi-column field in this repo does.
needs
The column keys this field's resolveOutput hook cannot compute without — stored columns and immediate relations on the same list, one hop, non-transitive, and stripped from the result unless the caller named them too. It is the only supported way for a field to widen the read it is computed inside. Declaring it without a resolveOutput hook is a refusal at generate time, because nothing would consume the declaration. See Declared Dependencies above.
Best Practices
1. Use Appropriate Field Types
// ✅ Good: Use integer for whole numbers
age: integer({ validation: { min: 0, max: 150 } })
// ✅ Good: Use decimal for currency and precise values
price: decimal({
precision: 10,
scale: 2,
validation: { min: '0' },
})
// ✅ Good: Use bigInt for whole numbers beyond Number.MAX_SAFE_INTEGER
occurredAtMs: bigInt({ validation: { isRequired: true } })
// ❌ Bad: Don't use text for numbers
age: text({ validation: { length: { max: 3 } } })
// ❌ Bad: Don't use integer for currency (loses precision)
price: integer()
// ❌ Bad: Don't use integer for a millisecond epoch (overflows 32-bit Int)
occurredAtMs: integer()
2. Add Validation Rules
// ✅ Good: Validate email format
email: text({
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 use text for IDs
authorId: text()
Next Steps
- Hooks System - Transform field data
- Custom Fields Guide - Create custom field types
- API Reference - Complete field API