NestJS Integration
QuickModel works seamlessly with NestJS as a drop-in replacement for DTO classes backed by class-validator + class-transformer. Because both rely on reflect-metadata and TypeScript decorators, the integration requires zero additional configuration.
Prerequisites
All NestJS projects already import reflect-metadata in main.ts. QuickModel uses the same mechanism, so no extra setup is needed.
No extra setup
If your main.ts already contains import 'reflect-metadata' (standard in every NestJS app), QuickModel picks it up automatically.
Make sure experimentalDecorators and emitDecoratorMetadata are enabled — they are in any standard NestJS project:
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Installation
npm install quickmodelUsing QModel as a DTO
Define your DTOs extending QModel instead of plain classes. You get automatic type coercion, validation, and serialization for free — no class-transformer needed.
// create-user.dto.ts
import { QModel, Quick } from 'quickmodel';
interface ICreateUserBody {
name: string;
email: string;
birthDate: string | Date;
age: number;
active: boolean;
}
@Quick(
{ birthDate: Date, active: 'boolean' },
{ unknownPropertyPolicy: 'strip' }
)
export class CreateUserDto extends QModel<ICreateUserBody> {
declare name: string;
declare email: string;
declare birthDate: Date; // automatically converted from ISO string
declare age: number;
declare active: boolean;
}Type coercion
@Quick({ birthDate: Date }) tells QuickModel to call new Date(value) when populating the field. The JSON body string "1994-06-15T00:00:00.000Z" becomes a proper Date instance — no manual new Date(dto.birthDate) needed.
Boolean fields
Use 'boolean' (string literal) in @Quick to declare a boolean field — not the Boolean constructor. The string literal activates the PrimitiveTransformer which validates the value is an actual boolean. In a standard NestJS JSON body, true/false are already parsed as real booleans by the JSON parser.
In your controller:
// users.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() body: object) {
return this.usersService.create(body);
}
}Validation with @QRule
Replace class-validator with @QRule for business-rule validation. Predicates are fully typed using generics — no more (value as string) casts:
// create-user.dto.ts
import { QModel, Quick, QRule } from 'quickmodel';
@Quick(
{ birthDate: Date, active: 'boolean' },
{ unknownPropertyPolicy: 'strip' }
)
export class CreateUserDto extends QModel<ICreateUserBody> {
@QRule((value: string) => value.trim().length > 0, 'Name is required')
@QRule((value: string) => value.trim().length <= 100, 'Name too long')
declare name: string;
@QRule(
(value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
'Invalid email format'
)
declare email: string;
@QRule((value: number) => value >= 0, 'Age cannot be negative')
@QRule((value: number) => value <= 130, 'Age must be realistic')
declare age: number;
declare birthDate: Date;
declare active: boolean;
}Validate in the service layer:
// users.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { CreateUserDto } from './create-user.dto';
@Injectable()
export class UsersService {
create(data: object): object {
const dto = new CreateUserDto(data);
const result = dto.$qCheckRules();
if (!result.valid) {
throw new BadRequestException({
message: 'Validation failed',
errors: result.errors, // [{ field, message, value }, ...]
});
}
return dto.$qSerialize();
}
}result.errors has the same shape as class-validator errors, so it's compatible with any existing NestJS error handler or exception filter.
Custom ValidationPipe
Wrap QuickModel validation in a standard NestJS PipeTransform for automatic injection at the controller level, keeping your controllers clean:
// quickmodel-validation.pipe.ts
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { QModel } from 'quickmodel';
@Injectable()
export class QuickModelValidationPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata) {
const { metatype } = metadata;
// Only process classes that extend QModel
if (!metatype || !(metatype.prototype instanceof QModel)) {
return value;
}
const instance = new (metatype as new (data: object) => QModel<object>)(
value as object
);
const result = instance.$qCheckRules();
if (!result.valid) {
throw new BadRequestException({
message: 'Validation failed',
errors: result.errors,
});
}
return instance; // controller receives a fully coerced + validated QModel instance
}
}Register globally in main.ts:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { QuickModelValidationPipe } from './quickmodel-validation.pipe';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new QuickModelValidationPipe());
await app.listen(3000);
}
bootstrap();Nested DTOs
Use a nested QModel class in @Quick to automatically instantiate and validate nested objects:
// address.dto.ts
@Quick({}, { unknownPropertyPolicy: 'strip' })
export class AddressDto extends QModel<IAddress> {
@QRule((value: string) => value.trim().length > 0, 'Street is required')
declare street: string;
@QRule((value: string) => value.trim().length > 0, 'City is required')
declare city: string;
@QRule((value: string) => /^\d{5}$/.test(value), 'ZIP must be 5 digits')
declare zip: string;
}
// create-order.dto.ts
@Quick({ shippingAddress: AddressDto }, { unknownPropertyPolicy: 'strip' })
export class CreateOrderDto extends QModel<ICreateOrderBody> {
@QRule((value: string) => value.trim().length > 0, 'Product ID required')
declare productId: string;
@QRule((value: number) => value >= 1, 'Quantity must be at least 1')
declare quantity: number;
declare shippingAddress: AddressDto; // automatically instantiated as AddressDto
}// In your service:
const order = new CreateOrderDto(body);
// order.shippingAddress is already an AddressDto instance
const addrResult = order.shippingAddress.$qCheckRules();
if (!addrResult.valid) {
throw new BadRequestException({ errors: addrResult.errors });
}Async @QRule (DB Uniqueness)
Use checkRulesAsync() when any @QRule predicate is asynchronous — typically for database uniqueness checks:
// register.dto.ts
@Quick({}, { unknownPropertyPolicy: 'strip' })
export class RegisterDto extends QModel<IRegisterBody> {
@QRule((value: string) => value.length >= 3, 'Username min 3 chars')
@QRule(
(value: string) => /^[a-zA-Z0-9_]+$/.test(value),
'Only letters, numbers, underscores'
)
declare username: string;
// Async predicate — queries the DB
@QRule(async (value: string) => {
const exists = await db.users.findOne({ email: value });
return !exists;
}, 'Email already registered')
declare email: string;
@QRule((value: string) => value.length >= 8, 'Password min 8 chars')
@QRule(
(value: string) => /[A-Z]/.test(value),
'Password needs uppercase letter'
)
@QRule((value: string) => /[0-9]/.test(value), 'Password needs a digit')
declare password: string;
}// auth.service.ts
@Injectable()
export class AuthService {
async register(data: object): Promise<object> {
const dto = new RegisterDto(data);
// evaluates both sync AND async predicates — all in parallel by default
const result = await dto.$qCheckRulesAsync();
if (!result.valid) {
throw new BadRequestException({
message: 'Registration failed',
errors: result.errors,
});
}
return this.saveUser(dto.$qSerialize());
}
}Timeout and execution mode
checkRulesAsync() accepts an optional IQRulesAsyncOptions object:
// Give each DB predicate a 300 ms budget — prevents hanging requests
const result = await dto.$qCheckRulesAsync({
timeoutMs: 300,
timeoutMessage: 'Service temporarily unavailable',
});
// Check which fields timed out vs. failed logically
result.errors.forEach((err) => {
if (err.timedOut) {
// predicate exceeded the 300 ms budget
this.logger.warn(`${err.field}: async check timed out`);
}
});By default all predicates run in parallel. Use mode: 'serial' when predicates must run in order (e.g. validate format locally before making a DB call):
// Serial: format check runs first, DB call only happens if format passes
const result = await dto.$qCheckRulesAsync({
mode: 'serial',
timeoutMs: 300,
});| Option | Type | Default | Description |
|---|---|---|---|
mode | 'parallel' | 'serial' | 'parallel' | Execution order of predicates |
timeoutMs | number | — | Max ms per predicate; exceeded → timedOut: true |
timeoutMessage | string | () => string | rule message | Message on timeout |
## Bulk Endpoints with createMany
Use `createMany()` for bulk POST endpoints. It returns `{ instances, errors }`, cleanly separating valid from invalid items:
```typescript
// users.service.ts
@Injectable()
export class UsersService {
createMany(data: object[]): object {
const { instances, errors } = CreateUserDto.createMany(data);
// instances → coerced + validated DTOs ready to persist
// errors → [{ index, instance, errors: [...] }]
if (errors.length > 0) {
throw new BadRequestException({
message: `${errors.length} items failed validation`,
errors: errors.map((entry) => ({
index: entry.index,
errors: entry.errors,
})),
});
}
return {
created: instances.length,
items: instances.map((dto) => dto.$qSerialize()),
};
}
}Partial success
Pass { includeErrorInstances: true } to createMany() to include failed instances in instances[] alongside valid ones — useful for bulk-insert-with-partial-failure endpoints.
Integration with @nestjs/swagger
getSchema('openapi') generates an OpenAPI 3.x compatible schema from the @Quick() type map. Use it programmatically or alongside @ApiProperty:
import { ApiProperty } from '@nestjs/swagger';
import { QModel, Quick } from 'quickmodel';
@Quick(
{ title: 'string', price: 'number', available: 'boolean', createdAt: Date },
{ unknownPropertyPolicy: 'strip' }
)
export class ProductDto extends QModel<IProduct> {
@ApiProperty({ example: 'Laptop Pro' })
declare title: string;
@ApiProperty({ example: 999.99 })
declare price: number;
@ApiProperty({ example: true })
declare available: boolean;
@ApiProperty({ example: '2024-01-01T00:00:00.000Z' })
declare createdAt: Date;
}Or generate the full schema automatically:
const schema = ProductDto.getSchema('openapi');
// {
// "type": "object",
// "properties": {
// "title": { "type": "string" },
// "price": { "type": "number" },
// "available": { "type": "boolean" },
// "createdAt": { "type": "string", "format": "date-time" }
// }
// }Schema generation scope
getSchema() generates entries only for fields explicitly typed in @Quick() or @QType(). Fields declared with declare name: string without a corresponding @Quick({ name: 'string' }) entry are not included — add explicit type mappings for full schema coverage.
Available formats: 'openapi', 'json' (JSON Schema Draft-07), 'ajv', 'zod', 'mongo', 'graphql', 'typescript'.
Service Layer with Repository Pattern
// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { QModel, Quick, QComputed } from 'quickmodel';
@Quick({ createdAt: Date }, { unknownPropertyPolicy: 'strip' })
class UserModel extends QModel<IUser> {
declare id: string;
declare firstName: string;
declare lastName: string;
declare email: string;
declare role: 'admin' | 'user';
declare createdAt: Date;
@QComputed()
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
@QComputed()
get isAdmin(): boolean {
return this.role === 'admin';
}
}
@Injectable()
export class UsersService {
private readonly store = new Map<string, UserModel>();
create(data: object): object {
const user = new UserModel(data);
this.store.set(user.id, user); // id is declared as string on UserModel — no cast needed
return user.$qSerialize(); // { id, firstName, lastName, ..., fullName, isAdmin }
}
findById(id: string): object {
const user = this.store.get(id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return user.$qSerialize();
}
findAll(): object[] {
return [...this.store.values()].map((usr) => usr.$qSerialize());
}
}Computed Fields in API Responses (@QComputed)
Use @QComputed() to include derived fields in the API response without storing them in the database:
import { QModel, Quick, QComputed } from 'quickmodel';
@Quick(
{
firstName: 'string',
lastName: 'string',
birthYear: 'number',
score: 'number',
},
{ unknownPropertyPolicy: 'strip' }
)
export class UserResponseDto extends QModel<IUserResponse> {
declare firstName: string;
declare lastName: string;
declare birthYear: number;
declare score: number;
@QComputed()
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
@QComputed()
get age(): number {
return new Date().getFullYear() - this.birthYear;
}
@QComputed()
get scoreLabel(): 'excellent' | 'good' | 'average' | 'poor' {
if (this.score >= 90) return 'excellent';
if (this.score >= 70) return 'good';
if (this.score >= 50) return 'average';
return 'poor';
}
}
// user.$qSerialize() → { firstName, lastName, birthYear, score, fullName, age, scoreLabel }@QComputed vs plain getter
A plain get fullName() without @QComputed() exists on the prototype but is invisible to $qSerialize() and toJSON(). The decorator is the opt-in that tells the serializer to include the getter in the output.
Testing NestJS Services
Test your service logic without spawning a NestJS application — QuickModel DTOs work in any test runner:
// create-user.dto.test.ts
import { describe, test, expect } from 'bun:test'; // or jest/vitest
describe('CreateUserDto', () => {
test('coerces birthDate from ISO string', () => {
const dto = new CreateUserDto({
name: 'Alice',
email: 'alice@example.com',
age: 30,
birthDate: '1994-06-15T00:00:00.000Z',
active: true,
});
expect(dto.birthDate).toBeInstanceOf(Date);
});
test('$qCheckRules() validates all fields', () => {
const invalid = new CreateUserDto({
name: '',
email: 'bad',
age: -1,
birthDate: '1994-06-15',
active: true,
});
const { valid, errors } = invalid.$qCheckRules();
expect(valid).toBe(false);
expect(errors.length).toBeGreaterThan(0);
});
test('$qSerialize() produces JSON-safe plain object', () => {
const dto = new CreateUserDto({
name: 'Alice',
email: 'alice@example.com',
age: 30,
birthDate: '1994-06-15T00:00:00.000Z',
active: true,
});
expect(() => JSON.stringify(dto.$qSerialize())).not.toThrow();
});
});Pattern Summary
| Concern | NestJS (class-validator) | QuickModel |
|---|---|---|
| Type coercion | class-transformer + @Type() | Automatic via @Quick({ field: Date }) |
| Field validation | @IsEmail(), @IsNotEmpty(), … | @QRule(predicate, message) |
| Async validation | @ValidatorConstraint({ async: true }) | async predicate + checkRulesAsync(options?) |
| Nested models | @Type(() => NestedDto) | @Quick({ field: NestedModel }) |
| Serialization | instanceToPlain() / plainToInstance() | .$qSerialize() / .toJSON() |
| Schema export | Manual @ApiProperty() per field | .getSchema('openapi') |
| Computed API fields | Not built-in | @QComputed() getters |
| Bulk create | Manual loop | createMany(array) → { instances, errors } |
| Strip unknown props | @Exclude() + excludeExtraneousValues | @Quick({}, { unknownPropertyPolicy: 'strip' }) |
Next Steps
- Validation (@QRule) — full predicate API, async rules, i18n messages
- Computed Fields (@QComputed) — derived data in responses
- Serialization — serialize / toJSON / toPlain options
- Schema Generation — multi-format schema export (
openapi,json,ajv,zod, …) - Mock Generation — generate test fixtures automatically