Skip to content

Validation with @QRule ​

QuickModel provides a declarative validation system via the @QRule decorator. Rules are defined directly on model properties and executed via the checkRules() method.

Basic Usage ​

typescript
import { Quick, QType, QModel, QRule } from 'quickmodel';

interface IUser {
	name: string;
	age: number;
	email: string;
}

@Quick()
class User extends QModel<IUser> {
	@QRule(
		(val: string) => val.length >= 3,
		'Name must be at least 3 characters'
	)
	declare name: string;

	@QRule((val: number) => val >= 0, 'Age cannot be negative')
	@QRule((val: number) => val <= 120, 'Age must be realistic')
	declare age: number;

	@QRule((val: string) => val.includes('@'), 'Must be a valid email')
	declare email: string;
}
typescript
const user = new User({ name: 'Jo', age: -1, email: 'notanemail' });

const result = user.$qCheckRules();

console.log(result.valid); // false
console.log(result.errors);
// [
//   { field: 'name',  message: 'Name must be at least 3 characters', value: 'Jo' },
//   { field: 'age',   message: 'Age cannot be negative',             value: -1 },
//   { field: 'email', message: 'Must be a valid email',              value: 'notanemail' }
// ]

TC39 mode ​

When using TC39 standard decorators (experimentalDecorators absent or false), replace declare with ! for fields decorated with @QRule. The decorator logic is identical in both modes.

typescript
// TC39 mode — use ! instead of declare
@Quick()
class User extends QModel<IUser> {
	@QRule((val) => val.length >= 3, 'Name must be at least 3 characters')
	name!: string; // ✅ TC39: use !

	@QRule((val) => val >= 0, 'Age cannot be negative')
	age!: number; // ✅ TC39: use !

	@QRule((val) => val.includes('@'), 'Must be a valid email')
	email!: string; // ✅ TC39: use !
}

@QType + @QRule in TC39 mode — automatic type inference ​

When @QType is combined with @QRule on the same field, the runtime type metadata is registered automatically. Place @QType below @QRule so it fires first (TC39 decorators on a field evaluate bottom-up).

typescript
// TC39 mode — @QType registers field type → @QRule inherits it
@Quick()
class Post extends QModel<IPost> {
	@QRule(
		(val) => val instanceof Date && !isNaN((val as Date).getTime()),
		'Invalid date'
	)
	@QType(Date) // bottom = runs first in TC39
	publishedAt!: Date;

	@QRule(
		(val) => typeof val === 'string' && (val as string).length > 0,
		'Title required'
	)
	@QType(String)
	title!: string;
}

const post = Post.create({ publishedAt: '2025-01-01', title: 'Hello' });
console.log(post.$qCheckRules().valid); // true

Stacking Multiple Rules ​

You can apply multiple @QRule to the same property. All failing rules are collected — not just the first one.

typescript
@QRule((val: number) => val >= 18, 'Must be at least 18 years old')
@QRule((val: number) => val <= 65, 'Must be under 65')
@QRule((val: number) => Number.isInteger(val), 'Age must be a whole number')
declare age: number;
typescript
const u = new User({ ..., age: 17.5 });
const result = u.$qCheckRules();
// errors includes two messages: 'Must be at least 18...' and 'Age must be a whole number'

Valid Result ​

When all rules pass, checkRules() returns { valid: true, errors: [] }.

typescript
const user = new User({ name: 'Alice', age: 30, email: 'alice@example.com' });
const result = user.$qCheckRules();
// { valid: true, errors: [] }

Built-in Validators ​

QuickModel ships a set of ready-to-use decorator validators that mirror the class-validator API, implemented as thin wrappers over @QRule. Import them from the main entry point:

typescript
import { IsEmail, Min, IsNotEmpty } from 'quickmodel';
// or tree-shake to a smaller bundle:
import { IsEmail } from 'quickmodel/validators';
typescript
@Quick()
class User extends QModel<IUser> {
	@IsEmail()
	declare email: string;

	@Min(0)
	@Max(120)
	@IsInt()
	declare age: number;

	@MinLength(3)
	@IsNotEmpty()
	declare name: string;
}

const user = new User({ email: 'bad', age: -1, name: '' });
user.$qCheckRules();
// errors → [ email invalid, age too small, name too short, name empty ]

📖 Built-in Validators reference — Full list of all 14 validators (IsEmail, IsUrl, Min, Max, IsInt, Matches, IsUuid, IsDateString, …) with signatures and examples.

API Reference ​

@QRule(fn, message) ​

