useDataValidator

Stable v1.0.0

Schema-driven validation for arbitrarily nested payloads — objects, arrays with per-item rules, uniqueness across rows — with a reactive error tree and a toast-ready navigable error list.

1. Interactive Playground (Shipping Schedule)

One schema validates the whole payload — top-level fields, the nested operator group, and every row of the editable ports table (per-td inputs, validated cell-by-cell on blur). Leave fields empty, duplicate a port code, or set a type to International without a customs code, then hit Validate schedule — the toast's error tree navigates straight to each field.

Ports (min. 2, unique codes)
Port Code
Port Name
Type
Customs Code
Actions
(Unavailable)
(Unavailable)
The schema — nested objects, conditional per-row rules, uniqueness
import { useDataValidator, type RulesSchema } from '@midnight-owl/use-data-validator'

const schedule = reactive({
  vesselName: '',
  voyageNumber: '',
  operator: { name: '', scacCode: '' },
  ports: [{ code: '', name: '', type: '', customsCode: '' }]
})

const scheduleRules: RulesSchema = {
  vesselName: { required: true, minChar: 3, displayName: 'Vessel name' },
  voyageNumber: { required: true, alphanumeric: true, displayName: 'Voyage number' },
  operator: {
    // Nesting an object nests the schema — displayName labels the group
    displayName: 'Operator',
    name: { required: true, displayName: 'Operator name' },
    scacCode: { required: true, alpha: true, minChar: 4, maxChar: 4, displayName: 'SCAC code' }
  },
  ports: {
    displayName: 'Ports',
    required: true,
    type: 'array',
    minLength: 2,
    // each = per-item rules. A function of (item, index) makes them
    // conditional — customs code only exists for international ports.
    each: (item) => {
      const rules: RulesSchema = {
        code: { required: true, minChar: 5, maxChar: 5, unique: true },
        name: { required: true },
        type: { required: true, oneOf: ['DOMESTIC', 'INTERNATIONAL'] }
      }
      if (item.type === 'INTERNATIONAL') {
        rules.customsCode = { required: true, alphanumeric: true }
      }
      return rules
    }
  }
}

const validator = useDataValidator()
Per-cell validation inside a Table
<!-- Per-<td> inputs: validate the one cell on both input and blur, so
     the error updates live instead of hiding until the field loses focus.
     Paths use dot + [index] notation however deep the field sits. -->
<Table :records="schedule.ports" :columns="portColumns">
  <template #cell-code="{ index }">
    <InputText
      v-model="schedule.ports[index].code"
      size="sm"
      :data-validate-field="`ports[${index}].code`"
      :error="validator.getFieldError(`ports[${index}].code`)"
      @update:model-value="validator.validateField(`ports[${index}].code`, schedule, scheduleRules)"
      @blur="validator.validateField(`ports[${index}].code`, schedule, scheduleRules)"
    />
  </template>
</Table>

<!-- unique: true re-checks the WHOLE column on every cell validation, so
     fixing a duplicate in row 2 also clears the mirror error in row 1. -->

2. Quick Start

execute(data, rules) validates everything and returns the resulting validity, so a submit handler is one guard clause. getFieldError(path) binds straight to an input's error prop; validateField on input re-runs the field's rules on every keystroke, so the error updates live instead of lingering stale until the field blurs.

  • At least 8 characters
  • One uppercase letter
  • One lowercase letter
  • One number
Rules + submit guard
import { useDataValidator, type RulesSchema } from '@midnight-owl/use-data-validator'

const account = reactive({ email: '', password: '', confirm: '' })

const accountRules: RulesSchema = {
  email: { required: true, email: true },
  password: {
    required: true,
    minChar: 8,
    regex: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/,
    messages: {
      regex: 'Password needs an uppercase letter, a lowercase letter, and a number.'
    }
  },
  confirm: { required: true, sameAs: 'password', displayName: 'confirmation' }
}

const { execute, validateField, getFieldError } = useDataValidator()

