Skip to content

Latest commit

 

History

History
333 lines (237 loc) · 8.99 KB

File metadata and controls

333 lines (237 loc) · 8.99 KB

Vest 6 — Consumer Usage Guide

Vest is a validation library that keeps test results between runs and does not depend on a UI framework.

Use Vest when validation unfolds over time: only some fields should run, earlier results must remain available, fields depend on one another, or async checks can overlap.

Full documentation: https://vestjs.dev/docs/get_started

The core model

  • A suite contains executable validation rules.
  • suite.run(data) performs a stateful run and reconciles it with previous results.
  • suite.only(field).run(data) runs one field while retaining other field results.
  • suite.runStatic(data) performs an independent stateless run, usually on the server.
  • The result distinguishes errors, warnings, pending work, tested fields, and validity.
  • Async tests automatically discard stale results when a newer run supersedes them.

Vest manages validation state. It does not own input values, DOM nodes, events, or form submission.

Installation

npm i vest

Current Vest 6 syntax

import { create, enforce, test } from 'vest';

export const suite = create((data = {}) => {
  test('username', 'Username is required', () => {
    enforce(data.username).isNotBlank();
  });

  test('username', 'Username must be at least 3 characters', () => {
    enforce(data.username).longerThanOrEquals(3);
  });
});

const result = suite.run({ username: 'evyatar' });
result.isValid('username');

Focused validation and retained state

Use suite-level focus at the call site for user-driven validation:

const result = suite.only('username').run(formData);

Only username tests run. Previously established results for other fields remain in the suite.

For combined focus rules:

suite
  .focus({
    only: ['street', 'city'],
    skipGroup: 'billing',
  })
  .run(formData);

Available modifiers are only, skip, onlyGroup, and skipGroup.

Use top-level only() and skip() inside the suite only when inclusion depends on business data rather than a UI event.

Async validation

const suite = create(data => {
  test('username', 'Username is already taken', async ({ signal }) => {
    const response = await fetch('/api/check-username', {
      method: 'POST',
      body: JSON.stringify({ username: data.username }),
      signal,
    });

    const { available } = await response.json();
    enforce(available).isTruthy();
  });
});

When username validation runs again, Vest cancels the previous test and ignores its eventual result. Pass the supplied signal to fetch to stop obsolete network work.

The result is usable immediately and is also awaitable:

const result = suite.only('username').run(formData);

result.isPending('username');
result.hasErrors('username');

await result;

Await a full run before submission:

const result = await suite.run(formData);

if (result.isValid()) {
  submit(formData);
}

Use suite.afterEach(callback) when the UI must update after the synchronous pass and after each async completion. Read the current result with suite.get() inside the callback.

Dependent fields

Use include to run a related field when another focused field runs:

import { create, enforce, include, test } from 'vest';

const suite = create(data => {
  include('confirmPassword').when('password');

  test('password', 'Password is required', () => {
    enforce(data.password).isNotBlank();
  });

  test('confirmPassword', 'Passwords do not match', () => {
    enforce(data.confirmPassword).equals(data.password);
  });
});

Now suite.only('password').run(data) also reevaluates confirmPassword.

Groups and multi-step workflows

import { create, enforce, group, test } from 'vest';

const suite = create(data => {
  group('profile', () => {
    test('name', 'Name is required', () => {
      enforce(data.name).isNotBlank();
    });
  });

  group('address', () => {
    test('city', 'City is required', () => {
      enforce(data.city).isNotBlank();
    });
  });
});

suite.focus({ onlyGroup: 'address' }).run(formData);

Query group results with isValidByGroup, hasErrorsByGroup, hasWarningsByGroup, getErrorsByGroup, and getWarningsByGroup.

Conditional and optional sections

Use skipWhen to avoid executing tests that are still required for eventual validity:

skipWhen(
  result => result.hasErrors('username'),
  () => {
    test('username', 'Username is already taken', async () => {
      await checkUsername(data.username);
    });
  },
);

Use omitWhen when a section is currently irrelevant and should not count against suite validity:

