Aviary

Filter

Declarative, ORM-agnostic filter classes for NestJS — turn query strings into safe, validated database queries.

@dudousxd/nestjs-filter turns messy ?name=Al&minAge=18 query strings into safe, validated database queries. You declare a filter class — one method per input key — and the library does the dispatch: it reads the request, normalizes and validates the input, and calls only the methods that match. Each method receives the parsed value and mutates your ORM's native query builder directly, so you keep full control over the SQL while the boilerplate disappears.

The idiom is borrowed from eloquent-filter and adonis-lucid-filter, redesigned for NestJS: filters are first-class injectables, so you get full dependency injection inside them, and your filter class can double as the validation DTO.

The problem it solves

List endpoints accumulate filtering logic fast. Without a structure, every controller grows a tangle of if (query.name) { ... } branches, ad-hoc validation, and string concatenation that invites injection bugs. nestjs-filter replaces that with a declarative contract:

  • One method per input key. A @FilterFor('name') method owns the name parameter. No central switch, no order-dependent if chains.
  • The filter is the DTO. Add class-validator decorators to the same class and unrecognized or malformed input is rejected before it ever reaches the database.
  • ORM-agnostic core. The core knows nothing about your database. You add a thin adapter — MikroORM or TypeORM — and write against its native query builder, not a lossy abstraction.
  • No cross-request leakage. Filters are singletons backed by AsyncLocalStorage, so per-request state never bleeds between concurrent requests.

Quickstart

The whole loop — install, define a filter, register the module, use it on a route — in four steps. For the full walkthrough (including TypeORM, structured input, and FilterRunner), see Getting Started.

Install the core package and an ORM adapter:

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

For validation support (optional but recommended), also add class-validator and class-transformer.

Define a filter — each @FilterFor method handles one input key:

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

Register the module — wire the core, the adapter, and your filters:

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: [
    FilterModule.forRoot({ inputNormalizer: 'camelCase' }),
    MikroOrmFilterModule.forRoot(),
    FilterModule.forFeature([UserFilter]),
  ],
  controllers: [UsersController],
})
export class AppModule {}

Apply it to a route with @ApplyFilter — done:

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

A request to GET /users?name=Al&minAge=18 extracts the input, normalizes the keys, validates with class-validator (if installed), dispatches name and minAge to their methods, and hands you a ready-to-execute query builder.

Zero-config auto-fields

For simple equality and contains checks you can skip @FilterFor entirely — an empty @Filterable class will match input keys against the entity's own columns automatically. Bracket notation (?age[gte]=18) unlocks the full operator set. See Filter Classes and Generic Operators.

More than a WHERE clause

Flat query strings are just the entry point. The same machinery accepts a structured input object that combines filtering, sorting, pagination, global search, eager-loading, and DISTINCT projection in a single request:

{
  filter: { status: 'active' },
  sort: ['-createdAt', 'name'],   // - prefix = descending
  paginate: { page: 0, size: 25 },
  search: 'fleet',                // global ILIKE across searchable fields
  include: ['role', 'posts'],     // eager-load relations
  distinct: 'status',             // SELECT DISTINCT — for filter dropdowns
}

For generic, table-driven endpoints, applyDynamic builds a query for any entity with no filter class at all, runner.findAndCount executes it with pagination-safe relation loading, and runner.describe(entity) returns the entity's fields and relations from ORM metadata to drive dynamic UIs. See Getting Started for the details.

Where to go next

On this page