// Same trigger convention on every form in this app: validate on input AND
// on blur (both call the same field, so the error tracks every keystroke
// instead of hiding until the field loses focus), and a final execute() as
// the real submit-time gate.
const validateAccountField = (field) => validateField(field, account, accountRules)

const submit = () => {
  if (!execute(account, accountRules)) return // errors tree is now populated
  // ... send the payload
}
Template wiring
<InputText
  v-model="account.email"
  label="Email"
  :error="getFieldError('email')"
  @update:model-value="validateAccountField('email')"
  @blur="validateAccountField('email')"
/>
<InputPassword
  v-model="account.password"
  label="Password"
  :error="getFieldError('password')"
  @update:model-value="validateAccountField('password')"
  @blur="validateAccountField('password')"
/>

3. Field-Level Validation

validateField(path) re-runs one field's rules without touching any other field's errors — the on-blur flow, and the only sane way to validate per-cell in an editable table. Paths use dot + [index] notation however deep the field sits. For fields with unique, the whole column is re-checked so a fixed duplicate clears its mirror error in the other row too.

Per-field API
// Validate one field without touching any other field's errors — wire the
// same call to both @update:model-value and @blur so the error tracks every
// keystroke instead of hiding until the field loses focus. data/rules are
// remembered from the last call, so subsequent calls only need the path.
validateField('email', form, rules)
validateField('operator.scacCode')      // nested object
validateField('ports[2].code')          // array item — also re-checks unique

// clearFieldError is for clearing a DIFFERENT field's now-stale error —
// e.g. switching a port's type away from INTERNATIONAL removes the
// customsCode rule, so its old error has to be cleared by hand since
// validateField('customsCode') would find no rule left to run.
clearFieldError('ports[2].customsCode')

// Read state anywhere (all read-only, safe in templates):
getFieldError('email')                  // first message or ''
getFieldErrors('email')                 // every message
getFieldStyle('ports[2].code')          // { border: '1px solid #ef4444', ... } while invalid
isValid.value                           // whole-payload flag
errorCount.value                        // total message count

4. Toast Summary & Navigation

getErrorList() emits the error tree in exactly the shape the toast's errorList renders — groups become nested items, array rows become numbered children (Ports #2), and every leaf navigates. Mark inputs with data-validate-field="<path>" (or an id) and clicking an item scrolls to and focuses the field. Try it in the playground above.

Blocking error summary
import { useToast } from '@midnight-owl/ui'

const toast = useToast()

const submit = () => {
  if (validator.execute(schedule, scheduleRules)) return save()

  // getErrorList() mirrors the error tree as { label, messages, children,
  // onNavigate } items — exactly the shape the toast's errorList renders.
  toast.danger('Click an item below to jump straight to the field.', {
    title: 'Submission blocked',
    position: 'center',
    size: 'md',
    duration: 0,
    errorList: validator.getErrorList()
  })
}
Navigation targets & overrides
<!-- Navigation targets: mark inputs with data-validate-field="<path>"
     (or id="<path>") and clicking the toast item scrolls to + focuses it. -->
<InputText v-model="schedule.vesselName" data-validate-field="vesselName" />
<InputText
  v-model="schedule.ports[index].code"
  :data-validate-field="`ports[${index}].code`"
/>

<!-- Take over navigation yourself (e.g. switch to the right tab first): -->
toast.danger('...', {
  errorList: validator.getErrorList({
    onNavigate: (path) => {
      activeTab.value = path.startsWith('operator.') ? 'operator' : 'schedule'
      nextTick(() => focusValidatedField(path))
    }
  })
})

5. Custom Rules, Validators & Messages

Register app-wide validators once via useDataValidator({ validators }), override default messages globally or per field, and use the per-field custom rule as the escape hatch for anything else — it receives the value plus a context with the whole payload, the owning object, and the row index.

Extending the validator
const validator = useDataValidator({
  // Extra validators, addressed from rule sets by their key
  validators: {
    phPhone: (value) => /^09\d{9}$/.test(String(value))
  },
  // Global default-message overrides, keyed by rule name
  messages: {
    phPhone: (field) => `The ${field} must be a valid PH mobile number.`,
    required: (field) => `${field} is required.`
  },
  // Replace the default red border applied via getFieldStyle()
  errorStyle: { outline: '2px solid #f97316' }
})

