Query Filters
Data-table filtering is powered by the query module.
Table filters are defined with descriptors from @tulip-systems/query.
A descriptor combines two concerns:
paramcontrols URL search-param parsing and serialization throughnuqsschemacontrols runtime validation and inferred filter value types
Calling .build(...) turns the descriptor into a table filter by mapping the resolved value to a portable condition tree.
Basic example
import {
arrayFilter,
booleanFilter,
defineFilters,
enumFilter,
stringFilter,
} from "@tulip-systems/query";
const projectStatusValues = ["planned", "active", "done"] as const;
export const projectFilters = defineFilters({
search: stringFilter.build((value) =>
value ? { field: "title", operator: "ilike", value: `%${value}%` } : null,
),
customerId: arrayFilter(stringFilter).build((value) =>
value?.length ? { field: "customerId", operator: "in", value } : null,
),
status: arrayFilter(enumFilter(projectStatusValues)).build((value) =>
value?.length ? { field: "status", operator: "in", value } : null,
),
isDeleted: booleanFilter.build((value) => ({
field: "deletedAt",
operator: value ? "isNotNull" : "isNull",
})),
});Return null or undefined when a filter is inactive.
Descriptor builders
Use the built-in descriptors for common filter values:
stringFilter.build((value) => ...)
booleanFilter.build((value) => ...)
dateRangeFilter.build((value) => ...)
enumFilter(["todo", "done"]).build((value) => ...)
arrayFilter(stringFilter).build((value) => ...)
arrayFilter(enumFilter(["todo", "done"])).build((value) => ...)Use arrayFilter(...) to compose array filters from scalar descriptors instead of using type-specific array builders.
Condition shape
Built filters return a ParsedFilter condition tree:
type ParsedFilter =
| { field: string; operator: string; value?: unknown }
| { and: readonly ParsedFilter[] }
| { or: readonly ParsedFilter[] }
| { not: ParsedFilter };field is a logical compiler key. It does not need to be a database column name. Server and local compilers decide how each logical field maps to concrete query expressions.
Compound filters
Return compound conditions when one UI filter maps to multiple query clauses:
search: stringFilter.build((value) =>
value
? {
or: [
{ field: "name", operator: "ilike", value: `%${value}%` },
{ field: "email", operator: "ilike", value: `%${value}%` },
],
}
: null,
),Custom options
Use .options(...) when a specific filter needs a stricter schema or different URL parser.
import z from "zod";
const titleFilter = stringFilter
.options({
schema: z.string().min(1).max(255),
})
.build((value) => ({
field: "title",
operator: "ilike",
value: `%${value}%`,
}));Use customFilter(parse, config) only when no built-in descriptor fits.
import { parseAsString } from "nuqs/server";
import z from "zod";
import { customFilter } from "@tulip-systems/query";
const slugFilter = customFilter(
(value) => (value ? { field: "slug", operator: "eq", value } : null),
{
param: parseAsString,
schema: z.string().regex(/^[a-z0-9-]+$/),
},
);Client defaults and constraints
useFilters(...) accepts defaults and constraints for URL-backed filter state. Use null to clear a default or constraint explicitly.
useFilters(projectFilters, {
defaults: {
search: null,
},
constraints: {
isDeleted: false,
},
});Local compatibility
Local load-subset helpers return the same condition tree. That means filters produced by defineFilters(...) and filters parsed from TanStack DB loadSubsetOptions can share module-specific compiler code.
import { parseLoadSubsetFilters } from "@tulip-systems/query/tanstack-db";
const filter = parseLoadSubsetFilters(ctx.meta?.loadSubsetOptions);