# Validate application input

> Check a note request, return useful field errors and make the boundary between validation, conversion and authorization explicit.

- Package: `@db3.ai/app/validation`
- Canonical page: [https://db3.ai/docs/validation](https://db3.ai/docs/validation)
- Markdown: [https://db3.ai/docs/validation.md](https://db3.ai/docs/validation.md)
- Framework source of truth: `packages/app/src/validation/README.md`

<a id="setup"></a>

## Validate 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.

- [Installation](https://db3.ai/docs/installation.md)

### Copy the shipped example

```bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/validation/examples/. examples/
```

<a id="run"></a>

## 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.

### Run the lab

```bash
npx tsx examples/runNoteValidation.ts
```

<a id="rules"></a>

## Describe 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.

- [Field-aware model input](https://db3.ai/docs/active-record.md)

### examples/validateNoteInput.ts

```typescript
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) } };
}
```

<a id="conversion"></a>

## 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.

### examples/runNoteValidation.ts

```typescript
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));
}
```

<a id="presence"></a>

## 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`.

<a id="rule-catalogue"></a>

## 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.

- [All current rule contracts](https://db3.ai/docs/validation-api.md)

<a id="errors"></a>

## 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.

<a id="testing"></a>

## 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.

### tests/validation/runNoteValidation.test.ts

```typescript
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: 'ada@example.test' };
	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' });
});
```

<a id="run-tests"></a>

## 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.

### Run your copied test and check types

```bash
npx vitest run tests/validation/runNoteValidation.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

<a id="coverage"></a>

## Coverage 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.

- [Validation API](https://db3.ai/docs/validation-api.md)
- [Authenticated API walkthrough](https://db3.ai/docs/guide-api.md)

## Behavioural verification
Rejects malformed note input, repairs it and returns an explicit safe shape without persisting invalid values.
- Behaviour test: `packages/app/src/validation/tests/examples/runNoteValidation.test.ts`
- Repository test command (framework checkout only): `npm run test:service --workspace @platform/app -- validation --maxWorkers=1`
- In an installed application, use the walkthrough commands instead of this repository test.
- Expected outcome: The guide test passes against the real framework components.
- Environment: Node.js 24. No database, network or credentials.

## Related documentation
- [Validation API reference](https://db3.ai/docs/validation-api.md): Current emitted signatures and options for @db3.ai/app/validation.
- [ActiveRecord](https://db3.ai/docs/active-record.md): Define your fields once. Create, validate, query and save records without repeating database conversion in every endpoint.
- [Keep conversion in the field](https://db3.ai/docs/fields.md): Define a reusable value once, from input and validation through storage and public output.
- [Build an owned-note JSON API](https://db3.ai/docs/guide-api.md): Keep HTTP validation, field conversion and authorization at their own boundaries. Use the starter’s real note routes as the example.

## Framework-owned source: `packages/app/src/validation/README.md`

This is the exact source document captured by the documentation build. Use it for detailed API and workflow guidance, subject to the public package exports and behavioural evidence identified above.

````markdown
# Validation

`@db3.ai/app/validation` provides a small validation layer for
plain request data.

## Run a note-input boundary

Complete [Installation](https://db3.ai/docs/installation), using the matching tarballs while npm publication is pending. From your independent app:

```sh
npm install --save-dev tsx typescript @types/node vitest
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/validation/examples/. examples/
npx tsx examples/runNoteValidation.ts
```

Expect two errors for the first request (blank title and excessive priority). The repaired request returns exactly `{ title: "First note", priority: 2 }`. The example selects writable fields explicitly and never returns submitted secrets, extra properties or an attacker-supplied owner. It does not persist anything or require a database.

[`validateNoteInput.ts`](./examples/validateNoteInput.ts) is the application-owned boundary; [`runNoteValidation.ts`](./examples/runNoteValidation.ts) exercises failure and recovery. The [website walkthrough](https://db3.ai/docs/validation#testing) renders the exact test. Save it as `tests/validation/runNoteValidation.test.ts` and run:

```sh
npx vitest run tests/validation/runNoteValidation.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

Request validation is not authorization. Before writing data, use authenticated ownership and an explicit input policy. When writing an ActiveRecord model, let its fields own conversion rather than copying DB conversion into the route.

```ts
import { validate } from '@db3.ai/app/validation';

const result = validate(request.body, {
	email: ['required', 'email'],
	name: ['required', 'string', { rule: 'maxLength', value: 80 }],
});

if (!result.valid) {
	return reply.status(422).send({
		errors: result.errors.map(({ field, rule, message }) => ({ field, rule, message })),
	});
}
```

## API

### `validate(data, rules)`

Validates a plain object and returns a result object.

```ts
const result = validate(data, rules);

if (result.valid) {
	// result.data is the original object typed as a record.
}
```

Return shape:

```ts
interface ValidationResult<TData extends Record<string, unknown>> {
	valid: boolean;
	data: TData;
	errors: ValidationFailure[];
}
```

`validate()` does not coerce or transform `data`. For example, the `integer`
rule accepts `"42"` as valid input, but `result.data.age` remains the string
`"42"`. Model fields or request DTO code should still own conversion into app
types.

If `data` is not a plain object, validation runs against an empty object.

### `assertValid(data, rules)`

Validates data and returns the original record when valid. It throws
`ValidationException` when validation fails.

```ts
import {
	assertValid,
	ValidationException,
} from '@db3.ai/app/validation';

try {
	const data = assertValid(request.body, {
		email: ['required', 'email'],
	});
} catch (error) {
	if (error instanceof ValidationException) {
		return reply.status(422).send({
			errors: error.errors.map(({ field, rule, message }) => ({ field, rule, message })),
		});
	}

	throw error;
}
```

## Rules

Rules are keyed by field name. Field names currently match top-level keys on the
input object.

```ts
const rules = {
	email: ['required', 'email'],
	age: ['nullable', 'integer', 'min:18'],
	role: [{ rule: 'in', values: ['admin', 'editor'] }],
};
```

Rules can be written in three forms:

```ts
type ValidationRule =
	| 'required'
	| 'email'
	| 'maxLength:80'
	| {
		rule: 'maxLength';
		value: 80;
		message?: string;
	};
```

Use the object form when a rule needs structured values, a regular expression,
or a custom message.

## Presence Rules

### `required`

The field must be present and non-empty.

Whitespace-only strings are not empty to this rule. Add a non-whitespace pattern such as `/\S/` when required, then explicitly trim or let your field normalize the value.

These values fail `required`:

- missing key
- `undefined`
- `null`
- empty string
- empty array

### `nullable`

Allows the field to be missing, `undefined`, `null`, or an empty string. When a
nullable value is empty, the rest of that field's rules are skipped.

```ts
validate(data, {
	website: ['nullable', 'url'],
});
```

Without `nullable`, optional missing values are still skipped by non-presence
rules. Use `required` when the field must be supplied.

## Type And Format Rules

### `string`

Passes when the value is a string.

### `number`

Passes for finite numbers and number-like strings such as `"12.5"`.

### `integer`

Passes for integers and integer-like strings such as `"42"`.

### `boolean`

Passes for booleans, `0`, `1`, and common form strings:

- `true`
- `false`
- `1`
- `0`
- `yes`
- `no`
- `on`
- `off`

### `email`

Passes for basic email-shaped strings.

### `url`

Passes for HTTP/HTTPS-shaped URLs. This is not an SSRF, public-network or authorization check. Values without a protocol are checked as if
they had `https://` prepended.

```ts
validate({
	website: 'example.com',
}, {
	website: ['url'],
});
```

The URL rule requires a hostname containing a dot, so `localhost` fails.

### `array`

Passes for arrays.

### `object`

Passes for objects other than arrays and `Date` instances. This does not enforce a plain-object prototype.

### `ulid`

Passes for canonical ULID-shaped strings.

### `uuid`

Passes for versioned UUID strings.

## Size And Comparison Rules

### `min`

For numeric values, compares the number. For non-numeric strings and arrays,
compares length.

```ts
validate(data, {
	age: ['integer', 'min:18'],
	tags: ['array', { rule: 'min', value: 1 }],
});
```

### `max`

For numeric values, compares the number. For non-numeric strings and arrays,
compares length.

```ts
validate(data, {
	score: ['number', 'max:100'],
	tags: ['array', { rule: 'max', value: 7 }],
});
```

### `minLength`

Compares string or array length.

### `maxLength`

Compares string or array length.

## Choice And Pattern Rules

### `in`

Passes when the value matches one of the allowed values.

```ts
validate(data, {
	status: [{ rule: 'in', values: ['draft', 'published'] }],
});
```

String shorthand is also available:

```ts
validate(data, {
	status: ['in:draft,published'],
});
```

### `regex`

Passes when a string matches the pattern.

Prefer expressions without `g` or `y`: these flags make `RegExp.test()` stateful when the same object is reused.

```ts
validate(data, {
	slug: [{ rule: 'regex', pattern: /^[a-z0-9-]+$/ }],
});
```

String shorthand accepts either a plain pattern or slash-delimited pattern:

```ts
validate(data, {
	slug: ['regex:/^[a-z0-9-]+$/'],
});
```

## Error Shape

Validation failures are returned as structured errors:

```ts
interface ValidationFailure {
	field: string;
	rule: ValidationRuleName;
	message: string;
	value?: unknown;
	details?: Record<string, unknown>;
}
```

Example:

```json
[
	{
		"field": "email",
		"rule": "email",
		"message": "email must be a valid email address",
		"value": "not-an-email"
	}
]
```

Rule object messages override the default message:

```ts
validate(data, {
	email: [{
		rule: 'email',
		message: 'Enter a valid work email address.',
	}],
});
```

## ActiveRecord Integration

Models can generate request-level validation rules from their fields:

```ts
const rules = Website.validationRules();
const result = validate(request.body, rules);
```

By default, generated model rules exclude:

- primary-key fields
- generated fields
- hidden fields
- fields blocked by `requestGuarded`
- fields not listed in `requestFillable`, when `requestFillable` is defined

You can override this per call:

```ts
const rules = Website.validationRules({
	includePrimary: true,
	includeGenerated: true,
	includeHidden: true,
	fillable: ['url', 'businessName'],
	guarded: ['user'],
});
```

Use this pattern at request boundaries:

```ts
const rules = Website.validationRules({
	fillable: ['url', 'businessName', 'targetAudiencePhrases'],
});
const result = validate(request.body, rules);

if (!result.valid) {
	return reply.status(422).send({
		errors: result.errors.map(({ field, rule, message }) => ({ field, rule, message })),
	});
}

const website = new Website();

website.setFromRequestWithMap(request.body, Website.onboardingRequestMap);
website.assign({ user: currentUser });
await website.save();
```

Field-level validation still runs during `record.validate()` and `record.save()`.
Request-level validation is the earlier, plain-data check before a model is
hydrated.

## Coverage and limits

The runnable note lab tests rejection/recovery, safe error output, explicit field selection, form-string conversion, optional/nullable values, length versus numeric size, custom messages and throwing validation. The [full API reference](https://db3.ai/docs/validation-api) contains all current exported rules and helper signatures.

Errors contain the original `value` and optional rule `details`; do not return or log them blindly. The validator does not strip unknown keys, normalize values, traverse nested paths, validate array wildcards, run asynchronous uniqueness checks or enforce ownership. `in` also accepts values with equal string representations. Use explicit nested validation or field-owned structures and keep authorization in the application.

## Field-Generated Rules

Fields publish reusable validation rules with `getValidationRules()`.

```ts
class StringField extends FieldType<string | null> {
	override getValidationRules(ctx?: FieldContext): ValidationRule[] {
		return [
			...super.getValidationRules(ctx),
			'string',
			{
				rule: 'maxLength',
				value: this.config.maxLength,
			},
		];
	}
}
```

Custom fields should include rules that describe their public input contract.
Keep domain or cross-field rules on the model or request object.

## Boundaries

Use validation for these jobs:

- validating raw request data before filling a model
- validating payloads for jobs, services, imports, or integrations
- reusing model field rules without constructing a record
- returning structured 422-style errors to API clients

Use fields for these jobs:

- normalising data into app memory
- converting database values
- converting display/API values
- record persistence validation

Validation answers "is this payload acceptable?". Fields answer "how does this
value move through the app?".
````

## Guidance for AI tools
Use the documented public import `@db3.ai/app/validation` and its exported types. Prefer the source-backed examples and behavioural outcomes above over invented APIs or source-relative internal imports.
