nestjs-filter

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 Module options).

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: { companyId: 'c1' }, distinct: 'status', sort: 'status' }
// → SELECT DISTINCT status FROM ... WHERE company_id = 'c1' 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. 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: 2

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();
  }
}

Module options

FilterModule.forRoot() accepts these options:

OptionTypeDefaultDescription
inputNormalizerInputNormalizernoneNormalizes 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.
dropIdbooleanfalseWhether 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.
stripEmptybooleantrueStrips null, undefined, and empty-string values from input before dispatch.
maxIncludeDepthnumber3Maximum depth for nested include paths (e.g. 'posts.comments.author').
maxPageSizenumber100Maximum page size for offset pagination.
throwOnInvalidbooleanfalseWhen true, invalid sorts, distinct fields, and unknown where columns raise a BadRequestException instead of being silently dropped. Overridable per-@Filterable.
defaultSortstring | SortItem[]noneSort 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 -- @ApplyFilter options, 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

On this page