Schema Validation
Vest introduces optional schema validation using n4s (enforce).
Why use a Schema?β
Validating data structure is often the first step in any validation pipeline. Before checking if a username is available, you want to know that the username field actually exists and is a string.
Vest's schema support gives you:
- Type Safety: Automatically infers TypeScript types for your data, so you get autocomplete and error checking in your suite.
- Structural Integrity: Ensures your data matches the expected shape before running more complex validations.
- Fail Fast: If the data structure is wrong, Vest fails immediately, saving resources.
Defining a Schemaβ
Use enforce.shape, enforce.loose, or enforce.partial to define your data structure.
import { create, test, enforce } from 'vest';
const userSchema = enforce.shape({
username: enforce.isString(),
age: enforce.isNumber(),
email: enforce.optional(enforce.isString()), // Optional field
});
const suite = create(data => {
// `data` is typed: { username: string, age: number, email?: string | undefined }
test('username', 'Must be at least 3 chars', () => {
enforce(data.username).longerThan(2);
});
}, userSchema);
How it worksβ
When you pass a schema to create:
- Vest implicitly runs the schema validation before your tests.
- If the data structure doesn't match the schema (e.g.,
ageis a string instead of a number), the suite run fails immediately for those fields. - Your tests run assuming the data types are correct.
TypeScript Inference for createβ
When a schema is passed as the second argument to create, Vest infers the suite callback data type and run(...) payload type directly from that schema.
const userSchema = enforce.shape({
username: enforce.isString(),
age: enforce.isNumber(),
});
const suite = create(data => {
// data is inferred as: { username: string; age: number }
test('username', () => {
enforce(data.username).isNotBlank();
});
}, userSchema);
// `run` payload is typed from schema
suite.run({ username: 'john', age: 42 });
// TypeScript error: `age` must be a number
// suite.run({ username: 'john', age: '42' });
Input vs output types with parsersβ
When a schema uses data parsers, Vest distinguishes between the input type (what suite.run() accepts) and the output type (what the callback receives and what result.value contains).
const schema = enforce.shape({
age: enforce.isNumeric().toNumber(), // input: string | number, output: number
name: enforce.isString().trim().toUpper(), // input: string, output: string
});
const suite = create(data => {
// data.age is typed as `number` (the output type)
test('age', () => {
enforce(data.age).greaterThan(0);
});
}, schema);
// suite.run() accepts `string | number` for age (the input type)
suite.run({ age: '25', name: ' alice ' }); // β
No type error
const result = suite.run({ age: '25', name: ' alice ' });
result.value; // typed as { age: number; name: string }
The first rule in a chain determines the input type, and the last parser in the chain determines the output type. This means you never need @ts-expect-error or as any for valid parser coercion inputs.
What becomes typed from the schemaβ
With create(callback, schema), TypeScript narrows:
- callback data (
data) to the schema input shape. suite.run(...)/suite.validate(...)first argument to the schema input shape.- field-oriented happy-path APIs (
test,optional,include) to schema keys. result.types.inputandresult.types.outputto schema input/output types.
Some lifecycle/focus helpers (remove, resetField, afterField, only, focus.only) intentionally still accept dynamic strings for nested/dynamic runtime workflows.
Group modifiers (onlyGroup / skipGroup) remain string unless you explicitly provide group generics to create.
API coverage (current typing standard)β
When using create(callback, schema), the current TypeScript standard is:
- Field-key inferred from schema for:
test(fieldName, message?, callback)include(fieldName).when(condition)optional(fieldName)
- Group generic-aware (when explicitly provided):
group(groupName, callback)suite.focus({ onlyGroup / skipGroup })
- Intentionally dynamic string-friendly:
suite.remove(fieldName)suite.resetField(fieldName)suite.only(fieldName)suite.afterField(fieldName, callback)only(fieldName)/skip(fieldName)hooks
Explicit generic override (advanced)β
If needed, you can still provide explicit suite generics to fully control field/group names:
const suite = create<'username' | 'age', 'account'>(data => {
// Without a schema, `data` is intentionally untyped (effectively `any`).
test('username', () => {
enforce(data.username).isNotBlank();
});
});
suite.focus({ onlyGroup: 'account' }); // typed group name
When you focus the suite with suite.only(), suite.skip(), or suite.focus(), Vest intelligently subsets your validation schema under the hood using enforce.pick and enforce.omit. This ensures that schema validation still runs securely for the fields in focusβand provides correct types in the test callback!βwhile safely ignoring un-focused fields and allowing you to validate partial payloads effectively.
// Validate only the username field, enforcing the schema for 'username' while ignoring 'age'
suite.only('username').run({
username: 'example',
});
Schema Typesβ
enforce.shape({}): Strict shape. No extra keys allowed.enforce.loose({}): Loose shape. Extra keys are ignored.enforce.partial({}): Partial shape. All keys are optional, but if present must match the type. No extra keys.enforce.isArrayOf(rule): Validates an array where every item matches the rule.
Inspecting schema resultsβ
The suite result includes typed properties for accessing validated and parsed data:
result.valueβ The parsed output when the suite is valid. Typed as the schema's output type.undefinedwhen invalid.result.types.inputβ Carries the schema's input type for static analysis. At runtime, holds the parsed output value.result.types.outputβ Carries the schema's output type. At runtime, holds the parsed output value.result.run.data.rawβ The current run data passed into the suite callback (parsed when schema validation succeeds; original input when it fails).result.run.data.parsedβ Cumulatively merged parsed data across focused runs.
const schema = enforce.shape({
score: enforce.isNumeric().toNumber(),
});
const suite = create(data => {
test('score', () => {
enforce(data.score).greaterThan(0);
});
}, schema);
const result = suite.run({ score: '42' });
result.value; // { score: 42 }
result.types?.output; // { score: 42 }
result.run.data.raw; // { score: 42 }
result.run.data.parsed; // { score: 42 }
Schema Parsingβ
Schema rules support built-in data parsers that transform values as part of validation. When a schema uses parsers, suite.run() receives the transformed data in the callback, and result.value contains the parsed output.
import { create, test, enforce } from 'vest';
const schema = enforce.shape({
name: enforce.isString().trim().toTitle(),
age: enforce.isNumeric().toNumber().clamp(0, 120),
});
const suite = create(data => {
// data is already parsed: { name: 'Jane Doe', age: 120 }
test('name', 'Name is required', () => {
enforce(data.name).isNotBlank();
});
}, schema);
const result = suite.run({ name: ' jANE DOE ', age: '180' });
// result.value β { name: 'Jane Doe', age: 120 }