Tulip Logo IconTulip
Data Tables

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:

  • param controls URL search-param parsing and serialization through nuqs
  • schema controls 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);

On this page