Getting Started
Add declarative filtering to a NestJS project in minutes — install, define a filter, register the module, and go.
The fastest way to get nestjs-filter running is four steps: install the packages, define a filter class, register the module, and use the filter in a controller or service. Most projects are done in under five minutes.
Prerequisites
- Node.js 20+
- NestJS 10+ (both v10 and v11 are supported)
- One of: MikroORM 7 or TypeORM 0.3+
- TypeScript 5+ with
experimentalDecoratorsandemitDecoratorMetadataenabled
Step 1 -- Install
pnpm add @dudousxd/nestjs-filter @dudousxd/nestjs-filter-mikro-ormpnpm add @dudousxd/nestjs-filter @dudousxd/nestjs-filter-typeormFor validation support (optional but recommended), also install:
pnpm add class-validator class-transformerStep 2 -- Define a filter
A filter is a regular NestJS injectable class that extends MikroOrmFilter or TypeOrmFilter. Each method decorated with @FilterFor() handles one input key.
// src/user.filter.ts
import { Injectable } from '@nestjs/common';
import { Filterable, FilterFor, escapeLike } from '@dudousxd/nestjs-filter';
import { MikroOrmFilter } from '@dudousxd/nestjs-filter-mikro-orm';
import { IsOptional, IsString, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
import { User } from './user.entity.js';
@Injectable()
@Filterable({ entity: User })
export class UserFilter extends MikroOrmFilter<User> {
@IsOptional() @IsString()
name?: string;
@IsOptional() @IsNumber()
@Type(() => Number)
minAge?: number;
@FilterFor('name')
applyName(value: string) {
this.$query.andWhere({ name: { $like: `%${escapeLike(value)}%` } });
}
@FilterFor('minAge')
applyMinAge(value: number) {
this.$query.andWhere({ age: { $gte: value } });
}
}// src/user.filter.ts
import { Injectable } from '@nestjs/common';
import { Filterable, FilterFor, escapeLike } from '@dudousxd/nestjs-filter';
import { TypeOrmFilter } from '@dudousxd/nestjs-filter-typeorm';
import { IsOptional, IsString, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
import { User } from './user.entity.js';
@Injectable()
@Filterable({ entity: User })
export class UserFilter extends TypeOrmFilter<User> {
@IsOptional() @IsString()
name?: string;
@IsOptional() @IsNumber()
@Type(() => Number)
minAge?: number;
@FilterFor('name')
applyName(v: string) {
this.$query.andWhere('user.name LIKE :name', {
name: `%${escapeLike(v)}%`,
});
}
@FilterFor('minAge')
applyMinAge(v: number) {
this.$query.andWhere('user.age >= :minAge', { minAge: v });
}
}The class-validator decorators (@IsOptional, @IsString, etc.) are optional. If you omit them, the filter works in lean mode -- no validation, just dispatch. See the Filter Classes guide for details.
Zero-config auto-fields (enabled by default)
Auto-fields are enabled by default. If your filters are simple equality/contains checks, you can skip @FilterFor entirely:
@Injectable()
@Filterable({ entity: User })
export class UserFilter extends MikroOrmFilter<User> {}A request to GET /users?name=Al&status=active will generate WHERE name = 'Al' AND status = 'active' -- no methods needed. You can also use bracket notation for operators: ?name[contains]=Al&age[gte]=18. See Auto-fields and Operators for details.
Step 3 -- Register the module
// src/app.module.ts
import { Module } from '@nestjs/common';
import { FilterModule } from '@dudousxd/nestjs-filter';
import { MikroOrmFilterModule } from '@dudousxd/nestjs-filter-mikro-orm';
import { UserFilter } from './user.filter.js';
import { UsersController } from './users.controller.js';
@Module({
imports: [
// ... your MikroOrmModule.forRoot() config ...
FilterModule.forRoot({ inputNormalizer: 'camelCase' }),
MikroOrmFilterModule.forRoot(),
FilterModule.forFeature([UserFilter]),
],
controllers: [UsersController],
})
export class AppModule {}// src/app.module.ts
import { Module } from '@nestjs/common';
import { FilterModule } from '@dudousxd/nestjs-filter';
import { TypeOrmFilterModule } from '@dudousxd/nestjs-filter-typeorm';
import { UserFilter } from './user.filter.js';
import { UsersController } from './users.controller.js';
@Module({
imports: [
// ... your TypeOrmModule.forRoot() config ...
FilterModule.forRoot({ inputNormalizer: 'camelCase' }),
TypeOrmFilterModule.forRoot(),
FilterModule.forFeature([UserFilter]),
],
controllers: [UsersController],
})
export class AppModule {}Step 4 -- Use in a controller
// src/users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { ApplyFilter } from '@dudousxd/nestjs-filter';
import { UserFilter } from './user.filter.js';
@Controller('users')
export class UsersController {
@Get()
list(@ApplyFilter(UserFilter) qb: QueryBuilder<User>) {
return qb.getResultList(); // MikroORM
// return qb.getMany(); // TypeORM
}
}That's it. A request to GET /users?name=Al&minAge=18 will:
- Extract
{ name: 'Al', minAge: '18' }from the query string - Normalize keys to camelCase
- Validate with class-validator (if installed)
- Dispatch
nametoapplyName()andminAgetoapplyMinAge() - Return the mutated QueryBuilder ready for execution
Structured input
Beyond flat query strings, nestjs-filter accepts a structured input object that combines filtering, sorting, pagination, search, and eager loading in a single request:
// POST /users/search — body:
{
filter: { name: 'Al', status: 'active' },
sort: ['-createdAt', 'name'], // prefix with - for descending
paginate: { page: 0, size: 25 },
search: 'fleet', // global ILIKE across searchable fields
include: ['role', 'posts'], // eager-load relations
distinct: 'status', // SELECT DISTINCT status (for filter dropdowns)
}Sort
The sort field accepts an array of field names. Prefix a field with - for descending order:
{ sort: ['-createdAt', 'name'] }
// ORDER BY createdAt DESC, name ASCA sort entry can also target a sub-key of a JSON column using dot-notation (MikroORM adapter only):
{ sort: ['-metadata.amount', 'metadata.tier'] }
// ORDER BY metadata->>'amount' DESC, metadata->>'tier' ASCSee JSON sub-path filtering for the full list of supported operators and the PostgreSQL numeric-sort caveat.
Pagination
The paginate field supports offset-based pagination:
{ paginate: { page: 0, size: 25 } }Configure maxPageSize in FilterModule.forRoot() to cap the page size (see Module options).
Include
The include field specifies relations to eager-load:
{ include: ['role', 'posts'] }Search
The search field runs a global ILIKE query across all searchable fields:
{ search: 'fleet' }Distinct
The distinct field selects DISTINCT values of one or more columns while the
active filter, search, sort and paginate still apply. It's built for
populating a filter dropdown with the distinct values of a column:
// "Which statuses exist among the rows matching the current filters?"
{ filter: { companyId: 'c1' }, distinct: 'status', sort: 'status' }
// → SELECT DISTINCT status FROM ... WHERE company_id = 'c1' ORDER BY statusAccepts a single field, a comma-separated string, or an array (distinct: ['status', 'type']
selects distinct combinations). Fields are validated against the entity's
columns — unknown fields are dropped, same as sort. Distinct projections have
no entity identifier to hydrate around, so they're executed as plain rows
rather than entities.
With @ApplyFilter, execute the builder with the raw API for your ORM
(qb.execute() in MikroORM, qb.getRawMany() in TypeORM) rather than the
entity-hydrating one:
// controller
@Post('search')
listStatuses(@ApplyFilter(UserFilter) qb: QueryBuilder<User>) {
return qb.execute(); // [{ status: 'active' }, { status: 'inactive' }]
}runner.findAndCount() (see the "findAndCount" section below) handles this automatically: whenever the structured input carries distinct fields, it routes to the adapter's raw-row execution path instead of the entity-hydrating one, skips the to-many relation-loading phase (there's no entity identity to attach relations to), and returns { rows, total } with total counting distinct tuples rather than entities:
const { rows, total } = await runner.findAndCount(User, {
filter: { companyId: 'c1' },
distinct: 'status',
sort: 'status',
});
// rows: [{ status: 'active' }, { status: 'inactive' }], total: 2Dot-notation auto-joins
Use dot-notation to filter on related entities. The library automatically joins the required relations:
// Filter by a relation field — auto-joins the 'posts' relation
{ filter: { 'posts.title': 'Hello' } }Dynamic filtering with applyDynamic
Query any entity without defining a filter class using FilterRunner.applyDynamic():
import { Injectable } from '@nestjs/common';
import { FilterRunner } from '@dudousxd/nestjs-filter';
import type { SqlEntityManager } from '@mikro-orm/sql';
import { User } from './user.entity.js';
@Injectable()
export class UsersService {
constructor(
private readonly runner: FilterRunner,
private readonly em: SqlEntityManager,
) {}
async search(input: Record<string, unknown>) {
const qb = this.em.createQueryBuilder(User);
await this.runner.applyDynamic(User, input, qb);
return qb.getResultList();
}
}Entity metadata introspection ensures unknown fields are rejected at runtime.
findAndCount — execute with pagination-safe relation loading
applyDynamic builds the query; you execute it. runner.findAndCount does
both, and loads relations correctly under pagination: to-one relations stay
on the join, to-many relations are loaded in a separate query after the
page is fetched (a leftJoinAndSelect + limit would multiply parent rows and
corrupt the page + count).
const { rows, total } = await runner.findAndCount(User, {
filter: { status: 'active' },
include: ['base', 'posts'], // base joined; posts loaded separately
sort: ['-createdAt'],
paginate: { page: 0, size: 20 },
});describe — metadata-driven field discovery
runner.describe(entity) returns an entity's scalar fields and its one-hop
relations (each with their own fields), read from the ORM's metadata — no
hand-maintained field map. It's memoized per entity class. Use it to build a
dynamic column picker / filter UI, or the meta payload of a generic,
table-name-driven endpoint:
const { fields, relations } = runner.describe(User);
// fields: { id: { type: 'number', column: 'id' }, name: { type: 'string', column: 'name' }, ... }
// relations: { base: { kind: 'many-to-one', target: 'Base', fields: { id, label } }, ... }Step 5 -- Use via FilterRunner (optional)
For programmatic use in services (outside controllers), inject FilterRunner:
// src/users.service.ts
import { Injectable } from '@nestjs/common';
import { FilterRunner } from '@dudousxd/nestjs-filter';
import type { SqlEntityManager } from '@mikro-orm/sql';
import { User } from './user.entity.js';
import { UserFilter } from './user.filter.js';
@Injectable()
export class UsersService {
constructor(
private readonly runner: FilterRunner,
private readonly em: SqlEntityManager,
) {}
async search(input: Record<string, unknown>) {
const qb = this.em.createQueryBuilder(User);
await this.runner.apply(UserFilter, input, qb);
return qb.getResultList();
}
}Module options
FilterModule.forRoot() accepts these options:
| Option | Type | Default | Description |
|---|---|---|---|
inputNormalizer | InputNormalizer | none | Normalizes input keys, e.g. 'camelCase' or 'snakeCase'. |
inputFormat | 'native' | 'spatie' | 'native' | The input query format. Set to 'spatie' to accept spatie-laravel-query-builder / JSON:API style query strings. |
dropId | boolean | false | Whether to drop the entity's id field from auto-fields/describe() output. |
onUnknownKey | 'ignore' | 'warn' | 'throw' | 'ignore' | Policy for input keys that don't match any @FilterFor method or auto-field. |
validation | 'auto' | 'off' | 'auto' | Validation mode. See Input Validation. |
stripEmpty | boolean | true | Strips null, undefined, and empty-string values from input before dispatch. |
maxIncludeDepth | number | 3 | Maximum depth for nested include paths (e.g. 'posts.comments.author'). |
maxPageSize | number | 100 | Maximum page size for offset pagination. |
throwOnInvalid | boolean | false | When true, invalid sorts, distinct fields, and unknown where columns raise a BadRequestException instead of being silently dropped. Overridable per-@Filterable. |
defaultSort | string | SortItem[] | none | Sort applied when the client sends no sort. Overridable per-@Filterable. |
FilterModule.forRoot({
inputNormalizer: 'camelCase',
maxPageSize: 50,
throwOnInvalid: true,
})Next steps
- Filter Classes -- validated mode, lean mode, setup(), and advanced features
- Controllers --
@ApplyFilteroptions, dynamic filter selection, and error handling - Using Filters in Services --
FilterRunner.apply()and custom context - Relation Filtering -- cross-entity filtering with
@Relations - Generic Operators --
ColumnFilter, all 22 operators, and the client-side filter builder - Typed Client -- generate a type-safe
filterQuery()builder with@dudousxd/nestjs-filter-codegen - Testing --
FilterTestingModule, mock query builders, and integration tests