Validate application input
Check a note request, return useful field errors and make the boundary between validation, conversion and authorization explicit.
On this page
Source-backed MarkdownValidate a note request
Follow Installation, then copy the example into your app. This is a plain-data validation lab; it does not need a server or database.
Validate at the boundary before business work or persistence. Authentication and ownership are separate checks: a valid title does not give someone permission to edit a note.
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/validation/examples/. examples/Reject, repair and run again
The first request contains a whitespace-only title and priority 9. Expect two field errors, with no submitted values. The repaired request produces exactly { title: "First note", priority: 2 }. An injected ownerId is not copied.
Change the title or priority in the runner and repeat. There are no resources to clean up and no invalid record can be written by this lab.
npx tsx examples/runNoteValidation.tsDescribe the input you accept
validate(input, rules) returns { valid, data, errors }. Rules apply to exact top-level keys, in order; validation collects errors rather than stopping after the first rule.
This function explicitly selects writable values after validation. In an ActiveRecord workflow, keep field conversion in the model and use its request mapping/fillable policy. Do not duplicate its database conversion in a route.
import { validate, type ValidationRuleName } from '@db3.ai/app/validation';
/** Safe, explicitly selected values accepted by the application note workflow. */
export interface NoteInput {
title: string;
priority: number;
}
/** Public validation response deliberately omits submitted values and rule details. */
export type NoteInputResult = { valid: true; data: NoteInput } | { valid: false; errors: Array<{ field: string; rule: ValidationRuleName; message: string }> };
/**
* Validates a note request, then explicitly converts and selects writable fields.
*
* Validation does not authorize a writer, remove unknown fields or convert form
* strings. The application owns those steps; model fields should own conversion
* instead when the destination is an ActiveRecord model.
*
* @param input - Untrusted JSON request body.
* @returns Safe application data or field errors without submitted values.
*/
export function validateNoteInput(input: unknown): NoteInputResult {
const result = validate(input, {
title: ['required', 'string', { rule: 'minLength', value: 1 }, { rule: 'maxLength', value: 120 }, { rule: 'regex', pattern: /\S/, message: 'Enter a title, not only spaces.' }],
priority: ['required', 'integer', 'min:1', 'max:5'],
});
if (!result.valid) {
return { valid: false, errors: result.errors.map(({ field, rule, message }) => ({ field, rule, message })) };
}
return { valid: true, data: { title: String(result.data.title).trim(), priority: Number(result.data.priority) } };
}
Validation is not conversion
data is the original object, not a sanitized DTO. Unknown properties remain. integer accepts a string such as "2" without changing it; generic type arguments are TypeScript assertions, not runtime transformations.
Our small non-model example converts priority with Number() and returns only title and priority. A route should return 422 on failure and continue only on success. Assign owner/workspace IDs from the authenticated context, not the request body.
import { pathToFileURL } from 'node:url';
import { validateNoteInput } from './validateNoteInput';
/**
* Exercises rejected input and recovery without a database or network access.
*
* @returns Invalid and repaired request outcomes; no submitted secret is returned.
*/
export function runNoteValidation() {
return {
invalid: validateNoteInput({ title: ' ', priority: '9', password: 'do-not-return-this' }),
repaired: validateNoteInput({ title: ' First note ', priority: '2', ownerId: 'attacker-controlled' }),
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
console.log(JSON.stringify(runNoteValidation(), null, 2));
}
Required, optional and nullable values
required rejects a missing key, undefined, null, an empty string or an empty array. It does not reject whitespace-only strings; the example adds /\S/ and trims after success.
Without required, missing/null/undefined input skips other rules. nullable additionally skips an empty string. Pair the rules deliberately; nullable does not override required.
Choose the right rule
Type/format rules: string, number, integer, boolean, email, url, array, object, ulid, uuid. Number and boolean checks accept common form strings; they do not convert them.
Use minLength and maxLength for text/array length. min and max interpret number-like strings numerically first: "123" exceeds max:10 but passes maxLength:10.
in compares both exact values and their string forms. regex accepts a RegExp object or a string pattern. Use non-global, non-sticky patterns: reusable /g or /y expressions have mutable lastIndex state.
url checks HTTP/HTTPS shape and a hostname containing a dot, adding HTTPS for the check when omitted. It is not a public-network/SSRF check, fetch permission or URL normalization. object excludes arrays and dates but does not guarantee a plain object prototype.
Return errors without reflecting secrets
A rule object can provide a custom message. The library error includes the original value and may include rule details. Map to { field, rule, message } before sending or logging user-facing errors, especially for passwords and tokens.
assertValid() returns the same data on success or throws ValidationException with .errors. Catch that specific error at the boundary; unexpected exceptions should still fail. Unknown rules are programming errors, not a valid user request.
Testing
Create a tests/validation directory and save the test below as runNoteValidation.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.
Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.
import { expect, it } from 'vitest';
import { assertValid, validate, ValidationException } from '@db3.ai/app/validation';
import { runNoteValidation } from '../../examples/runNoteValidation';
import { validateNoteInput } from '../../examples/validateNoteInput';
it('rejects bad input, returns safe errors, then explicitly selects and converts a valid note', () => {
const output = runNoteValidation();
expect(output.invalid.valid).toBe(false);
if (!output.invalid.valid) expect(output.invalid.errors).toEqual([{ field: 'title', rule: 'regex', message: 'Enter a title, not only spaces.' }, { field: 'priority', rule: 'max', message: 'priority is invalid' }]);
expect(output.repaired).toEqual({ valid: true, data: { title: 'First note', priority: 2 } });
expect(JSON.stringify(output)).not.toMatch(/do-not-return-this|attacker-controlled|password|ownerId/);
for (const input of [null, [], {}, { title: 1, priority: 1 }, { title: 'x'.repeat(121), priority: 1 }, { title: 'Note', priority: 1.5 }]) expect(validateNoteInput(input).valid).toBe(false);
});
it('demonstrates original-data, presence, length and top-level-only semantics', () => {
const input = { priority: '2', extra: 'unchanged' };
const result = validate(input, { priority: ['required', 'integer'] });
expect(result.data).toBe(input);
expect(result.data.priority).toBe('2');
expect(result.data.extra).toBe('unchanged');
expect(validate({ title: ' ' }, { title: ['required'] }).valid).toBe(true);
expect(validate({}, { title: ['string'] }).valid).toBe(true);
expect(validate({ url: '' }, { url: ['nullable', 'url'] }).valid).toBe(true);
expect(validate({ title: '123' }, { title: ['max:10'] }).valid).toBe(false);
expect(validate({ title: '123' }, { title: ['maxLength:10'] }).valid).toBe(true);
expect(validate({ profile: { name: 'Ada' } }, { 'profile.name': ['required'] }).valid).toBe(false);
});
it('supports custom messages and a throwing boundary without claiming type conversion', () => {
const data = { email: '[email protected]' };
expect(assertValid(data, { email: ['required', 'email'] })).toBe(data);
expect(() => assertValid({}, { email: ['required'] })).toThrow(ValidationException);
const result = validate({ email: 'private-invalid-value' }, { email: [{ rule: 'email', message: 'Enter an email address.' }] });
expect(result.errors[0]).toMatchObject({ message: 'Enter an email address.', value: 'private-invalid-value' });
});
Test your boundary
The three copied tests cover rejection/recovery, non-disclosure, extra-field selection, length and numeric limits, malformed objects, no coercion, optional/nullable values and throwing validation.
Extend the application tests with authorized/unauthorized writers and prove failed requests do not change database state. The isolated lab does not claim that HTTP or SQL behavior.
npx vitest run tests/validation/runNoteValidation.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.tsCoverage and limits
Taught and tested: note input, explicit output selection/conversion, safe field errors, presence, size, pattern and integer constraints, custom messages and throwing validation. The reference includes every current rule and exported helper.
No nested-path traversal, wildcards, async/custom callback rules, uniqueness query, unknown-field stripping, coercion or authorization policy is built in. Validate nested records explicitly or use the owning model field structure; do not invent dotted-path behavior.
Rejects malformed note input, repairs it and returns an explicit safe shape without persisting invalid values.
The guide test passes against the real framework components.packages/app/src/validation/tests/examples/runNoteValidation.test.tsThis test command requires the framework repository. Use the walkthrough commands in an installed application.
Environment: Node.js 24. No database, network or credentials.