ParameterTypeDescription
fn(value: unknown) => booleanValidation function. Return true to pass.
messagestring | (() => string)Static message, i18n key, or lazy resolver evaluated when checkRules() is called.

Can be stacked — applies one rule per decorator call.

checkRules() ​

Runs all @QRule rules declared on the model's properties.

Returns: IQRuleValidationResult

typescript
interface IQRulesResult {
	valid: boolean;
	errors: Array<{
		field: string; // property name
		message: string; // resolved message (string or return value of () => string)
		value: unknown; // current value of the field
	}>;
}

Group filtering with @QGroup ​

Decorate fields with @QGroup('name') to assign them to a named group. Then pass { group } to checkRules() to evaluate only that subset — useful for multi-step forms.

typescript
import { QRule, QGroup } from 'quickmodel';

@Quick()
class SignupModel extends QModel<ISignup> {
	@QRule((val: string) => val.length >= 2, 'Name too short')
	@QGroup('identity')
	declare name: string;

	@QRule((val: string) => /^[^@]+@[^@]+\.[^@]+$/.test(val), 'Invalid email')
	@QGroup('identity')
	declare email: string;

	@QRule((val: string) => val.length >= 8, 'Password too short')
	@QGroup('security')
	declare password: string;
}

const model = new SignupModel({
	name: 'A',
	email: 'a@b.com',
	password: 'Secret1!',
});

// All rules:
model.$qCheckRules();
// { valid: false, errors: [{ field: 'name', message: 'Name too short', ... }] }

// Only 'identity' group — ignore 'security' (e.g. step 1 of 2):
model.$qCheckRules({ group: 'identity' });
// { valid: false, errors: [{ field: 'name', ... }] }

// 'security' group only:
model.$qCheckRules({ group: 'security' });
// { valid: true, errors: [] }

Use qGroups() from the /forms subpath to get typed group name constants with autocomplete:

typescript
import { qGroups } from 'quickmodel/forms';

const Groups = qGroups('identity', 'security');
// Groups.identity === 'identity'  (fully typed — no typos possible)

model.$qCheckRules({ group: Groups.identity });

NOTE

Without a group filter checkRules() evaluates all @QRule-decorated fields, including ungrouped ones. With a group filter only fields carrying @QGroup(name) matching that group are evaluated.

hasIntegrity() ​

Boolean shortcut for checkIntegrity().length === 0.

Returns true when every field value conforms to its declared transformer type (no DoS limits exceeded, no type mismatches).

typescript
if (!user.$qHasIntegrity()) {
	const errors = user.$qCheckIntegrity();
	// handle transformer-level violations...
}

isValid() ​

Single boolean gate that combines both checks: hasIntegrity() && checkRules().valid.

Returns true only when the instance has full type integrity and all business rules pass.

typescript
if (!user.$qIsValid()) {
	// dig into specific failures:
	const integrityErrors = user.$qCheckIntegrity(); // type-level
	const ruleErrors = user.$qCheckRules().errors; // business-logic
}
MethodReturnsWhat it checks
checkIntegrity()IQIntegrityResult[]Transformer constraints (types, DoS limits)
hasIntegrity()booleanShortcut: checkIntegrity().length === 0
checkRules()IQRulesResultBusiness rules (@QRule predicates)
isValid()booleanBoth: integrity + rules
validationReport()IQValidationReportBoth, but returns full detail

validationReport() ​

Single call that runs both checks and returns detailed results.

typescript
const report = user.$qValidationReport();

if (!report.valid) {
	// transformer-level failures:
	report.integrity.forEach((err) => console.error(err.error));
	// @QRule failures:
	report.rules.errors.forEach((err) => console.error(err.field, err.message));
}

Returns: IQValidationReport

typescript
interface IQValidationReport {
	valid: boolean;
	integrity: IQIntegrityResult[]; // empty = all pass
	rules: IQRulesResult; // { valid, errors[] }
}

i18n Support ​

The message parameter accepts string | (() => string). The function form is evaluated lazily — at checkRules() call-time — which makes it suitable for runtime i18n:

typescript
import { t } from './i18n'; // your global translator

@Quick({ name: 'string' })
class User extends QModel<IUser> {
	// Lazy: resolved when checkRules() is called
	@QRule((val) => (val as string).length >= 3, () => t('validation.name.min'))
	declare name: string;
}

If the user changes language at runtime, the next checkRules() call will reflect the new language.

Angular: plain keys + | translate pipe ​

You can also store plain i18n keys as the message and resolve them in the template — no lazy function needed:

