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-codegenimport { 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.