ObjectOS
구축데이터

Validation Rules

Required fields, unique constraints, and CEL-powered rules that stop bad data at the platform level — with error messages you control.

Validation runs on every write path. REST, forms, ObjectQL — the same rules fire everywhere, so there is no back door for bad data. Since 16.0 that includes multi-row updates: a bulk update evaluates the rules once per matched row. A validation rule is a deterministic, synchronous, side-effect-free predicate over a single record: decidable from the incoming write (and, on update, the prior record) with no I/O.

Validation is layered — use the lowest layer that can express the rule:

LayerExpressesExample
Field modifiersPresence, uniquenessrequired: true, unique: true
Field constraintsPer-field shapemin, max, maxLength, format
Conditional modifiersContext-dependent presencerequiredWhen: P`record.amount > 10000`
Object validation rulesCross-field business logic"Discount cannot exceed total"
Unique indexesComposite / scoped uniqueness{ fields: ['code', 'organization'], unique: true }
Lifecycle hooksArbitrary validation code, delete guardsbeforeInsert / beforeUpdate / beforeDelete

Field-level: required, unique, constraints

The universal modifiers work on every field type:

import { ObjectSchema, Field } from '@objectstack/spec/data';

fields: {
  email: Field.email({ label: 'Contact Email', required: true, unique: true }),
  quantity: Field.number({ label: 'Quantity', min: 1, max: 9999 }),
  code: Field.text({ label: 'Code', minLength: 3, maxLength: 20 }),
}
ModifierBehavior
required: trueRejects null / undefined at runtime
unique: trueDatabase-level uniqueness constraint — not a racy application check
min / maxNumeric range (number, currency, percent, rating, slider…)
minLength / maxLengthCharacter bounds on text types
formatBuilt-in shape checks — email, url, phone field types carry theirs by default
readonly: trueServer-enforced on update — a non-system write to the field is silently dropped (insert exempt)

Field types also validate themselves for free: email checks the local@domain shape, url requires a protocol, select values must match an option, lookup enforces that the referenced record exists, json must parse. See the field-type reference for every default.

Conditional required / read-only / visible

Presence can depend on the rest of the record via CEL predicates:

import { P } from '@objectstack/spec';

po_number: Field.text({
  label: 'PO Number',
  requiredWhen: P`record.amount > 10000`,
}),

visibleWhen, readonlyWhen, and requiredWhen all take a CEL predicate — see Formulas.

Object-level validation rules

Cross-field business logic lives in the object's validations array:

export const Order = ObjectSchema.create({
  name: 'order',
  fields: {
    amount: Field.currency({ label: 'Amount', required: true }),
    status: Field.select({ label: 'Status', options: [ /* ... */ ] }),
  },

  validations: [
    {
      name: 'amount_positive',
      type: 'script',
      severity: 'error',
      message: 'Amount must be greater than zero',
      // CEL predicate — TRUE means the record is INVALID.
      condition: 'record.amount <= 0',
      events: ['insert', 'update'],
    },
  ],
});

Warning: For script rules the condition is the failure predicate — when it evaluates TRUE, validation fails. Write the test for the bad state: record.amount <= 0 rejects non-positive amounts.

Common properties

Every rule type shares this base shape:

PropertyRequiredDescription
nameyesUnique rule name (snake_case)
messageyesUser-facing error message
typeyesscript, state_machine, format, cross_field, json_schema, conditional
severitynoerror (blocks save, default), warning (allows save), info
eventsnoinsert, update — default ['insert', 'update']. The delete event was removed in 16.0: the evaluator never ran on delete (a delete carries no record payload), so it was a silent no-op — guard deletions with a beforeDelete hook
priorityno0–9999, lower runs first (default 100)
activenoToggle without deleting (default true)

The six rule types

TypeChecksKey config
scriptAny CEL predicatecondition (TRUE = invalid)
state_machineAllowed status transitionsfield, transitions map, optional initialStates
formatOne field against regex or built-in formatfield, regex or format: 'email' | 'url' | 'phone' | 'json'
cross_fieldRelationship between fieldsfields, condition
json_schemaJSON field against a JSON Schemafield, schema
conditionalApplies a nested rule only when a predicate holdswhen, then, optional otherwise

Two rules you'll reach for constantly:

// State machine — enforce the status flow
{
  name: 'order_status_transitions',
  type: 'state_machine',
  severity: 'error',
  message: 'Invalid status transition',
  field: 'status',
  transitions: {
    draft: ['submitted', 'cancelled'],
    submitted: ['approved', 'rejected', 'cancelled'],
    approved: ['completed'],
    rejected: ['draft'],
    cancelled: [],
    completed: [],
  },
  initialStates: ['draft'],  // new in 16.0: states a record may be CREATED in
  events: ['insert', 'update'],  // include 'insert' so initialStates is checked
}

// Cross-field — dates in order
{
  name: 'date_range_valid',
  type: 'cross_field',
  severity: 'error',
  message: 'End date must be after start date',
  fields: ['start_date', 'end_date'],
  condition: 'record.end_date <= record.start_date',
  events: ['insert', 'update'],
}

New in 16.0: state_machine rules can declare initialStates — the states a record may be created in. An insert whose state field carries a value outside the list is rejected (invalid_initial_state). transitions only governs updates, and a select field permits any declared option as an initial value, so without initialStates a record can be born mid-flow (e.g. created already approved). Omit it to keep the legacy behavior (no initial-state check on insert). The rule's events must include insert for the check to run.

In update-time conditions, previous holds the pre-change snapshot — record.stage != previous.stage detects a change.

Uniqueness: use an index, not a rule

There is no uniqueness validation type — on purpose. A SELECT-then-INSERT check is inherently racy (TOCTOU); a database unique constraint is not. Enforce uniqueness at the data layer:

// Field-level
email: Field.email({ label: 'Contact Email', unique: true }),

// Composite / scoped via indexes
indexes: [
  { fields: ['code', 'organization'], unique: true },
  // add `partial` for a scoped/conditional constraint
]

The same logic excludes async/remote validation (a client-form concern and an SSRF/latency hazard on the write path) and custom handlers — arbitrary validation code belongs in a beforeInsert / beforeUpdate lifecycle hook, and delete-time guards in a beforeDelete hook.

Custom error messages

The message is what the user sees when the rule fires — in the form and in the API error response. Make it actionable:

WeakStrong
"Invalid input""Close date is required for closed deals"
"Validation failed""Phone must match format: +1-XXX-XXX-XXXX"
"Error""Discount cannot exceed total"

Use severity to calibrate: error blocks the save, warning shows the message but lets the save through, info is purely informational.

Tip: A predicate that can't be evaluated (parse error, unbound variable) is treated as a broken rule — logged and skipped rather than blocking every write.

Best practices

DoDon't
Validate at the lowest layer possible (field → rule → hook)Use hooks for what a field modifier expresses
Write clear, actionable messagesDuplicate the same check across layers
Use priority to run cheap format checks firstBuild overly complex conditions
Test rules with real-world dataBlock legitimate edge cases

Where to go next

PageWhy
Data ModelThe objects and fields these rules protect
FormulasThe CEL language behind condition and requiredWhen
RelationshipsReferential integrity across objects
CEL referenceFull operator and stdlib reference for conditions
Field TypesBuilt-in validation defaults per field type
FlowsReact to (valid) record changes with automation

On this page