typescript
@QRule((val) => (val as string).length >= 3, 'validation.name.min')
declare name: string;
html
<!-- In your Angular template -->
<span *ngFor="let e of result.errors">{{ e.message | translate }}</span>

Both approaches are valid; pick the one that fits your architecture.

NOTE

checkRules() is separate from checkIntegrity(). checkIntegrity() checks transformer-level constraints (DoS limits, type ranges). checkRules() is for your business logic.

Combining with Change Tracking ​

@QRule and checkRules() work seamlessly alongside isDirty(), copy(), and patch().

typescript
const user = new User({ name: 'Alice', age: 30, email: 'alice@example.com' });

const updated = user.$qCopy({ age: -5 });
const result = updated.$qCheckRules();

console.log(result.valid); // false
console.log(result.errors[0].field); // 'age'

Inheriting Rules ​

@QRule rules are inherited: a subclass will also run rules defined on its parent's properties.

typescript
@Quick()
class Admin extends User {
	@QRule(
		(val: string) => val.startsWith('ADMIN_'),
		'Admin name must start with ADMIN_'
	)
	declare name: string; // overrides parent rule for 'name'
}

WARNING

If a subclass re-declares a property with @QRule, only the subclass rules apply to that field (the parent rules for that field are overridden). Fields not re-declared keep their parent rules.

Form Schema (@QField) ​

Use @QField to annotate model properties with form metadata. Then call getFormSchema() to get a schema array ready to pass to any form library (Angular, React, etc.).

typescript
import { QField } from 'quickmodel';

@Quick({ birthDate: Date })
class ProfileModel extends QModel<IProfile> {
	@QField({
		widget: 'input',
		inputType: 'email',
		label: 'Email',
		required: true,
	})
	declare email: string;

	@QField({
		widget: 'select',
		label: 'Role',
		options: ['admin', 'user', 'guest'],
	})
	declare role: string;

	@QField({ widget: 'checkbox', label: 'Active' })
	declare active: boolean;

	@QField({ widget: 'datepicker', label: 'Birth date' })
	declare birthDate: Date;
}

// Static — no instance needed:
const schema = ProfileModel.getFormSchema();
// [
//   { field: 'email',     widget: 'input',     inputType: 'email', label: 'Email', required: true },
//   { field: 'role',      widget: 'select',    options: ['admin','user','guest'], label: 'Role' },
//   { field: 'active',    widget: 'checkbox',  label: 'Active' },
//   { field: 'birthDate', widget: 'datepicker',label: 'Birth date' },
// ]

Supported widgets ​

WidgetTypical use
inputText, email, password fields
textareaMulti-line text
selectDropdown list
checkboxBoolean toggle
radioSingle-choice from a list
datepickerDate / datetime picker
numberNumeric input
switchMaterial/UI toggle switch
'my-widget'Any custom string is also valid

Extra metadata ​

Any additional property you pass is preserved:

typescript
@QField({
	widget: 'input',
	inputType: 'email',
	label: 'Email',
	placeholder: 'you@example.com',
	hint: 'Must be unique in the system',
	cssClass: 'full-width',
})
declare email: string;

Inheritance ​

Subclasses inherit their parent's @QField entries. Re-declaring a field overrides it.

Async Validation ​

All sync methods have async equivalents that handle predicates returning Promise<boolean>.

checkRulesAsync() ​

Like checkRules() but awaits each predicate. Use this when any @QRule contains an async function (e.g. a database uniqueness check).

typescript
// Async predicate — e.g. checks uniqueness against a DB
@QRule(
	async (val) => !(await db.emailExists(val as string)),
	'Email already taken'
)
declare email: string;

// Evaluate
const result = await user.$qCheckRulesAsync();
if (!result.valid) {
	console.log(result.errors); // [{ field: 'email', message: '...', value: '...' }]
}

Sync predicates also work seamlessly — they are wrapped in Promise.resolve() internally.

NOTE

checkRules() (sync) still works as before and ignores async predicates — it does NOT await them. Use checkRulesAsync() when async rules are present.

Execution mode ​

By default all predicates run in parallel (mode: 'parallel'), so total time ≈ the slowest individual predicate. Pass mode: 'serial' when predicates must run one after the other (e.g. checking format before hitting the database):

typescript
// Parallel (default) — all predicates race simultaneously
const result = await user.$qCheckRulesAsync();

// Serial — predicates run in field-declaration order
const result = await user.$qCheckRulesAsync({ mode: 'serial' });
ModeTotal timeWhen to use
'parallel' (default)max(individual times)Independent I/O calls
'serial'Σ(individual times)Side-effects or strict ordering required

