Aviary

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 experimentalDecorators and emitDecoratorMetadata enabled

Step 1 -- Install

pnpm add @dudousxd/nestjs-filter @dudousxd/nestjs-filter-mikro-orm
pnpm add @dudousxd/nestjs-filter @dudousxd/nestjs-filter-typeorm

For validation support (optional but recommended), also install:

pnpm add class-validator class-transformer

Step 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:

  1. Extract { name: 'Al', minAge: '18' } from the query string
  2. Normalize keys to camelCase
  3. Validate with class-validator (if installed)
  4. Dispatch name to applyName() and minAge to applyMinAge()
  5. 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 ASC

A 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' ASC

See 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'] }

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 status

Accepts 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 -- @ApplyFilter options, 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

On this page