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 Configuration).
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: { baseId: 'b1' }, distinct: 'status', sort: 'status' }
// → SELECT DISTINCT status FROM ... WHERE base_id = 'b1' 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. The endpoint returns the
raw projected rows, so 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(WorkOrderFilter) qb: QueryBuilder<WorkOrder>) {
return qb.execute(); // [{ status: 'OPEN' }, { status: 'CLOSED' }]
}Dot-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();
}
}Next steps
- Installation -- per-ORM install commands, peer dependencies, and TypeScript config
- Filter Classes -- validated mode, lean mode, setup(), and advanced features
- Controllers --
@ApplyFilteroptions, dynamic filter selection, and error handling - Services --
FilterRunner.apply()and custom context - Testing --
FilterTestingModule, mock query builders, and integration tests - Configuration -- all
FilterModule.forRoot()options