Per-predicate timeout ​

Pass timeoutMs to give each predicate a maximum budget. Predicates that exceed it fail with timedOut: true in the error entry. Optionally provide a custom timeoutMessage:

typescript
const result = await user.$qCheckRulesAsync({
	timeoutMs: 200,
	timeoutMessage: 'Service unavailable', // or () => i18n.t('errors.timeout')
});

result.errors.forEach((err) => {
	if (err.timedOut) {
		console.warn(`${err.field}: predicate timed out after 200 ms`);
	}
});

Options can be combined freely:

typescript
// Serial execution with a 300 ms budget per predicate
const result = await user.$qCheckRulesAsync({
	mode: 'serial',
	timeoutMs: 300,
});

isValidAsync() ​

Async equivalent of isValid(). Returns Promise<boolean>.

typescript
if (await user.$qIsValidAsync()) {
	// integrity OK + all async @QRule predicates pass
}

validationReportAsync() ​

Async equivalent of validationReport(). Returns Promise<IQValidationReport>.

typescript
const report = await user.$qValidationReportAsync();
if (!report.valid) {
	report.integrity.forEach((err) => console.error(err.error));
	report.rules.errors.forEach((err) => console.error(err.field, err.message));
}

Async API summary ​

MethodReturnsNotes
checkRulesAsync(options?)Promise<IQRulesResult>Awaits sync + async predicates
isValidAsync(options?)Promise<boolean>Integrity + async rules
validationReportAsync(options?)Promise<IQValidationReport>Full report, async-safe

options (IQRulesAsyncOptions)

OptionTypeDefaultDescription
mode'parallel' | 'serial''parallel'Execution order of predicates
timeoutMsnumber—Max ms per predicate; exceeded → timedOut: true
timeoutMessagestring | () => stringrule messageMessage used when a predicate times out

Validating without QModel — the /forms subpath ​

All helpers on this page (checkRules, checkRulesAsync, group filtering…) are also available as standalone functions that work on any plain class — no need to extend QModel. Import them from the /forms entry point:

typescript
import { QRule, QGroup } from 'quickmodel';
import {
	qGroups,
	qCheckRules,
	qCheckRulesAsync,
	qCheckRulesByGroup,
	qCheckRulesByGroupAsync,
} from 'quickmodel/forms';

const Groups = qGroups('identity', 'security');

class ProfileForm {
	// ← plain class, no QModel
	@QRule((val: string) => val.length >= 2, 'Too short')
	@QGroup(Groups.identity)
	name = '';
}

const form = new ProfileForm();
const result = qCheckRules(form, { group: Groups.identity });

📖 Form Validation guide — Full reference: qGroups, qGetGroups, qCheckRules, qCheckRulesAsync, qCheckRulesByGroup, qCheckRulesByGroupAsync, Angular/React/Vue examples.

Tracing rule evaluations ​

@QRule accepts an optional third argument — options: IQRuleOptions — that lets you configure per-rule trace behavior independently of global and per-model settings.

The resolution chain is: per-rule > per-model > global > silent.

typescript
import { QRule } from 'quickmodel';

class PaymentModel extends QModel<{ amount: number; currency: string }> {
	// Always surface failures even if global trace is 'silent'
	@QRule<number>((val) => val > 0, 'Amount must be positive', {
		trace: { verbosity: 'warn' },
	})
	declare amount: number;

	// Route only failures of this rule to a security sink
	@QRule<string>((val) => /^[A-Z]{3}$/.test(val), 'Invalid currency code', {
		trace: {
			verbosity: 'error',
			events: ['rule-fail', 'rule-error'],
			sink: (entry) => securityAudit.write(entry),
		},
	})
	declare currency: string;
}

📖 Tracing & Observability guide — Full reference for global, per-model and per-rule trace configuration, IQTraceEntry structure, and real-world use cases.

Performance ​

⚡ Performance Benchmark

Hover the bars to see details

1k iterations — async rule orchestration with instant-resolving predicates (measures orchestration overhead). QuickModel parallel mode: Promise.allSettled over all @QRule decorators — optimal for IO-bound rules. joi validateAsync(): Promise per field, sequential. yup validate() async: sequential rule execution. QM unique: timeoutMs per rule + serial/parallel mode switch.

QuickModel
127k ops/s
›› noticeably faster
joi
8k ops/s
yup
5k ops/s

Not applicable in this scenario: TypeBox, arktype, valibot, Zod, class-validator, vest

← slower       faster →