Field Types API Reference
Complete API reference for all built-in field types in OpenSaaS Stack. For usage examples and guides, see the Field Types guide.
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<string, string>
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
Example:
email: text({
isIndexed: 'unique',
validation: { isRequired: true },
})
db.map
Custom database column name.
Type: string
Purpose:
Customize the column name in the database using Prisma's @map attribute. 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 Prisma schema:
firstName String @map("first_name")
emailAddress String @unique @map("email")
The field names in your code (firstName, emailAddress) remain unchanged, but the database columns will use the mapped names.
ui.displayMode
UI display mode for the field.
Type: 'input' | 'textarea' Default: 'input'
Example:
description: text({
ui: { displayMode: 'textarea' },
})
Database Type
Prisma: String
TypeScript Type
string (optional if not required)
integer()
Numeric field for whole numbers.
import { integer } from '@opensaas/stack-core/fields'
integer(options?: {
validation?: {
isRequired?: boolean
min?: number
max?: number
}
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<number, number>
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 })
Database Type
Prisma: Int
TypeScript Type
number (optional if not required)
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<Decimal, Decimal>
})
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
Example:
accountNumber: decimal({
isIndexed: 'unique',
precision: 20,
scale: 0,
})
Database Type
Prisma: Decimal with precision and scale
Generated Prisma schema:
price Decimal @db.Decimal(10, 2)
TypeScript Type
import('decimal.js').Decimal (from decimal.js library)
Import:
import type { Decimal } from 'decimal.js'
Usage Example
import { Decimal } from 'decimal.js'
// Creating records
const product = await context.db.product.create({
data: {
name: 'Widget',
price: '19.99', // Can use string
// price: 19.99, // or number (converted to Decimal)
},
})
// Performing precise calculations
const quantity = 3
const subtotal = product.price.times(quantity) // Decimal: 59.97
const tax = subtotal.times('0.1') // Decimal: 5.997
const total = subtotal.plus(tax) // Decimal: 65.967
// Rounding for currency
const totalCents = total.toDecimalPlaces(2) // Decimal: 65.97
// Converting to string/number
const priceString = product.price.toString() // '19.99'
const priceNumber = product.price.toNumber() // 19.99
Decimal.js API
The Decimal type from decimal.js provides precise arithmetic operations:
Common methods:
.plus(n)- Addition.minus(n)- Subtraction.times(n)- Multiplication.div(n)- Division.toDecimalPlaces(dp)- Round to decimal places.toString()- Convert to string.toNumber()- Convert to number (may lose precision).lessThan(n)- Comparison.greaterThan(n)- Comparison.equals(n)- Equality check
See: decimal.js documentation for complete API
The decimal field type uses Prisma's Decimal type, which is backed by the decimal.js library. This ensures precise decimal arithmetic without floating-point errors, making it ideal for financial applications where accuracy is critical.
Key Features
- Precision: No floating-point errors (unlike JavaScript's
number) - Configurable: Set precision and scale for your use case
- Validation: String-based min/max for precise bounds
- Database-Native: Uses native
DECIMALtype in PostgreSQL/MySQL - Type-Safe: Full TypeScript support with
Decimaltype
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<boolean, boolean>
})
Options
defaultValue
Default boolean value.
Type: boolean
Example:
isPublished: checkbox({ defaultValue: false })
emailVerified: checkbox({ defaultValue: true })
Database Type
Prisma: Boolean
TypeScript Type
boolean (optional if no default value)
timestamp()
Date/time field with automatic timestamp support.
import { timestamp } from '@opensaas/stack-core/fields'
timestamp(options?: {
defaultValue?: { kind: 'now' } | Date
ui?: {
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<Date, Date>
})
Options
defaultValue
Default timestamp value.
Type: { kind: 'now' } | Date
Values:
{ kind: 'now' }- Automatically set to current time on createDate- Specific date/time value
Example:
createdAt: timestamp({
defaultValue: { kind: 'now' },
})
publishedAt: timestamp()
Database Type
Prisma: DateTime
TypeScript Type
Date (optional if no default value)
Validation
Accepts:
Dateobjects- ISO 8601 datetime strings
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<Date, Date>
})
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
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 Type
Prisma: DateTime with @db.Date attribute
- PostgreSQL/MySQL: Native
DATEtype (date only, no time) - SQLite: String representation in ISO8601 format
Generated Prisma:
birthDate DateTime @db.Date
startDate DateTime? @db.Date @default("2025-01-01")
eventDate DateTime? @db.Date @index
TypeScript Type
Date (nullable if not required)
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) |
| Database type | DATE (PostgreSQL/MySQL) | DATETIME/TIMESTAMP |
| Input format | ISO8601 date (YYYY-MM-DD) | Date object or ISO datetime |
| Use case | Birth dates, events | Created/updated timestamps |
| 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<string, HashedPassword>
})
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)
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 Type
Prisma: String
TypeScript Type
string for input, HashedPassword for output
Usage Example
// Creating a user - password is automatically hashed
const user = await context.db.user.create({
data: {
email: 'user@example.com',
password: 'plaintextPassword', // Hashed before storage
},
})
// Authenticating - use the compare() method
const user = await context.db.user.findUnique({
where: { email: 'user@example.com' },
})
if (user && (await user.password.compare('plaintextPassword'))) {
// Password is correct
}
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
ui?: {
displayMode?: 'select' | 'segmented-control' | 'radio'
[key: string]: unknown
}
access?: FieldAccess
hooks?: FieldHooks<string, string>
})
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
Database Type
Prisma: String
TypeScript Type
Union of option values (e.g., 'draft' | 'published' | 'archived')
relationship()
Foreign key relationship to another list.
import { relationship } from '@opensaas/stack-core/fields'
relationship(options: {
ref: string
many?: boolean
db?: {
foreignKey?: boolean
}
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 Prisma schema:
model User {
accountId String? @unique
account Account? @relation(fields: [accountId], references: [id])
}
model Account {
user User?
}
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.
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 Type
Prisma: Foreign key relationship with @relation directive
TypeScript Type
many: false-string(ID of related item, optional)many: true-string[](array of IDs, optional)
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<unknown, unknown>
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,
},
})
// Creating with JSON data
const item = await context.db.item.create({
data: {
metadata: {
tags: ['tag1', 'tag2'],
settings: { theme: 'dark', notifications: true },
},
},
})
// Querying returns parsed JSON
const item = await context.db.item.findUnique({
where: { id: '...' },
})
console.log(item.metadata.tags) // ['tag1', 'tag2']
Database Type
Prisma: Json (native JSON in PostgreSQL/MySQL, TEXT in SQLite)
TypeScript Type
unknown (requires type assertion or type guard when using)
virtual()
Computed field that is not stored in the database.
import { virtual } from '@opensaas/stack-core/fields'
virtual(options: {
type: string
hooks: {
resolveOutput: (args: {
operation: 'query'
value: unknown
item: TItem
listKey: string
fieldName: string
context: AccessContext
}) => unknown
resolveInput?: (args: {
operation: 'create' | 'update'
inputValue: unknown
item?: TItem
listKey: string
fieldName: string
context: AccessContext
}) => Promise<unknown> | unknown
}
ui?: {
[key: string]: unknown
}
access?: FieldAccess
})
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.
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",
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' },
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
},
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
hooks.resolveOutput (required)
Compute the field value from other fields in the item.
Type: Function
Parameters:
operation- Always'query'for virtual fieldsvalue- Database value (alwaysundefinedfor virtual fields)item- The full item with all selected fieldslistKey- The list name (e.g.,'User')fieldName- The field name (e.g.,'fullName')context- Access context with session and db
Returns: Computed value of the type specified in type option
Example:
displayName: virtual({
type: 'string',
hooks: {
resolveOutput: ({ item }) => {
return `${item.name} (${item.email})`
},
},
})
hooks.resolveInput (optional)
Perform side effects during create/update operations.
Type: Function (optional)
Parameters:
operation- Either'create'or'update'inputValue- Input value provided (if any)item- Existing item (undefined for create)listKey- The list namefieldName- The field namecontext- Access context
Returns: undefined (return value is ignored, use for side effects only)
Use cases:
- Sync data to external API
- Trigger webhooks
- Update related records
Example:
syncToExternal: virtual({
type: 'boolean',
hooks: {
resolveInput: async ({ item, operation }) => {
// Side effect: sync to external API
if (operation === 'update') {
await syncToExternalAPI(item)
}
return undefined // Return value is ignored
},
resolveOutput: () => true,
},
})
Key Characteristics
- No Database Storage: Virtual fields do not create database columns
- On-Demand Computation: Only computed when explicitly selected/included
- Type Safety: TypeScript type is generated from
typeoption - Performance: Efficient - only computed for requested fields
- Flexible: Can combine multiple fields or perform complex computations
Usage Examples
Read-Only Computed Field
User: list({
fields: {
firstName: text(),
lastName: text(),
fullName: virtual({
type: 'string',
hooks: {
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
},
}),
},
})
// Usage
const user = await context.db.user.findUnique({
where: { id },
select: { firstName: true, lastName: true, fullName: true },
})
console.log(user.fullName) // "John Doe"
Complex Computed Value
Order: list({
fields: {
items: json(), // Array of { price: number, quantity: number }
total: virtual({
type: 'number',
hooks: {
resolveOutput: ({ item }) => {
if (!item.items || !Array.isArray(item.items)) return 0
return item.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
},
}),
},
})
Write Side Effects
Post: list({
fields: {
title: text(),
content: text(),
searchIndexSync: virtual({
type: 'boolean',
hooks: {
resolveInput: async ({ item, operation }) => {
// Update search index when post is created or updated
if (operation === 'create' || operation === 'update') {
await updateSearchIndex(item)
}
return undefined
},
resolveOutput: () => true,
},
}),
},
})
Important Notes
Virtual fields must be explicitly selected in queries. They are not included by default.
// ❌ Virtual field NOT computed
const user = await context.db.user.findUnique({
where: { id },
})
// user.fullName is undefined
// ✅ Virtual field IS computed
const user = await context.db.user.findUnique({
where: { id },
select: { firstName: true, lastName: true, fullName: true },
})
// user.fullName is "John Doe"
The resolveOutput hook must be provided. Virtual fields cannot exist without a computation function.
Database Type
None - virtual fields do not create database columns
TypeScript Type
Type specified in type option
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?: AccessControl
create?: AccessControl
update?: AccessControl
}
type AccessControl = (args: {
session: Session
item?: T
context: AccessContext
}) => boolean | Promise<boolean>
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<TInput, TOutput>
type FieldHooks<TInput, TOutput> = {
resolveInput?: (args: {
operation: 'create' | 'update'
inputValue: TInput | undefined
item?: TItem
listKey: string
fieldName: string
context: AccessContext
}) => Promise<TInput | undefined> | TInput | undefined
resolveOutput?: (args: {
operation: 'query'
value: TInput | undefined
item: TItem
listKey: string
fieldName: string
context: AccessContext
}) => TOutput | undefined
beforeOperation?: (args: {
operation: 'create' | 'update' | 'delete'
resolvedValue: TInput | undefined
item?: TItem
listKey: string
fieldName: string
context: AccessContext
}) => Promise<void> | void
afterOperation?: (args: {
operation: 'create' | 'update' | 'delete' | 'query'
value: TInput | TOutput | undefined
item: TItem
listKey: string
fieldName: string
context: AccessContext
}) => Promise<void> | void
}
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:
slug: text({
hooks: {
resolveInput: async ({ inputValue, item }) => {
// Auto-generate slug from title if not provided
if (!inputValue && item?.title) {
return item.title.toLowerCase().replace(/\s+/g, '-')
}
return inputValue
},
},
})
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 }) => {
// Add CDN prefix to image URLs
return value ? `https://cdn.example.com/${value}` : null
},
},
})
beforeOperation
Side effects before database operation. Does NOT modify data.
When called: Before create, update, or delete operations, after validation
Use cases: Logging, validation, pre-operation checks
Example:
status: select({
options: [
/* ... */
],
hooks: {
beforeOperation: async ({ operation, resolvedValue, item }) => {
// Log status changes
if (operation === 'update' && item.status !== resolvedValue) {
await auditLog.record({
event: 'status_change',
from: item.status,
to: resolvedValue,
})
}
},
},
})
afterOperation
Side effects after database operation. Does NOT modify data.
When called: After create, update, delete, or query operations
Use cases: Cache invalidation, webhooks, cleanup
Example:
thumbnail: text({
hooks: {
afterOperation: async ({ operation, value, item }) => {
if (operation === 'delete' && value) {
// Delete file from storage
await deleteFromCDN(value)
} else if (operation === 'update' && value) {
// Invalidate CDN cache
await invalidateCDNCache(value)
}
},
},
})
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 Methods
Every field configuration object implements these methods used by generators and validators:
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
}
getPrismaType(fieldName)
Returns Prisma type and modifiers for schema generation.
Signature:
getPrismaType(fieldName: string): {
type: string
modifiers?: string
}
Parameters:
fieldName- Field name (used for generating field-specific modifiers)
Returns: Object with:
type- Prisma scalar type (String,Int,Boolean,DateTime,Json)modifiers- Optional Prisma modifiers (?,@default(...),@unique,@index)
Example implementation:
getPrismaType: (fieldName) => {
return {
type: 'String',
modifiers: options?.validation?.isRequired ? undefined : '?',
}
}
getTypeScriptType()
Returns TypeScript type information for type generation.
Signature:
getTypeScriptType(): {
type: string
optional: boolean
}
Returns: Object with:
type- TypeScript type string (e.g.,'string','number','boolean','Date')optional- Whether the field is optional in TypeScript
Example implementation:
getTypeScriptType: () => {
return {
type: 'string',
optional: !options?.validation?.isRequired,
}
}
getTypeScriptImports()
Returns TypeScript imports needed for this field's type (optional).
Signature:
getTypeScriptImports(): Array<{
names: string[]
from: string
typeOnly?: boolean
}>
Returns: Array of import specifications
Example implementation:
getTypeScriptImports: () => {
return [
{
names: ['HashedPassword'],
from: '@opensaas/stack-core',
typeOnly: false,
},
]
}
Creating Custom Field Types
Custom field types must implement the BaseFieldConfig interface:
import type { BaseFieldConfig } from '@opensaas/stack-core'
import { z } from 'zod'
export type EmailField = BaseFieldConfig & {
type: 'email'
requireVerification?: boolean
}
export function email(options?: Omit<EmailField, 'type'>): EmailField {
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()
},
getPrismaType: (fieldName) => {
return {
type: 'String',
modifiers: options?.validation?.isRequired ? undefined : '?',
}
},
getTypeScriptType: () => {
return {
type: 'string',
optional: !options?.validation?.isRequired,
}
},
}
}
Key principles:
- Extend
BaseFieldConfigwith your field's options - Implement all three generator methods
- 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)
import { image, file } from '@opensaas/stack-storage/fields'
avatar: image({
storage: 's3',
validation: { isRequired: true },
transformations: {
thumbnail: { width: 150, height: 150 },
large: { width: 1200, height: 1200 },
},
})
document: file({
storage: 'local',
validation: {
maxSize: 10 * 1024 * 1024, // 10MB
allowedTypes: ['application/pdf', 'text/plain'],
},
})
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
- Field-level Zod schema validation (from
getZodSchema()) - List-level
validateInputhook - Field-level access control (filter writable fields)
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: Use hooks for data transformation
email: text({
hooks: {
resolveInput: async ({ inputValue }) => {
return inputValue?.toLowerCase().trim()
},
},
})
// ❌ Bad: Don't transform in application code
Next Steps
- Field Types Guide - Usage examples and patterns
- Hooks System - Field-level data transformation
- Access Control - Field-level security
- Custom Fields Guide - Create custom field types
- Config API - Complete configuration reference