validator.execute(form, {
  mobile: { required: true, phPhone: true },

  // Per-field escape hatch: return true, or an error string (or false)
  coupon: {
    custom: (value, { data }) =>
      data.total > 100 || value === '' ? true : 'Coupons only apply to orders over $100.'
  },

  // Per-field message overrides beat both defaults and global overrides
  username: {
    required: true,
    minChar: 3,
    messages: { minChar: 'Pick a longer username (3+ characters).' }
  }
})

API Reference

Complete specifications for useDataValidator's options, rules, and returned state.

Options — useDataValidator(options)

Name
Type
Description
validators
Record<string, ValidatorFn>
Extra validators — (value, ruleValue, context) => boolean — addressed from rule sets by their key.
messages
Record<string, MessageResolver>
Global default-message overrides keyed by rule name. Per-field messages still win over these.
errorStyle
Record<string, string>
Inline style getFieldStyle() returns while a field has errors. Defaults to a red border — handy for plain inputs that don't have an error prop.

Built-in Rules

Rule
Applies To
Description
required: true
any
Not null/undefined, not a blank string, not an empty array. Every other rule is skipped while the field is empty.
type: '<validator>'
any
Runs the named validator as a type check — 'string', 'num', 'int', 'array', 'email', …
minChar / maxChar
strings
Length bounds. Skipped for non-string values so a wrong type never double-errors.
min / max
numbers
Value bounds. Skipped for non-numeric values.
decimal: { maxInt, maxDecimal }
numbers
Caps integer digits and decimal places.
email / url
strings
Format checks.
alpha / alphanumeric
strings
Character-set checks.
regex: /…/
strings
Custom pattern — pair with messages.regex for a human message.
oneOf: [...]
any
Value must be one of the listed options.
sameAs: 'field'
any
Must equal a sibling field — password confirmation.
minLength / maxLength
arrays
Item-count bounds.
unique: true | { with: [...] }
array items
No two items in the collection may share this value; with makes the key composite. Re-checked across the whole column on every cell validation.
each: rules | (item, index) => rules
arrays
Per-item schema. The function form makes rules conditional per row.
custom: (value, ctx) => …
any
Escape hatch — return true when valid, or an error string (or false) when not. ctx carries data, dataNode, collection, index.
displayName / messages
metadata
Human label used in messages and error lists; per-rule message overrides for this field.
tin
strings
000-000-000-00000 tax-ID format.

Return Values

Name
Type
Description
errors
reactive tree
Mirrors the payload shape; leaves are { messages, style } and array fields carry items[] per row.
isValid
Ref<boolean>
Whole-payload validity after the last execute()/validateField() run.
errorCount
ComputedRef<number>
Total messages currently in the tree — for "N issues" summaries.
execute
(data, rules) => boolean
Validate the whole payload; returns the resulting validity and stores data/rules for later per-field calls.
validateField
(path, data?, rules?) => void
Re-validate one field by path ('email', 'operator.scacCode', 'ports[2].code') without touching other fields.
clearFieldError
(path) => void
Clear one field's errors directly, without re-running its rules. For a field the user is actively editing, prefer calling validateField on both input and blur instead — this is for clearing a DIFFERENT field's now-stale error when a rule change (e.g. each) removes it from the schema entirely.
reset
() => void
Clear every error and restore isValid.
getFieldError
(path) => string
First message for a path, or '' — binds straight to an input's error prop.
getFieldErrors
(path) => string[]
Every message for a path.
getFieldStyle
(path) => Record<string, string>
The errorStyle bag while the field has errors, {} while valid — for plain inputs.
getErrorList
(opts?) => ErrorListItem[]
Toast-ready nested error list — { key, label, messages, children, onNavigate }. opts.onNavigate overrides navigation; opts.navigable: false emits a plain tree.
getFlatErrors
() => FlatError[]
The same content flattened to { path, label, message } rows, labels joined with ">".