omitWhen(!data.hasShippingAddress, () => {
  test('street', 'Street is required', () => {
    enforce(data.street).isNotBlank();
  });
});

Use optional(field) for a field that may be empty but must pass its tests when provided.

Errors, warnings, pending, and tested state

result.isValid();
result.isValid('username');
result.hasErrors('username');
result.getError('username');
result.getErrors('username');
result.hasWarnings('password');
result.getWarning('password');
result.isPending('username');
result.isTested('username');

isValid() === false can mean the workflow is incomplete or pending; it does not always mean the user caused an error.

Create a non-blocking warning with warn():

import { enforce, test, warn } from 'vest';

test('password', 'Consider a stronger password', () => {
  warn();
  enforce(data.password).matches(/\d/);
});

For warnings invoked after an await, capture the warning callback with useWarn() before leaving the synchronous call stack.

Dynamic lists

Use each() and give every test a stable key as the final argument:

import { each, enforce, test } from 'vest';

each(data.travelers, traveler => {
  test(
    'travelerName',
    'Traveler name is required',
    () => enforce(traveler.name).isNotBlank(),
    traveler.id,
  );
});

Do not use an array index as the key when items can be inserted, removed, or reordered.

Memoization and expensive checks

import { memo } from 'vest/memo';

memo(
  () => {
    test('username', 'Username is already taken', async () => {
      await checkUsername(data.username);
    });
  },
  [data.username],
  { cacheSize: 5, ttl: 30_000 },
);

When dependencies match a cached run, Vest restores the earlier test result instead of repeating the work.

Schema validation and parsing

const accountSchema = enforce.shape({
  username: enforce.isString().trim(),
  age: enforce.isNumeric().toNumber(),
});

const suite = create(data => {
  test('age', 'Must be 18 or older', () => {
    enforce(data.age).greaterThanOrEquals(18);
  });
}, accountSchema);

const result = suite.run({ username: '  evyatar ', age: '30' });
result.value; // { username: 'evyatar', age: 30 } when valid

Schema-aware suites infer the run input, parsed callback data, result value, and field-oriented APIs.

Vest can also be used alongside Zod: use Vest for progressive interaction and Zod for a final submitted boundary.

Server and SSR usage

Use stateless execution for independent server requests:

const result = await suite.runStatic(requestBody);

Do not retain one user's state in another server request.

For SSR, serialize the server result and resume it in the browser:

import { SuiteSerializer } from 'vest/exports/SuiteSerializer';

const state = SuiteSerializer.serialize(serverResult);
SuiteSerializer.resume(suite, state);

Standard Schema

Vest suites and Enforce rules implement Standard Schema V1.

Pass the suite itself to a Standard Schema consumer. The consumer invokes the suite's ~standard.validate contract automatically.

For application code, use suite.run(data) for stateful validation or suite.runStatic(data) for an independent server run. Do not treat .validate() as Vest's general execution API; it exists for Standard Schema interoperability.

If you are implementing or testing a Standard Schema integration directly, invoke the contract explicitly:

const output = await suite['~standard'].validate(data);

State lifecycle

suite.get();
suite.reset();
suite.resetField('email');
suite.remove('removedDynamicField');

Reset a suite when beginning a new form transaction. Remove dynamic fields when they no longer exist so they do not affect overall validity.

When not to use Vest

Vest may be unnecessary for a trivial synchronous form or a one-shot object parse. Use a schema validator when the primary task is defining and parsing a complete data boundary. Use a form manager when the primary task is input registration and value management.

Obsolete patterns to avoid

Do not generate pre–Vest 6 APIs:

suite(data); // obsolete: use suite.run(data)
result.done(callback); // obsolete: await the run or use suite.afterEach
promisify(suite); // obsolete: suite.run() is awaitable
staticSuite(...); // obsolete: use suite.runStatic(...)
test.memo(...); // obsolete: import memo from 'vest/memo'

Prefer suite.only(field).run(data) or suite.focus(...).run(data) for UI-driven focused validation.