Aviary
Integrations

nestjs-filter

Typed query filters from @FilterFor / @ApplyFilter.

If you use nestjs-filter, register the filter extension. It discovers @ApplyFilter/@FilterFor on your controllers and adds a typed filterQuery() helper to every matching api.ts leaf, backed by @dudousxd/nestjs-filter-client.

pnpm add -D @dudousxd/nestjs-filter-codegen
src/app.module.ts
import { nestjsFilterCodegen } from '@dudousxd/nestjs-filter-codegen';

NestjsCodegenModule.forRoot({
  contracts: { glob: 'src/**/*.controller.ts' },
  codegen: { outDir: 'src/generated' },
  extensions: [nestjsFilterCodegen()],
});
@Controller('tasks')
export class TasksController {
  @Get()
  @ApplyFilter(TaskFilter)
  list(@FilterFor(TaskFilter) query: FilterQuery) { /* … */ }
}

The extension types filterQuery() over the route's filterable fields (with their resolved types — enums, dates, relations), so building a filter on the client is fully typed. The leaf exposes it directly:

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

const query = api.tasks.list().filterQuery()
  .where('status', 'eq', 'open')
  .build();

useQuery(api.tasks.list({ query }).queryOptions());

It's the same builder as filterQueryTyped() from @dudousxd/nestjs-filter-client, but pre-typed to the route's fields so you don't repeat the field union by hand.

Field types come from the filter class / @FilterFor parameter — named enums and type aliases are referenced by name in the generated types.

Validating a field name at runtime

filterQuery().where() is typed to the route's field union, so a field arriving as a plain string from runtime state (a saved view, a user-picked column, a search box) can't be passed without a cast. To close that gap, every filter leaf also carries its field list as a runtime as const array, and api.ts exports an isFilterField type guard:

const leaf = api.tasks.list();

for (const filter of userColumnFilters) {
  // Narrow the dynamic string to the leaf's field union — no cast.
  if (isFilterField(leaf.filterFields, filter.field)) {
    leaf.filterQuery().where(filter.field, 'eq', filter.value);
  }
}

leaf.filterFields is readonly ['status', 'name', ...] as const, generated from the same discovered field list as the type-level union, so the runtime value can never drift from the type. isFilterField (import { isFilterField } from '.../api') narrows legitimately instead of asserting with as, so a mistyped or server-renamed field is caught at the guard rather than silently accepted.

On this page