ErstellenDaten
Diese Seite ist noch nicht übersetzt; angezeigt wird die englische Fassung.

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:

// Fields of an object; the object around them is omitted.
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 insert and update — a non-system write to the field is dropped and reported in the response's droppedFields, or refused outright when the write passes the strictReadonlyWrites option. To seed a read-only field at create time, write as the system: a seed, a system hook, or a flow with runAs: 'system'

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. They read the record's own fields only. A requiredWhen or readonlyWhen that cannot be evaluated — a misspelled field, ordering over null, a read through a lookup such as record.account.tier — refuses the write with VALIDATION_FAILED instead of letting it through; a check that has to read a related record belongs in a validation rule.

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: [
        { label: 'Draft', value: 'draft' },
        { label: 'Submitted', value: 'submitted' },
      ],
    }),
  },

  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:

// Two validation rules, entries of the object's `validations` array.

// 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.

Since ObjectStack 17.5 a script or cross_field rule can read one hop through a lookup, master_detail, user or tree field:

// One validation rule, an entry of the object's `validations`
{
  name: 'partner_deal_cap',
  type: 'script',
  severity: 'error',
  message: 'Partner deals over 10,000 need a direct contract',
  condition: "record.account.type == 'partner' && record.amount > 10000",
  events: ['insert', 'update'],
}

The engine loads only the related fields the condition names, in one batched read per reference field, and reads nothing when no rule traverses. A second hop (record.account.owner.email) is refused, and so is comparing the reference's id while traversing it. Field-level predicates and row-level security stay on the record's own fields.

A rule sees record and previous and nothing else: it cannot ask who is saving. current_user.can(...) is answered elsewhere — see CEL → Permission checks — and in a validation rule it cannot be evaluated, so the rule refuses every write it runs on.

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:

// Keys of an object; its name and its other fields are omitted.
fields: {
  // 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.

Warning: A rule whose predicate cannot be evaluated (a parse error, a misspelled field, a value it cannot compare) fails closed: the write is refused with VALIDATION_FAILED, never saved with the rule skipped. Test a new rule against real records before you publish it.

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