Skip to main content

Overview

Custom filters allow you to define complex filtering logic that goes beyond the built-in primitive filter types. Each adapter has its own custom filter signature tailored to its execution model.

Custom Filter Signatures

Custom filters are defined as functions in your filter definition. The function signature varies by adapter:
type InMemoryCustomFilter<Entity, Input> = (
  entity: Entity,
  input: Input
) => boolean;
Returns true if the entity passes the filter.

In-Memory Custom Filters

In-memory custom filters receive the entity being tested and the filter input value, returning a boolean.

Basic Example

import { inMemoryFilter } from '@filter-def/in-memory';

interface User {
  name: string;
  roles: string[];
  createdAt: Date;
}

const userFilter = inMemoryFilter<User>().def({
  // Primitive filters
  name: { kind: 'eq' },
  
  // Custom filter: check if user has a specific role
  hasRole: (user: User, role: string) => {
    return user.roles.includes(role);
  },
  
  // Custom filter: check if user was created within X days
  createdWithinDays: (user: User, days: number) => {
    const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
    return user.createdAt >= cutoff;
  },
});

// Usage
const admins = users.filter(userFilter({ hasRole: 'admin' }));
const recentUsers = users.filter(userFilter({ createdWithinDays: 30 }));

Complex Business Logic

Custom filters can implement arbitrarily complex logic:
interface BlogPost {
  title: string;
  content: string;
  tags: string[];
  viewCount: number;
  likeCount: number;
  commentCount: number;
  publishedAt: Date;
}

const postFilter = inMemoryFilter<BlogPost>().def({
  // Check if post has ALL of the provided tags
  hasAllTags: (post: BlogPost, tags: string[]) => {
    return tags.every(tag => post.tags.includes(tag));
  },
  
  // Calculate engagement rate
  minEngagementRate: (post: BlogPost, minRate: number) => {
    const rate = (post.likeCount + post.commentCount) / post.viewCount;
    return rate >= minRate;
  },
  
  // Complex popularity score
  minPopularityScore: (post: BlogPost, minScore: number) => {
    const score = post.viewCount * 1 
                + post.likeCount * 5 
                + post.commentCount * 10;
    return score >= minScore;
  },
  
  // Word count threshold
  minWordCount: (post: BlogPost, minWords: number) => {
    const wordCount = post.content.split(/\s+/).length;
    return wordCount >= minWords;
  },
  
  // Published in date range
  publishedBetween: (post: BlogPost, range: { start: Date; end: Date }) => {
    return post.publishedAt >= range.start && post.publishedAt <= range.end;
  },
});

// Combine multiple custom filters
const featured = posts.filter(postFilter({
  hasAllTags: ['tutorial', 'typescript'],
  minEngagementRate: 0.1,
  minPopularityScore: 5000,
  minWordCount: 500,
}));

Accessing External Data

Custom filters can access closures and external data:
const currentUser = getCurrentUser();
const blockedUserIds = await getBlockedUsers(currentUser.id);

const postFilter = inMemoryFilter<Post>().def({
  // Filter out posts from blocked users
  notBlocked: (post: Post, apply: boolean) => {
    if (!apply) return true;
    return !blockedUserIds.includes(post.authorId);
  },
  
  // Compare with another post
  betterThan: (post: Post, otherPostId: string) => {
    const otherPost = posts.find(p => p.id === otherPostId);
    if (!otherPost) return false;
    return post.viewCount > otherPost.viewCount;
  },
});

Drizzle Custom Filters

Drizzle custom filters return SQL expressions using Drizzle’s query builder.

Basic SQL Expressions

import { drizzleFilter } from '@filter-def/drizzle';
import { pgTable, text, integer } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

const usersTable = pgTable('users', {
  id: integer('id').primaryKey(),
  name: text('name').notNull(),
  age: integer('age').notNull(),
});

const userFilter = drizzleFilter(usersTable).def({
  // Primitive filter
  name: { kind: 'eq' },
  
  // Custom filter: age divisible by X
  ageDivisibleBy: (divisor: number) => 
    sql`${usersTable.age} % ${divisor} = 0`,
  
  // Custom filter: name length
  nameLongerThan: (length: number) => 
    sql`length(${usersTable.name}) > ${length}`,
});

const where = userFilter({ ageDivisibleBy: 5 });
await db.select().from(usersTable).where(where);

Complex SQL Logic

import { and, gte, lte, eq } from 'drizzle-orm';

