Skip to content

Validation

A focused showcase of every validation strategy the library supports — declarative restrictions, custom validation functions, async server-side checks, and reactive cross-field rules — all in a single form.

What It Demonstrates

  • restriction properties (minLength, maxLength, pattern, whiteSpace, fractionDigits, minExclusive, …) that need zero validation code
  • A custom validation function for password complexity with a real-time strength bar
  • An async validation function that simulates a server-side uniqueness check
  • Cross-field validation — the rule re-runs automatically when the referenced field changes using computedProps
  • A custom password field type with a show/hide toggle and animated strength indicator

Example

Account details

Combines restriction-based rules with custom and async validation.

Letters, numbers and underscores only. 3-20 characters.

Format is checked with a restriction. Availability is verified asynchronously — try admin@example.com.

Must contain 8+ characters, uppercase, lowercase, a number and a special character.

Re-validated automatically whenever the password field changes.

Profile

Whitespace and length restrictions keep data clean without writing a single validation function.

Leading/trailing spaces and repeated whitespace are rejected via the whiteSpace: collapse restriction.

Optional. Max 200 characters.

Optional. Validated with a custom function — must be a valid http(s) URL.

Date range

End date is validated against the start date. computedProps re-triggers validation on endDate whenever startDate changes.

Billing

Numeric and format restrictions — no custom code needed.

Must be greater than 0, at most $10,000, and no more than 2 decimal places.

Optional. Must be 4-8 uppercase letters or digits (e.g. SAVE2025).

IsDirty: false
Touched: false
Valid: true

// form values:
{
  "account": {},
  "profile": {},
  "dateRange": {},
  "billing": {}
}
    

Restrictions — No Code Required

Restrictions are declared inline on the field and automatically converted to vee-validate rules by the library. No validation function is needed.

The username field combines three restrictions at once — length bounds and a character-set pattern:

ts
{
  name: 'username',
  fieldOptions: { label: 'Username' },
  description: 'Letters, numbers and underscores only. 3-20 characters.',
  restriction: {
    minLength: 3,
    maxLength: 20,
    pattern: '^[a-zA-Z0-9_]+$',
  },
},

Other restriction flavours used in this example:

FieldRestrictionWhat it enforces
displayNamewhiteSpace: 'collapse'Rejects leading/trailing spaces and double spaces
biomaxLength: 200Hard character cap
startDate / endDatepattern: '^\\d{4}-\\d{2}-\\d{2}$'YYYY-MM-DD format
amountminExclusive: 0, maxInclusive: 10000, fractionDigits: 2Positive monetary value, max 2 decimals
promoCodepattern: '^[A-Z0-9]{4,8}$'4–8 uppercase alphanumeric chars

Async Validation

The validation property accepts a function that returns a Promise. vee-validate handles the pending state automatically — the field stays invalid while the promise is in flight.

The email field first checks the format via a restriction, then calls the async function to simulate a round-trip to the server. Try entering admin@example.com to see the taken-email error.

ts
const takenEmails = ['admin@example.com', 'test@test.com', 'hello@world.com'];

async function validateEmailAvailability(value: unknown): Promise<true | string> {
  if (!value) return true;
  await new Promise(resolve => setTimeout(resolve, 700));
  return takenEmails.includes(String(value).toLowerCase())
    ? `${value} is already registered`
    : true;
}

TIP

You can combine restriction and validation on the same field. The restriction runs first; the async function only fires if the value already passes it.

Password Complexity + Strength Bar

The validation function on the password field checks five criteria and lists any that are still missing:

ts
function validatePassword(value: unknown): true | string {
  const str = String(value ?? '');
  if (!str) return true;
  const missing: string[] = [];
  if (str.length < 8) missing.push('8+ characters');
  if (!/[A-Z]/.test(str)) missing.push('uppercase letter');
  if (!/[a-z]/.test(str)) missing.push('lowercase letter');
  if (!/[0-9]/.test(str)) missing.push('number');
  if (!/[^A-Za-z0-9]/.test(str)) missing.push('special character');
  return missing.length === 0 ? true : `Needs: ${missing.join(', ')}`;
}

The animated strength bar (PasswordStrengthBar.vue) is rendered directly inside the custom #password-input slot in AdvancedFormTemplate. It reads the same value.value ref that vee-validate tracks, so the bar updates on every keystroke without any extra wiring.

The new password field type was added to the template by:

  1. Registering password: string in defineMetadata
  2. Adding a showStrengthBar?: boolean extended property
  3. Dropping in a #password-input slot that renders PasswordInput (with the eye toggle) followed by PasswordStrengthBar

Cross-field Validation

Each field's validation function is a plain closure that reads a reactive ref at the moment it runs. The validation for confirmPassword and endDate both do exactly that:

Confirm password — reads passwordValue.value at validation time:

ts
{
  name: 'confirmPassword',
  type: 'password',
  showStrengthBar: false,
  fieldOptions: { label: 'Confirm password' },
  description: 'Re-validated automatically whenever the password field changes.',
  validation: (value: unknown) => {
      if (!value) return true;
      const pwd = passwordValue.value;
      return value === pwd || 'Passwords do not match';
  },
  computedProps: [
    (_, fieldValue) => {
      if (passwordValue.value && fieldValue.value)
        validateField('account.confirmPassword')
    }
  ]
},

End date — reads startDateValue.value at validation time. YYYY-MM-DD strings compare correctly as plain strings, so no date parsing is needed:

ts
{
  name: 'endDate',
  fieldOptions: { label: 'End date' },
  placeholder: 'YYYY-MM-DD',
  restriction: { pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
  validation: (value: unknown) => {
      const start = startDateValue.value;
      if (!value || !start) return true;
      return String(value) > String(start) || 'End date must be after start date';
  },
  computedProps: [
    (_, fieldValue) => {
      if (startDateValue.value && fieldValue.value)
        validateField('dateRange.endDate')
    }
  ]
},

Re-triggering when the source field changes

A validation function only runs when its own field is validated (on blur, input, or submit). Both fields above use computedProps to re-trigger validateField whenever the source field changes.

computedProps callbacks run inside a Vue computed, which means any reactive ref read inside them is tracked automatically. When passwordValue (or startDateValue) changes, the computed re-runs and — if both fields already have a value — calls validateField on the dependent field. The two concerns stay cleanly separated: the logic lives in validation, and the re-trigger lives in computedProps.

Website Validation

A plain synchronous function validates the URL format. Using the browser's built-in URL constructor keeps the code short and handles edge cases correctly:

ts
function validateWebsite(value: unknown): true | string {
  if (!value) return true;
  try {
    const url = new URL(String(value));
    return (url.protocol === 'https:' || url.protocol === 'http:') || 'Must be a valid URL';
  }
  catch {
    return 'Must be a valid URL (e.g. https://example.com)';
  }
}

Full Metadata

ts
const metadata: Metadata[] = [
  // ─── Account details ────────────────────────────────────────────────────────
  {
    name: 'account',
    type: 'heading',
    fieldOptions: { label: 'Account details' },
    description: 'Combines restriction-based rules with custom and async validation.',
    children: [
      {
        name: 'username',
        fieldOptions: { label: 'Username' },
        description: 'Letters, numbers and underscores only. 3-20 characters.',
        restriction: {
          minLength: 3,
          maxLength: 20,
          pattern: '^[a-zA-Z0-9_]+$',
        },
      },
      {
        name: 'email',
        fieldOptions: { label: 'Email' },
        description: 'Format is checked with a restriction. Availability is verified asynchronously — try admin@example.com.',
        restriction: { pattern: '^.+@.+\\..+$' },
        validation: validateEmailAvailability,
      },
      {
        name: 'password',
        type: 'password',
        showStrengthBar: true,
        fieldOptions: { label: 'Password' },
        description: 'Must contain 8+ characters, uppercase, lowercase, a number and a special character.',
        validation: validatePassword,
      },
      {
        name: 'confirmPassword',
        type: 'password',
        showStrengthBar: false,
        fieldOptions: { label: 'Confirm password' },
        description: 'Re-validated automatically whenever the password field changes.',
        validation: (value: unknown) => {
            if (!value) return true;
            const pwd = passwordValue.value;
            return value === pwd || 'Passwords do not match';
        },
        computedProps: [
          (_, fieldValue) => {
            if (passwordValue.value && fieldValue.value)
              validateField('account.confirmPassword')
          }
        ]
      },
    ],
  },

  // ─── Profile ────────────────────────────────────────────────────────────────
  {
    name: 'profile',
    type: 'heading',
    fieldOptions: { label: 'Profile' },
    description: 'Whitespace and length restrictions keep data clean without writing a single validation function.',
    children: [
      {
        name: 'displayName',
        fieldOptions: { label: 'Display name' },
        description: 'Leading/trailing spaces and repeated whitespace are rejected via the whiteSpace: collapse restriction.',
        restriction: {
          minLength: 2,
          maxLength: 50,
          whiteSpace: 'collapse',
        },
      },
      {
        name: 'bio',
        minOccurs: 0,
        fieldOptions: { label: 'Bio' },
        description: 'Optional. Max 200 characters.',
        restriction: { maxLength: 200 },
      },
      {
        name: 'website',
        minOccurs: 0,
        fieldOptions: { label: 'Website' },
        description: 'Optional. Validated with a custom function — must be a valid http(s) URL.',
        placeholder: 'https://example.com',
        validation: validateWebsite,
      },
    ],
  },

  // ─── Date range (cross-field) ────────────────────────────────────────────────
  {
    name: 'dateRange',
    type: 'heading',
    fieldOptions: { label: 'Date range' },
    description: 'End date is validated against the start date. computedProps re-triggers validation on endDate whenever startDate changes.',
    children: [
      {
        name: 'startDate',
        fieldOptions: { label: 'Start date' },
        placeholder: 'YYYY-MM-DD',
        restriction: { pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
      },
      {
        name: 'endDate',
        fieldOptions: { label: 'End date' },
        placeholder: 'YYYY-MM-DD',
        restriction: { pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
        validation: (value: unknown) => {
            const start = startDateValue.value;
            if (!value || !start) return true;
            return String(value) > String(start) || 'End date must be after start date';
        },
        computedProps: [
          (_, fieldValue) => {
            if (startDateValue.value && fieldValue.value)
              validateField('dateRange.endDate')
          }
        ]
      },
    ],
  },

  // ─── Billing ────────────────────────────────────────────────────────────────
  {
    name: 'billing',
    type: 'heading',
    fieldOptions: { label: 'Billing' },
    description: 'Numeric and format restrictions — no custom code needed.',
    children: [
      {
        name: 'amount',
        fieldOptions: { label: 'Amount (USD)' },
        description: 'Must be greater than 0, at most $10,000, and no more than 2 decimal places.',
        placeholder: '0.00',
        restriction: {
          minExclusive: 0,
          maxInclusive: 10000,
          fractionDigits: 2,
        },
      },
      {
        name: 'promoCode',
        minOccurs: 0,
        fieldOptions: { label: 'Promo code' },
        description: 'Optional. Must be 4-8 uppercase letters or digits (e.g. SAVE2025).',
        placeholder: 'SAVE2025',
        restriction: { pattern: '^[A-Z0-9]{4,8}$' },
      },
    ],
  },
];

Released under the MIT License.