nestjs-filter
Guides

Typed Client (nestjs-codegen)

Generate a type-safe filterQuery() builder for your filter endpoints with the @dudousxd/nestjs-filter-codegen extension for nestjs-codegen.

@dudousxd/nestjs-codegen reads your NestJS controllers and emits a fully typed api.ts client. The @dudousxd/nestjs-filter-codegen extension plugs into that pipeline and teaches the generated client about your filter endpoints: every route decorated with @ApplyFilter gains a typed filterQuery() helper that builds the structured where/sort/paginate input from your front-end, with field names and operators checked at compile time.

The filterQuery() builder is backed by filterQueryTyped() from @dudousxd/nestjs-filter-client, so this is a type-only integration — no extra runtime beyond the client builder you'd reach for anyway.


Step 1 -- Install

The extension is a dev dependency of your codegen setup. It has two peers — the codegen itself, and the filter client runtime:

pnpm add -D @dudousxd/nestjs-filter-codegen @dudousxd/nestjs-codegen
pnpm add @dudousxd/nestjs-filter-client
Peer dependencyRange
@dudousxd/nestjs-codegen>=0.1.0
@dudousxd/nestjs-filter-client>=1.0.0

Step 2 -- Register the extension

Add nestjsFilterCodegen() to the extensions array of NestjsCodegenModule.forRoot():

// src/app.module.ts
import { Module } from '@nestjs/common';
import { NestjsCodegenModule } from '@dudousxd/nestjs-codegen/nest';
import { nestjsFilterCodegen } from '@dudousxd/nestjs-filter-codegen';

@Module({
  imports: [
    NestjsCodegenModule.forRoot({
      contracts: { glob: 'src/**/*.controller.ts' },
      codegen: { outDir: 'src/generated' },
      extensions: [nestjsFilterCodegen()],
    }),
    // ... your FilterModule.forRoot() and ORM modules ...
  ],
})
export class AppModule {}

On the next codegen run, nestjsFilterCodegen() inspects the discovered routes and emits filterQuery() only on the leaves whose controller method applies a filter.

The extension adds its client import to api.ts only when at least one route is actually filtered, so projects that haven't decorated any endpoint with @ApplyFilter pay nothing.


Capping relation depth with maxDepth

For autoFields: true entities with deep or wide relation graphs, the generated filterFields union can grow very large — every relation hop the codegen can reach becomes another dotted field name in the union. maxDepth bounds how many relation hops are included:

nestjsFilterCodegen({ maxDepth: 1 })

Depth counts relation hops in a dot-path field name:

  • 0 — only the entity's own columns (no relation paths), e.g. "name".
  • 1 — own columns + direct relation fields, e.g. "base.name".
  • 2 — one relation hop deeper, e.g. "base.owner.name".

The default is uncapped — relation-path expansion is bounded only by nestjs-codegen's own cycle guard (it never revisits the same entity name), not by hop count.

Override the global maxDepth for a single filter with @Filterable({ codegen: { maxDepth } }) — a per-filter override always takes precedence over the global option:

@Filterable({ entity: User, codegen: { maxDepth: 2 } })
export class UserFilter extends MikroOrmFilter<User> {}

codegen on @Filterable is metadata only — read statically (via ts-morph) by this extension. The core runtime ignores it entirely, so it has no effect outside of a codegen run. See Filter Classes for the rest of the options this decorator accepts.


Step 3 -- Use filterQuery() on the generated client

Given a filtered endpoint:

// src/users.controller.ts
@Controller('users')
export class UsersController {
  @Post('search')
  search(@ApplyFilter(UserFilter) qb: QueryBuilder<User>) {
    return qb.getResultList();
  }
}

…the generated client exposes filterQuery() on that leaf. It returns a typed builder whose field names are restricted to this filter's fields, with operators and values checked against each field's type:

import { api } from './generated/api';

const query = api.users.search
  .filterQuery()
  .contains('name', 'Al')      // string field → string operators
  .gte('age', 18)              // numeric field → ordering operators
  .equals('status', 'active')  // enum field → only the known members
  .sortDesc('age')
  .page(0, 25)
  .build();

// Distinct values of a column — for a filter dropdown. `distinct()` is
// field-checked too, so only this endpoint's fields are accepted:
const statuses = api.users.search
  .filterQuery()
  .distinct('status')
  .sortAsc('status')
  .build();

// ❌ Compile error — 'invalid' is not one of this endpoint's filter fields:
// api.users.search.filterQuery().where('invalid', 'foo');

const result = await api.users.search(query);

When the codegen can resolve a field's concrete type — enum members, Date, nullability, or a named DTO type — it threads that into the builder, so e.g. .equals('status', …) accepts only the valid enum values and ordering helpers (gt/gte/between) are gated to orderable fields.

The builder is the standard TypedFilterQueryBuilder from @dudousxd/nestjs-filter-client. See Operators for the full method surface — where / equals / contains / in / between / gt·gte·lt·lte / isNull / sort / search / include / page / or / and — terminating in build(), toQueryString(), or toFlatObject().

Pick the terminator that matches your transport: build() for the structured body that @ApplyFilter reads on POST/PUT, or toQueryString() for a GET endpoint that filters from the query string. See Controller Integration for how the source is resolved per HTTP method.


Table controllers built by a factory

Tables are often produced by a factory rather than written out per entity:

// Every route lives on the class the factory returns…
@Controller('users/search')
export class SearchUsersController extends createTableController(User, { dto: UserDto }) {}

// …and an override names the generated filter through the factory's static.
const PostsTable = createTableController(Post, { dto: PostDto });

@Controller('posts/search')
export class SearchPostsController extends PostsTable {
  @Post()
  override async search(
    @ApplyFilter(PostsTable.filter, { source: 'body' }) qb: QueryBuilder<Post>,
  ) { /* … */ }
}

Both shapes are fully supported: inherited routes and overridden ones get the same filterQuery() builder, the same computed aliases, and the same to-many aggregate paths (<rel>.$count, <rel>.$sum.<col>, …) as a hand-written controller filtering the same entity.

Requires @dudousxd/nestjs-codegen >= 0.17.1, which records which factory produced a route's base class and which entity it was called with — the generated filter's @Filterable({ entity }) names the factory's own parameter, so the entity is knowable only from the call site.


How it fits

nestjs-codegen exposes a CodegenExtension contract at @dudousxd/nestjs-codegen/extension. This package implements two of its api.ts hooks: apiHeader (adds the filterQueryTyped import) and apiMembers (adds the filterQuery member to each handle leaf that carries filter fields). Because member contributions land on handle leaves, filterQuery() sits alongside whatever client layer is active — the plain fetcher or, for example, TanStack Query. The codegen owns route discovery and the field-type model; the extension maps that onto a typed call.

On this page