const postsTable = pgTable('posts', {
  id: integer('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content').notNull(),
  viewCount: integer('view_count').notNull(),
  likeCount: integer('like_count').notNull(),
  commentCount: integer('comment_count').notNull(),
  publishedAt: timestamp('published_at').notNull(),
  isPublished: boolean('is_published').notNull(),
});

const postFilter = drizzleFilter(postsTable).def({
  // Published within X days
  publishedWithinDays: (days: number) =>
    sql`${postsTable.publishedAt} >= NOW() - INTERVAL '${sql.raw(String(days))} days'`,
  
  // Date range using Drizzle operators
  publishedBetween: (range: { start: Date; end: Date }) =>
    and(
      gte(postsTable.publishedAt, range.start),
      lte(postsTable.publishedAt, range.end)
    ),
  
  // Minimum engagement (likes + comments)
  minEngagement: (minTotal: number) =>
    sql`${postsTable.likeCount} + ${postsTable.commentCount} >= ${minTotal}`,
  
  // Popularity score calculation
  minPopularityScore: (minScore: number) =>
    sql`(
      ${postsTable.viewCount} * 1 + 
      ${postsTable.likeCount} * 5 + 
      ${postsTable.commentCount} * 10
    ) >= ${minScore}`,
  
  // Word count (PostgreSQL specific)
  minWordCount: (minWords: number) =>
    sql`array_length(regexp_split_to_array(${postsTable.content}, '\\s+'), 1) >= ${minWords}`,
});

const where = postFilter({
  publishedWithinDays: 30,
  minEngagement: 50,
  minPopularityScore: 3000,
});

await db.select().from(postsTable).where(where);

Conditional Filters

Return undefined to skip a filter conditionally:
const postFilter = drizzleFilter(postsTable).def({
  optionalStatus: (status: 'published' | 'draft' | 'all') =>
    status === 'all'
      ? undefined  // Skip filtering when "all" selected
      : eq(postsTable.isPublished, status === 'published'),
});

const allPosts = postFilter({ optionalStatus: 'all' });
// Returns: undefined (no WHERE clause)

const publishedOnly = postFilter({ optionalStatus: 'published' });
// Returns: is_published = true
Returning undefined from a custom filter tells Drizzle to skip that condition entirely, as if the filter wasn’t provided.

BigQuery Custom Filters

BigQuery custom filters return parameterized SQL with a params object.

Basic Parameterized SQL

import { bigqueryFilter } from '@filter-def/bigquery';

interface User {
  id: string;
  name: string;
  age: number;
}

const userFilter = bigqueryFilter<User>().def({
  // Primitive filter
  name: { kind: 'eq' },
  
  // Custom filter: age divisible by X
  ageDivisibleBy: (divisor: number) => ({
    sql: 'MOD(age, @divisor) = 0',
    params: { divisor },
  }),
  
  // Custom filter: name length
  nameLongerThan: (length: number) => ({
    sql: 'LENGTH(name) > @length',
    params: { length },
  }),
});

const { sql, params } = userFilter({ ageDivisibleBy: 5 });
const [rows] = await bigquery.query({
  query: `SELECT * FROM \`dataset.users\` WHERE ${sql}`,
  params,
});

Complex BigQuery Functions

interface BlogPost {
  title: string;
  content: string;
  tags: string[];  // ARRAY<STRING> in BigQuery
  viewCount: number;
  likeCount: number;
  commentCount: number;
  publishedAt: Date;
}

const postFilter = bigqueryFilter<BlogPost>().def({
  // Array contains element
  hasTag: (tag: string) => ({
    sql: '@tag IN UNNEST(tags)',
    params: { tag },
  }),
  
  // Array contains all elements
  hasAllTags: (tags: string[]) => ({
    sql: `(
      SELECT COUNT(DISTINCT tag) = @tagCount
      FROM UNNEST(@tags) AS tag
      WHERE tag IN UNNEST(tags)
    )`,
    params: { tags, tagCount: tags.length },
  }),
  
  // Date arithmetic
  publishedWithinDays: (days: number) => ({
    sql: 'publishedAt >= DATE_SUB(CURRENT_DATE(), INTERVAL @days DAY)',
    params: { days },
  }),
  
  // Engagement rate calculation
  minEngagementRate: (minRate: number) => ({
    sql: '(likeCount + commentCount) / viewCount >= @minRate',
    params: { minRate },
  }),
  
  // String array manipulation
  minWordCount: (minWords: number) => ({
    sql: 'ARRAY_LENGTH(SPLIT(content, " ")) >= @minWords',
    params: { minWords },
  }),
  
  // Complex calculation
  minPopularityScore: (minScore: number) => ({
    sql: '(viewCount * 1 + likeCount * 5 + commentCount * 10) >= @minScore',
    params: { minScore },
  }),
});

const { sql, params } = postFilter({
  hasTag: 'typescript',
  publishedWithinDays: 30,
  minEngagementRate: 0.1,
});

Multiple Parameters

const eventFilter = bigqueryFilter<Event>().def({
  inDateRange: (range: { start: string; end: string }) => ({
    sql: 'eventDate BETWEEN @startDate AND @endDate',
    params: {
      startDate: range.start,
      endDate: range.end,
    },
  }),
  
  inPriceRange: (range: { min: number; max: number }) => ({
    sql: 'price >= @minPrice AND price <= @maxPrice',
    params: {
      minPrice: range.min,
      maxPrice: range.max,
    },
  }),
});

Combining Custom and Primitive Filters

Custom filters work seamlessly alongside primitive filters:
const postFilter = inMemoryFilter<BlogPost>().def({
  // Primitive filters
  author: { kind: 'eq' },
  titleContains: { kind: 'contains', field: 'title' },
  
  // Custom filters
  hasTag: (post: BlogPost, tag: string) => post.tags.includes(tag),
  minEngagementRate: (post: BlogPost, minRate: number) => {
    const rate = (post.likeCount + post.commentCount) / post.viewCount;
    return rate >= minRate;
  },
});

// Mix primitive and custom filters
const results = posts.filter(postFilter({
  author: 'Alice',              // Primitive filter
  titleContains: 'TypeScript',  // Primitive filter
  hasTag: 'tutorial',           // Custom filter
  minEngagementRate: 0.1,       // Custom filter
}));

Type Inference

Custom filters maintain full type safety:
const postFilter = inMemoryFilter<BlogPost>().def({
  hasAllTags: (post: BlogPost, tags: string[]) => 
    tags.every(tag => post.tags.includes(tag)),
  
  publishedBetween: (post: BlogPost, range: { start: Date; end: Date }) => 
    post.publishedAt >= range.start && post.publishedAt <= range.end,
});

// TypeScript infers input types
type FilterInput = InMemoryFilterInput<typeof postFilter>;
// {
//   hasAllTags?: string[];
//   publishedBetween?: { start: Date; end: Date };
// }

// ✅ Valid
postFilter({ hasAllTags: ['typescript', 'tutorial'] });
postFilter({ publishedBetween: { start: new Date(), end: new Date() } });

// ❌ TypeScript errors
postFilter({ hasAllTags: 'invalid' });  // Error: must be string[]
postFilter({ publishedBetween: 'invalid' });  // Error: wrong type

Best Practices

Use Primitive Filters First

Only use custom filters when primitive filters can’t express the logic:
// ❌ Unnecessary custom filter
const bad = inMemoryFilter<User>().def({
  isActive: (user: User, active: boolean) => user.isActive === active,
});

// ✅ Use primitive filter
const good = inMemoryFilter<User>().def({
  isActive: { kind: 'eq' },
});

Keep Functions Pure

For in-memory filters, avoid side effects:
// ❌ Side effects in filter
let count = 0;
const bad = inMemoryFilter<User>().def({
  countUsers: (user: User) => {
    count++;  // Don't do this!
    return true;
  },
});

// ✅ Pure function
const good = inMemoryFilter<User>().def({
  isActive: (user: User, active: boolean) => user.isActive === active,
});

Parameterize SQL Properly

For BigQuery, always use parameterized queries:
// ❌ SQL injection risk
const bad = bigqueryFilter<User>().def({
  nameIs: (name: string) => ({
    sql: `name = '${name}'`,  // Dangerous!
    params: {},
  }),
});

// ✅ Parameterized query
const good = bigqueryFilter<User>().def({
  nameIs: (name: string) => ({
    sql: 'name = @name',
    params: { name },
  }),
});

Limitations

Custom filters cannot be used inside boolean filters (AND/OR). Boolean filters only accept primitive filter conditions.
// ❌ Not supported
const invalid = inMemoryFilter<User>().def({
  complex: {
    kind: 'or',
    conditions: [
      (user: User, value: string) => user.name === value,  // Error
    ],
  },
});

Next Steps

Build docs developers (and LLMs) love