.md

Build a product catalog with filtering

In this tutorial, you will build a product catalog with faceted navigation. This is a common pattern for e-commerce sites where users filter products by category, price, brand, and other attributes.

Time required: 25 minutes

What you will build: A product catalog that supports filtering by category, price range, brand and tags, with dynamic facet counts that update as users apply filters, and a small class that manages the user's filter selections.

What is faceted navigation?

Faceted navigation lets users narrow results by several attributes at once, with a live count next to each option; dcupl computes those counts with catalog.fn.facets(), which you will use throughout this tutorial. The pattern itself is in Faceted search.

Prerequisites

Step 1: set up the project

Create a new project directory and install dcupl (for yarn and pnpm, see the Quick start):

terminal
mkdir product-catalog
cd product-catalog
npm init -y
npm install @dcupl/core

Create the main application file:

catalog.ts
import { Dcupl } from '@dcupl/core';

async function main() {
  const dcupl = new Dcupl();

  // We will build the catalog here
}

main();

Step 2: define the product model

A product catalog needs a model that captures all filterable attributes. Define the model, and a Product type for the query results:

catalog.ts
import { Dcupl } from '@dcupl/core';

type Product = {
  key: string;
  name: string;
  description: string;
  category: string;
  brand: string;
  price: number;
  rating: number;
  inStock: boolean;
  tags: string[];
};

async function main() {
  const dcupl = new Dcupl();

  // Define the Product model with filterable properties
  dcupl.models.set({
    key: 'Product',
    properties: [
      { key: 'name', type: 'string' },
      { key: 'description', type: 'string' },
      { key: 'category', type: 'string' },
      { key: 'brand', type: 'string' },
      { key: 'price', type: 'float' },
      { key: 'rating', type: 'float' },
      { key: 'inStock', type: 'boolean' },
      { key: 'tags', type: 'Array<string>' },
    ],
  });
}

main();

Notice the tags property uses Array<string> type. This allows products to have multiple tags like "new", "sale", "featured".

The Product type is for TypeScript: Step 4 passes it to dcupl.lists.create<Product>(), so the items you read from the list are typed.

Step 3: load product data

Add a realistic product dataset after dcupl.models.set(), inside main(). In production, this data would come from an API or a file:

catalog.ts: add inside main()
// Load product data
dcupl.data.set(
  [
    // Electronics
    {
      key: 'p1',
      name: 'Laptop Pro 15',
      description: 'High-performance laptop',
      category: 'Electronics',
      brand: 'TechCorp',
      price: 1299.99,
      rating: 4.8,
      inStock: true,
      tags: ['featured', 'new'],
    },
    {
      key: 'p2',
      name: 'Wireless Mouse',
      description: 'Ergonomic wireless mouse',
      category: 'Electronics',
      brand: 'TechCorp',
      price: 49.99,
      rating: 4.5,
      inStock: true,
      tags: ['bestseller'],
    },
    {
      key: 'p3',
      name: 'USB-C Hub',
      description: '7-in-1 USB-C hub',
      category: 'Electronics',
      brand: 'ConnectPro',
      price: 79.99,
      rating: 4.2,
      inStock: true,
      tags: [],
    },
    {
      key: 'p4',
      name: 'Mechanical Keyboard',
      description: 'RGB mechanical keyboard',
      category: 'Electronics',
      brand: 'TypeMaster',
      price: 149.99,
      rating: 4.7,
      inStock: false,
      tags: ['popular'],
    },
    {
      key: 'p5',
      name: 'Monitor 27"',
      description: '4K IPS monitor',
      category: 'Electronics',
      brand: 'ViewTech',
      price: 449.99,
      rating: 4.6,
      inStock: true,
      tags: ['featured'],
    },
    // Clothing
    {
      key: 'p6',
      name: 'Cotton T-Shirt',
      description: '100% organic cotton',
      category: 'Clothing',
      brand: 'EcoWear',
      price: 29.99,
      rating: 4.4,
      inStock: true,
      tags: ['sale', 'eco-friendly'],
    },
    {
      key: 'p7',
      name: 'Denim Jeans',
      description: 'Classic fit jeans',
      category: 'Clothing',
      brand: 'DenimCo',
      price: 79.99,
      rating: 4.3,
      inStock: true,
      tags: ['bestseller'],
    },
    {
      key: 'p8',
      name: 'Running Shoes',
      description: 'Lightweight running shoes',
      category: 'Clothing',
      brand: 'SportyFeet',
      price: 129.99,
      rating: 4.8,
      inStock: true,
      tags: ['new', 'popular'],
    },
    {
      key: 'p9',
      name: 'Winter Jacket',
      description: 'Waterproof winter jacket',
      category: 'Clothing',
      brand: 'OutdoorGear',
      price: 199.99,
      rating: 4.5,
      inStock: false,
      tags: ['seasonal'],
    },
    {
      key: 'p10',
      name: 'Wool Sweater',
      description: 'Merino wool sweater',
      category: 'Clothing',
      brand: 'EcoWear',
      price: 89.99,
      rating: 4.6,
      inStock: true,
      tags: ['eco-friendly'],
    },
    // Home & Garden
    {
      key: 'p11',
      name: 'Coffee Maker',
      description: 'Programmable coffee maker',
      category: 'Home & Garden',
      brand: 'BrewMaster',
      price: 99.99,
      rating: 4.4,
      inStock: true,
      tags: ['bestseller'],
    },
    {
      key: 'p12',
      name: 'Plant Pot Set',
      description: 'Ceramic pot set of 3',
      category: 'Home & Garden',
      brand: 'GreenThumb',
      price: 34.99,
      rating: 4.1,
      inStock: true,
      tags: ['sale'],
    },
    {
      key: 'p13',
      name: 'LED Desk Lamp',
      description: 'Adjustable LED lamp',
      category: 'Home & Garden',
      brand: 'LightWorks',
      price: 59.99,
      rating: 4.7,
      inStock: true,
      tags: ['featured'],
    },
    {
      key: 'p14',
      name: 'Air Purifier',
      description: 'HEPA air purifier',
      category: 'Home & Garden',
      brand: 'CleanAir',
      price: 249.99,
      rating: 4.5,
      inStock: true,
      tags: ['new'],
    },
    {
      key: 'p15',
      name: 'Kitchen Scale',
      description: 'Digital kitchen scale',
      category: 'Home & Garden',
      brand: 'CookPro',
      price: 24.99,
      rating: 4.2,
      inStock: true,
      tags: [],
    },
  ],
  { model: 'Product' },
);

await dcupl.init();
console.log('Product catalog initialized');

Steps 4 to 13 add more code inside main(), each below the previous step.

Step 4: create the product list

Create a list that you will use for all queries:

catalog.ts: add inside main()
// Create the main product list
const productList = dcupl.lists.create<Product>({ modelKey: 'Product' });

// Display initial stats
console.log('\n--- Catalog Overview ---');
console.log('Total products:', productList.catalog.query.count());

Run the app:

terminal
npx tsx catalog.ts

Output:

Product catalog initialized

--- Catalog Overview ---
Total products: 15

Step 5: build faceted navigation

Facets are the counts shown next to filter options. For example, "Electronics (5)" tells users there are 5 electronics products.

catalog.ts: add inside main()
// Get facets for all filterable attributes
function displayFacets() {
  console.log('\n--- Available Filters ---');

  // Category facets
  console.log('\nCategories:');
  productList.catalog.fn.facets({ attribute: 'category' }).forEach((f) => {
    console.log(`  [ ] ${f.value} (${f.size})`);
  });

  // Brand facets, sorted by name
  console.log('\nBrands:');
  productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
    console.log(`  [ ] ${f.value} (${f.size})`);
  });

  // In-stock facet
  console.log('\nAvailability:');
  productList.catalog.fn.facets({ attribute: 'inStock' }).forEach((f) => {
    const label = f.value === true ? 'In Stock' : 'Out of Stock';
    console.log(`  [ ] ${label} (${f.size})`);
  });

  // Tags facets: one entry per tag (products with tags: [] add none)
  console.log('\nTags:');
  productList.catalog.fn.facets({ attribute: 'tags' }).forEach((f) => {
    console.log(`  [ ] ${f.value} (${f.size})`);
  });
}

displayFacets();

Output:

--- Available Filters ---

Categories:
  [ ] Electronics (5)
  [ ] Clothing (5)
  [ ] Home & Garden (5)

Brands:
  [ ] BrewMaster (1)
  [ ] CleanAir (1)
  [ ] ConnectPro (1)
  [ ] CookPro (1)
  [ ] DenimCo (1)
  [ ] EcoWear (2)
  [ ] GreenThumb (1)
  [ ] LightWorks (1)
  [ ] OutdoorGear (1)
  [ ] SportyFeet (1)
  [ ] TechCorp (2)
  [ ] TypeMaster (1)
  [ ] ViewTech (1)

Availability:
  [ ] In Stock (13)
  [ ] Out of Stock (2)

Tags:
  [ ] featured (3)
  [ ] new (3)
  [ ] bestseller (3)
  [ ] popular (2)
  [ ] sale (2)
  [ ] eco-friendly (2)
  [ ] seasonal (1)

Facet values come in the order dcupl first meets them. Pass sort ('value-asc', 'value-desc', 'size-desc' or 'size-asc') for a fixed order, as the brand facet does. Products with tags: [] add no entry: an empty array has no values to count.

Step 6: filter by category

Now simulate a user selecting "Electronics":

catalog.ts: add inside main()
// User selects "Electronics" category
console.log('\n--- User selects: Electronics ---');

productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});

// Show filtered products
console.log('\nFiltered products:');
productList.catalog.query.items().forEach((p) => {
  console.log(`  ${p.name} - $${p.price} (${p.brand})`);
});

// The category facet keeps every category and marks the selected one
console.log('\nCategories:');
productList.catalog.fn.facets({ attribute: 'category' }).forEach((f) => {
  console.log(`  ${f.selected ? '[x]' : '[ ]'} ${f.value} (${f.size})`);
});

// Facets of other attributes count only the electronics
console.log('\nBrands in Electronics:');
productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
  console.log(`  [ ] ${f.value} (${f.size})`);
});

Output:

--- User selects: Electronics ---

Filtered products:
  Laptop Pro 15 - $1299.99 (TechCorp)
  Wireless Mouse - $49.99 (TechCorp)
  USB-C Hub - $79.99 (ConnectPro)
  Mechanical Keyboard - $149.99 (TypeMaster)
  Monitor 27" - $449.99 (ViewTech)

Categories:
  [x] Electronics (5)
  [ ] Clothing (5)
  [ ] Home & Garden (5)

Brands in Electronics:
  [ ] ConnectPro (1)
  [ ] TechCorp (2)
  [ ] TypeMaster (1)
  [ ] ViewTech (1)

Notice how the brand facet now counts only electronics products. The category facet still lists all three categories with their full counts, and selected: true marks Electronics: when counting, a facet leaves out the conditions on its own attribute, so users can switch to or add another category.

addCondition stores the condition in a query group keyed by its attribute (category), and that is the group facets() leaves out by default. If you put a selection into a group with a custom key, pass that key as excludeGroup to facets().

Step 7: add price range filter

Add a price range filter on top of the category filter. Conditions that addCondition adds on the same attribute are combined with OR (price >= 50 OR price <= 200 would match every product), so put both bounds into one 'and' group keyed price:

catalog.ts: add inside main()
// User adds price filter: $50 - $200
console.log('\n--- User adds: Price $50 - $200 ---');

productList.catalog.query.addGroup({
  groupKey: 'price',
  groupType: 'and',
  queries: [
    { attribute: 'price', operator: 'gte', value: 50 },
    { attribute: 'price', operator: 'lte', value: 200 },
  ],
});

console.log('\nFiltered products (Electronics, $50-$200):');
productList.catalog.query.items().forEach((p) => {
  console.log(`  ${p.name} - $${p.price}`);
});

console.log('\nMatching count:', productList.catalog.query.count());

Output:

--- User adds: Price $50 - $200 ---

Filtered products (Electronics, $50-$200):
  USB-C Hub - $79.99
  Mechanical Keyboard - $149.99

Matching count: 2

Step 8: filter by multiple values

Allow users to select multiple brands. First, clear filters and start fresh:

catalog.ts: add inside main()
// Clear all filters
productList.catalog.query.clear();

console.log('\n--- New search: Multiple brands ---');

// Filter to show products from TechCorp OR EcoWear
// Use 'in' operator for multiple values
productList.catalog.query.addCondition({
  attribute: 'brand',
  operator: 'in',
  value: ['TechCorp', 'EcoWear'],
});

console.log('\nProducts from TechCorp or EcoWear:');
productList.catalog.query.items().forEach((p) => {
  console.log(`  ${p.name} - ${p.brand}`);
});

Output:

--- New search: Multiple brands ---

Products from TechCorp or EcoWear:
  Laptop Pro 15 - TechCorp
  Wireless Mouse - TechCorp
  Cotton T-Shirt - EcoWear
  Wool Sweater - EcoWear

Step 9: filter by tags

Filter products that have specific tags:

catalog.ts: add inside main()
productList.catalog.query.clear();

console.log('\n--- Filter by tag: featured ---');

// On an array property, eq matches when any element equals the value
productList.catalog.query.addCondition({
  attribute: 'tags',
  operator: 'eq',
  value: 'featured',
});

console.log('\nFeatured products:');
productList.catalog.query.items().forEach((p) => {
  console.log(`  ${p.name} - $${p.price}`);
});

// Require a second tag: two conditions on 'tags' would be combined with OR,
// so replace the 'tags' group with one 'and' group
console.log('\n--- Must also be tagged: new ---');

productList.catalog.query.removeGroup('tags');
productList.catalog.query.addGroup({
  groupKey: 'tags',
  groupType: 'and',
  queries: [
    { attribute: 'tags', operator: 'eq', value: 'featured' },
    { attribute: 'tags', operator: 'eq', value: 'new' },
  ],
});

console.log('\nFeatured AND new:');
productList.catalog.query.items().forEach((p) => {
  console.log(`  ${p.name} - ${p.tags.join(', ')}`);
});

Output:

--- Filter by tag: featured ---

Featured products:
  Laptop Pro 15 - $1299.99
  Monitor 27" - $449.99
  LED Desk Lamp - $59.99

--- Must also be tagged: new ---

Featured AND new:
  Laptop Pro 15 - featured, new

Step 10: get price statistics

Show price range and average for filtered results:

catalog.ts: add inside main()
productList.catalog.query.clear();

// Filter to Electronics
productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});

// Get price statistics: `types` selects what to calculate
const priceStats = productList.catalog.fn.aggregate({
  attribute: 'price',
  types: ['min', 'max', 'avg'],
});

// min, max and avg are missing when no product matches, hence the defaults
const { min = 0, max = 0, avg = 0 } = priceStats;

console.log('\n--- Electronics Price Statistics ---');
console.log(`  Min: $${min.toFixed(2)}`);
console.log(`  Max: $${max.toFixed(2)}`);
console.log(`  Average: $${avg.toFixed(2)}`);

Output:

--- Electronics Price Statistics ---
  Min: $49.99
  Max: $1299.99
  Average: $405.99

Step 11: sort products

Add sorting options for the catalog:

catalog.ts: add inside main()
productList.catalog.query.clear();

console.log('\n--- Sorting Examples ---');

// Sort by price (low to high)
console.log('\nBy price (low to high):');
productList.catalog.query
  .items({
    sort: { attributes: ['price'], order: ['ASC'] },
    count: 5,
  })
  .forEach((p) => {
    console.log(`  $${p.price} - ${p.name}`);
  });

// Sort by rating (high to low)
console.log('\nBy rating (high to low):');
productList.catalog.query
  .items({
    sort: { attributes: ['rating'], order: ['DESC'] },
    count: 5,
  })
  .forEach((p) => {
    console.log(`  ${p.rating} stars - ${p.name}`);
  });

// Sort by multiple attributes: category, then price
console.log('\nBy category, then price:');
productList.catalog.query
  .items({
    sort: { attributes: ['category', 'price'], order: ['ASC', 'ASC'] },
  })
  .forEach((p) => {
    console.log(`  ${p.category} | $${p.price} - ${p.name}`);
  });

Output:

--- Sorting Examples ---

By price (low to high):
  $24.99 - Kitchen Scale
  $29.99 - Cotton T-Shirt
  $34.99 - Plant Pot Set
  $49.99 - Wireless Mouse
  $59.99 - LED Desk Lamp

By rating (high to low):
  4.8 stars - Laptop Pro 15
  4.8 stars - Running Shoes
  4.7 stars - Mechanical Keyboard
  4.7 stars - LED Desk Lamp
  4.6 stars - Monitor 27"

By category, then price:
  Clothing | $29.99 - Cotton T-Shirt
  Clothing | $79.99 - Denim Jeans
  Clothing | $89.99 - Wool Sweater
  Clothing | $129.99 - Running Shoes
  Clothing | $199.99 - Winter Jacket
  Electronics | $49.99 - Wireless Mouse
  Electronics | $79.99 - USB-C Hub
  Electronics | $149.99 - Mechanical Keyboard
  Electronics | $449.99 - Monitor 27"
  Electronics | $1299.99 - Laptop Pro 15
  Home & Garden | $24.99 - Kitchen Scale
  Home & Garden | $34.99 - Plant Pot Set
  Home & Garden | $59.99 - LED Desk Lamp
  Home & Garden | $99.99 - Coffee Maker
  Home & Garden | $249.99 - Air Purifier

Step 12: combine everything

Here is a realistic filter scenario combining multiple conditions:

catalog.ts: add inside main()
productList.catalog.query.clear();

console.log('\n--- Complete Filter Example ---');
console.log('Filters: Clothing OR Home & Garden, In Stock, Under $100');

// Multiple categories
productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'in',
  value: ['Clothing', 'Home & Garden'],
});

// Must be in stock
productList.catalog.query.addCondition({
  attribute: 'inStock',
  operator: 'eq',
  value: true,
});

// Price under $100
productList.catalog.query.addCondition({
  attribute: 'price',
  operator: 'lt',
  value: 100,
});

// Display results with sorting
const results = productList.catalog.query.items({
  sort: { attributes: ['price'], order: ['ASC'] },
});

console.log(`\nFound ${results.length} products:\n`);
results.forEach((p) => {
  console.log(`  ${p.name}`);
  console.log(`    Category: ${p.category} | Brand: ${p.brand}`);
  console.log(`    Price: $${p.price} | Rating: ${p.rating} stars`);
  console.log('');
});

// Show remaining filter options
console.log('Brands available in filtered results:');
productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
  console.log(`  [ ] ${f.value} (${f.size})`);
});

Output:

--- Complete Filter Example ---
Filters: Clothing OR Home & Garden, In Stock, Under $100

Found 7 products:

  Kitchen Scale
    Category: Home & Garden | Brand: CookPro
    Price: $24.99 | Rating: 4.2 stars

  Cotton T-Shirt
    Category: Clothing | Brand: EcoWear
    Price: $29.99 | Rating: 4.4 stars

  Plant Pot Set
    Category: Home & Garden | Brand: GreenThumb
    Price: $34.99 | Rating: 4.1 stars

  LED Desk Lamp
    Category: Home & Garden | Brand: LightWorks
    Price: $59.99 | Rating: 4.7 stars

  Denim Jeans
    Category: Clothing | Brand: DenimCo
    Price: $79.99 | Rating: 4.3 stars

  Wool Sweater
    Category: Clothing | Brand: EcoWear
    Price: $89.99 | Rating: 4.6 stars

  Coffee Maker
    Category: Home & Garden | Brand: BrewMaster
    Price: $99.99 | Rating: 4.4 stars

Brands available in filtered results:
  [ ] BrewMaster (1)
  [ ] CookPro (1)
  [ ] DenimCo (1)
  [ ] EcoWear (2)
  [ ] GreenThumb (1)
  [ ] LightWorks (1)

For booleans, eq matches the exact value. The isTruthy operator matches any truthy value instead (for example 1 or a non-empty string), and with value: false any falsy one.

Step 13: build a reusable filter manager

A real filter UI keeps the user's selections and rebuilds the query whenever one changes. The FacetedSearch class below does that: one 'and' group per attribute, keyed by the attribute, so a minimum and a maximum on price must both hold and every facet still leaves out its own attribute.

Change the import at the top of catalog.ts to also import the DcuplList type:

catalog.ts: the import
import { Dcupl, type DcuplList } from '@dcupl/core';

Add the class at the top level of catalog.ts, below the Product type (not inside main()):

catalog.ts: add below the Product type
type FilterOperator = 'eq' | 'in' | 'gte' | 'lte';

interface Selection {
  attribute: string;
  values: Array<string | number | boolean>;
  operator: FilterOperator;
}

// Keeps the user's selections and turns them into the list's query
class FacetedSearch {
  private selections = new Map<string, Selection>();

  constructor(private list: DcuplList<Product>) {}

  // Select values of an attribute (an empty array clears it)
  setFacet(attribute: string, values: Array<string | number | boolean>) {
    if (values.length === 0) {
      this.selections.delete(attribute);
    } else {
      this.selections.set(attribute, { attribute, values, operator: 'in' });
    }
    this.applyFilters();
  }

  // Set a range on a numeric attribute (leave a bound out to drop it)
  setRange(attribute: string, min?: number, max?: number) {
    this.selections.delete(`${attribute}:min`);
    this.selections.delete(`${attribute}:max`);
    if (min !== undefined) this.selections.set(`${attribute}:min`, { attribute, values: [min], operator: 'gte' });
    if (max !== undefined) this.selections.set(`${attribute}:max`, { attribute, values: [max], operator: 'lte' });
    this.applyFilters();
  }

  clearAll() {
    this.selections.clear();
    this.list.catalog.query.clear();
  }

  private applyFilters() {
    this.list.catalog.query.clear();

    for (const selection of this.selections.values()) {
      // One value: eq (or the range operator). Several values: in
      const condition =
        selection.values.length === 1
          ? {
              attribute: selection.attribute,
              operator: selection.operator === 'in' ? 'eq' : selection.operator,
              value: selection.values[0],
            }
          : { attribute: selection.attribute, operator: 'in' as const, value: selection.values };

      // One 'and' group per attribute, keyed by the attribute: a min and a max on
      // 'price' must both hold, and a facet leaves its own attribute's group out
      this.list.catalog.query.addGroup({
        groupKey: selection.attribute,
        groupType: 'and',
        queries: [condition],
      });
    }
  }

  getResults() {
    return this.list.catalog.query.items();
  }

  getCount() {
    return this.list.catalog.query.count();
  }

  getFacets(attribute: string) {
    return this.list.catalog.fn.facets({ attribute });
  }

  getActiveFilters() {
    return [...this.selections.values()].map((s) =>
      s.operator === 'in' ? `${s.attribute}: ${s.values.join(', ')}` : `${s.attribute} ${s.operator} ${s.values[0]}`,
    );
  }
}

Then use it at the end of main():

catalog.ts: add inside main()
// Reset and demonstrate the reusable class
productList.catalog.query.clear();

console.log('\n--- Using FacetedSearch ---');

const search = new FacetedSearch(productList);

// User selects a category, then two brands
search.setFacet('category', ['Electronics']);
console.log(`\nAfter selecting Electronics: ${search.getCount()} results`);

search.setFacet('brand', ['TechCorp', 'ViewTech']);
console.log(`After selecting TechCorp and ViewTech: ${search.getCount()} results`);

// The brand facet keeps its unselected values and marks the selected ones
console.log('\nBrand options:');
search.getFacets('brand').forEach((f) => {
  console.log(`  ${f.selected ? '[x]' : '[ ]'} ${f.value} (${f.size})`);
});

// User sets a price range
search.setRange('price', 50, 500);
console.log(`\nAfter price $50 - $500: ${search.getCount()} results`);
console.log('Active filters:', search.getActiveFilters().join('; '));
search.getResults().forEach((p) => {
  console.log(`  ${p.name} - $${p.price}`);
});

// Clear and start over
search.clearAll();
console.log(`\nAfter clearing all: ${search.getCount()} results`);

Output:

--- Using FacetedSearch ---

After selecting Electronics: 5 results
After selecting TechCorp and ViewTech: 3 results

Brand options:
  [x] TechCorp (2)
  [ ] ConnectPro (1)
  [ ] TypeMaster (1)
  [x] ViewTech (1)

After price $50 - $500: 1 results
Active filters: category: Electronics; brand: TechCorp, ViewTech; price gte 50; price lte 500
  Monitor 27" - $449.99

After clearing all: 15 results

Complete code

Here is the complete product catalog application, Steps 1 to 13 in one file:

catalog.ts
import { Dcupl, type DcuplList } from '@dcupl/core';

type Product = {
  key: string;
  name: string;
  description: string;
  category: string;
  brand: string;
  price: number;
  rating: number;
  inStock: boolean;
  tags: string[];
};

type FilterOperator = 'eq' | 'in' | 'gte' | 'lte';

interface Selection {
  attribute: string;
  values: Array<string | number | boolean>;
  operator: FilterOperator;
}

// Keeps the user's selections and turns them into the list's query
class FacetedSearch {
  private selections = new Map<string, Selection>();

  constructor(private list: DcuplList<Product>) {}

  // Select values of an attribute (an empty array clears it)
  setFacet(attribute: string, values: Array<string | number | boolean>) {
    if (values.length === 0) {
      this.selections.delete(attribute);
    } else {
      this.selections.set(attribute, { attribute, values, operator: 'in' });
    }
    this.applyFilters();
  }

  // Set a range on a numeric attribute (leave a bound out to drop it)
  setRange(attribute: string, min?: number, max?: number) {
    this.selections.delete(`${attribute}:min`);
    this.selections.delete(`${attribute}:max`);
    if (min !== undefined) this.selections.set(`${attribute}:min`, { attribute, values: [min], operator: 'gte' });
    if (max !== undefined) this.selections.set(`${attribute}:max`, { attribute, values: [max], operator: 'lte' });
    this.applyFilters();
  }

  clearAll() {
    this.selections.clear();
    this.list.catalog.query.clear();
  }

  private applyFilters() {
    this.list.catalog.query.clear();

    for (const selection of this.selections.values()) {
      // One value: eq (or the range operator). Several values: in
      const condition =
        selection.values.length === 1
          ? {
              attribute: selection.attribute,
              operator: selection.operator === 'in' ? 'eq' : selection.operator,
              value: selection.values[0],
            }
          : { attribute: selection.attribute, operator: 'in' as const, value: selection.values };

      // One 'and' group per attribute, keyed by the attribute: a min and a max on
      // 'price' must both hold, and a facet leaves its own attribute's group out
      this.list.catalog.query.addGroup({
        groupKey: selection.attribute,
        groupType: 'and',
        queries: [condition],
      });
    }
  }

  getResults() {
    return this.list.catalog.query.items();
  }

  getCount() {
    return this.list.catalog.query.count();
  }

  getFacets(attribute: string) {
    return this.list.catalog.fn.facets({ attribute });
  }

  getActiveFilters() {
    return [...this.selections.values()].map((s) =>
      s.operator === 'in' ? `${s.attribute}: ${s.values.join(', ')}` : `${s.attribute} ${s.operator} ${s.values[0]}`,
    );
  }
}

async function main() {
  const dcupl = new Dcupl();

  // Define the Product model with filterable properties
  dcupl.models.set({
    key: 'Product',
    properties: [
      { key: 'name', type: 'string' },
      { key: 'description', type: 'string' },
      { key: 'category', type: 'string' },
      { key: 'brand', type: 'string' },
      { key: 'price', type: 'float' },
      { key: 'rating', type: 'float' },
      { key: 'inStock', type: 'boolean' },
      { key: 'tags', type: 'Array<string>' },
    ],
  });

  // Load product data
  dcupl.data.set(
    [
      // Electronics
      {
        key: 'p1',
        name: 'Laptop Pro 15',
        description: 'High-performance laptop',
        category: 'Electronics',
        brand: 'TechCorp',
        price: 1299.99,
        rating: 4.8,
        inStock: true,
        tags: ['featured', 'new'],
      },
      {
        key: 'p2',
        name: 'Wireless Mouse',
        description: 'Ergonomic wireless mouse',
        category: 'Electronics',
        brand: 'TechCorp',
        price: 49.99,
        rating: 4.5,
        inStock: true,
        tags: ['bestseller'],
      },
      {
        key: 'p3',
        name: 'USB-C Hub',
        description: '7-in-1 USB-C hub',
        category: 'Electronics',
        brand: 'ConnectPro',
        price: 79.99,
        rating: 4.2,
        inStock: true,
        tags: [],
      },
      {
        key: 'p4',
        name: 'Mechanical Keyboard',
        description: 'RGB mechanical keyboard',
        category: 'Electronics',
        brand: 'TypeMaster',
        price: 149.99,
        rating: 4.7,
        inStock: false,
        tags: ['popular'],
      },
      {
        key: 'p5',
        name: 'Monitor 27"',
        description: '4K IPS monitor',
        category: 'Electronics',
        brand: 'ViewTech',
        price: 449.99,
        rating: 4.6,
        inStock: true,
        tags: ['featured'],
      },
      // Clothing
      {
        key: 'p6',
        name: 'Cotton T-Shirt',
        description: '100% organic cotton',
        category: 'Clothing',
        brand: 'EcoWear',
        price: 29.99,
        rating: 4.4,
        inStock: true,
        tags: ['sale', 'eco-friendly'],
      },
      {
        key: 'p7',
        name: 'Denim Jeans',
        description: 'Classic fit jeans',
        category: 'Clothing',
        brand: 'DenimCo',
        price: 79.99,
        rating: 4.3,
        inStock: true,
        tags: ['bestseller'],
      },
      {
        key: 'p8',
        name: 'Running Shoes',
        description: 'Lightweight running shoes',
        category: 'Clothing',
        brand: 'SportyFeet',
        price: 129.99,
        rating: 4.8,
        inStock: true,
        tags: ['new', 'popular'],
      },
      {
        key: 'p9',
        name: 'Winter Jacket',
        description: 'Waterproof winter jacket',
        category: 'Clothing',
        brand: 'OutdoorGear',
        price: 199.99,
        rating: 4.5,
        inStock: false,
        tags: ['seasonal'],
      },
      {
        key: 'p10',
        name: 'Wool Sweater',
        description: 'Merino wool sweater',
        category: 'Clothing',
        brand: 'EcoWear',
        price: 89.99,
        rating: 4.6,
        inStock: true,
        tags: ['eco-friendly'],
      },
      // Home & Garden
      {
        key: 'p11',
        name: 'Coffee Maker',
        description: 'Programmable coffee maker',
        category: 'Home & Garden',
        brand: 'BrewMaster',
        price: 99.99,
        rating: 4.4,
        inStock: true,
        tags: ['bestseller'],
      },
      {
        key: 'p12',
        name: 'Plant Pot Set',
        description: 'Ceramic pot set of 3',
        category: 'Home & Garden',
        brand: 'GreenThumb',
        price: 34.99,
        rating: 4.1,
        inStock: true,
        tags: ['sale'],
      },
      {
        key: 'p13',
        name: 'LED Desk Lamp',
        description: 'Adjustable LED lamp',
        category: 'Home & Garden',
        brand: 'LightWorks',
        price: 59.99,
        rating: 4.7,
        inStock: true,
        tags: ['featured'],
      },
      {
        key: 'p14',
        name: 'Air Purifier',
        description: 'HEPA air purifier',
        category: 'Home & Garden',
        brand: 'CleanAir',
        price: 249.99,
        rating: 4.5,
        inStock: true,
        tags: ['new'],
      },
      {
        key: 'p15',
        name: 'Kitchen Scale',
        description: 'Digital kitchen scale',
        category: 'Home & Garden',
        brand: 'CookPro',
        price: 24.99,
        rating: 4.2,
        inStock: true,
        tags: [],
      },
    ],
    { model: 'Product' },
  );

  await dcupl.init();
  console.log('Product catalog initialized');

  // Step 4: Create the product list
  // Create the main product list
  const productList = dcupl.lists.create<Product>({ modelKey: 'Product' });

  // Display initial stats
  console.log('\n--- Catalog Overview ---');
  console.log('Total products:', productList.catalog.query.count());

  // Step 5: Build faceted navigation
  // Get facets for all filterable attributes
  function displayFacets() {
    console.log('\n--- Available Filters ---');

    // Category facets
    console.log('\nCategories:');
    productList.catalog.fn.facets({ attribute: 'category' }).forEach((f) => {
      console.log(`  [ ] ${f.value} (${f.size})`);
    });

    // Brand facets, sorted by name
    console.log('\nBrands:');
    productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
      console.log(`  [ ] ${f.value} (${f.size})`);
    });

    // In-stock facet
    console.log('\nAvailability:');
    productList.catalog.fn.facets({ attribute: 'inStock' }).forEach((f) => {
      const label = f.value === true ? 'In Stock' : 'Out of Stock';
      console.log(`  [ ] ${label} (${f.size})`);
    });

    // Tags facets: one entry per tag (products with tags: [] add none)
    console.log('\nTags:');
    productList.catalog.fn.facets({ attribute: 'tags' }).forEach((f) => {
      console.log(`  [ ] ${f.value} (${f.size})`);
    });
  }

  displayFacets();

  // Step 6: Filter by category
  // User selects "Electronics" category
  console.log('\n--- User selects: Electronics ---');

  productList.catalog.query.addCondition({
    attribute: 'category',
    operator: 'eq',
    value: 'Electronics',
  });

  // Show filtered products
  console.log('\nFiltered products:');
  productList.catalog.query.items().forEach((p) => {
    console.log(`  ${p.name} - $${p.price} (${p.brand})`);
  });

  // The category facet keeps every category and marks the selected one
  console.log('\nCategories:');
  productList.catalog.fn.facets({ attribute: 'category' }).forEach((f) => {
    console.log(`  ${f.selected ? '[x]' : '[ ]'} ${f.value} (${f.size})`);
  });

  // Facets of other attributes count only the electronics
  console.log('\nBrands in Electronics:');
  productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
    console.log(`  [ ] ${f.value} (${f.size})`);
  });

  // Step 7: Add a price range
  // User adds price filter: $50 - $200
  console.log('\n--- User adds: Price $50 - $200 ---');

  productList.catalog.query.addGroup({
    groupKey: 'price',
    groupType: 'and',
    queries: [
      { attribute: 'price', operator: 'gte', value: 50 },
      { attribute: 'price', operator: 'lte', value: 200 },
    ],
  });

  console.log('\nFiltered products (Electronics, $50-$200):');
  productList.catalog.query.items().forEach((p) => {
    console.log(`  ${p.name} - $${p.price}`);
  });

  console.log('\nMatching count:', productList.catalog.query.count());

  // Step 8: Filter by multiple values
  // Clear all filters
  productList.catalog.query.clear();

  console.log('\n--- New search: Multiple brands ---');

  // Filter to show products from TechCorp OR EcoWear
  // Use 'in' operator for multiple values
  productList.catalog.query.addCondition({
    attribute: 'brand',
    operator: 'in',
    value: ['TechCorp', 'EcoWear'],
  });

  console.log('\nProducts from TechCorp or EcoWear:');
  productList.catalog.query.items().forEach((p) => {
    console.log(`  ${p.name} - ${p.brand}`);
  });

  // Step 9: Filter by tags
  productList.catalog.query.clear();

  console.log('\n--- Filter by tag: featured ---');

  // On an array property, eq matches when any element equals the value
  productList.catalog.query.addCondition({
    attribute: 'tags',
    operator: 'eq',
    value: 'featured',
  });

  console.log('\nFeatured products:');
  productList.catalog.query.items().forEach((p) => {
    console.log(`  ${p.name} - $${p.price}`);
  });

  // Require a second tag: two conditions on 'tags' would be combined with OR,
  // so replace the 'tags' group with one 'and' group
  console.log('\n--- Must also be tagged: new ---');

  productList.catalog.query.removeGroup('tags');
  productList.catalog.query.addGroup({
    groupKey: 'tags',
    groupType: 'and',
    queries: [
      { attribute: 'tags', operator: 'eq', value: 'featured' },
      { attribute: 'tags', operator: 'eq', value: 'new' },
    ],
  });

  console.log('\nFeatured AND new:');
  productList.catalog.query.items().forEach((p) => {
    console.log(`  ${p.name} - ${p.tags.join(', ')}`);
  });

  // Step 10: Get price statistics
  productList.catalog.query.clear();

  // Filter to Electronics
  productList.catalog.query.addCondition({
    attribute: 'category',
    operator: 'eq',
    value: 'Electronics',
  });

  // Get price statistics: `types` selects what to calculate
  const priceStats = productList.catalog.fn.aggregate({
    attribute: 'price',
    types: ['min', 'max', 'avg'],
  });

  // min, max and avg are missing when no product matches, hence the defaults
  const { min = 0, max = 0, avg = 0 } = priceStats;

  console.log('\n--- Electronics Price Statistics ---');
  console.log(`  Min: $${min.toFixed(2)}`);
  console.log(`  Max: $${max.toFixed(2)}`);
  console.log(`  Average: $${avg.toFixed(2)}`);

  // Step 11: Sort products
  productList.catalog.query.clear();

  console.log('\n--- Sorting Examples ---');

  // Sort by price (low to high)
  console.log('\nBy price (low to high):');
  productList.catalog.query
    .items({
      sort: { attributes: ['price'], order: ['ASC'] },
      count: 5,
    })
    .forEach((p) => {
      console.log(`  $${p.price} - ${p.name}`);
    });

  // Sort by rating (high to low)
  console.log('\nBy rating (high to low):');
  productList.catalog.query
    .items({
      sort: { attributes: ['rating'], order: ['DESC'] },
      count: 5,
    })
    .forEach((p) => {
      console.log(`  ${p.rating} stars - ${p.name}`);
    });

  // Sort by multiple attributes: category, then price
  console.log('\nBy category, then price:');
  productList.catalog.query
    .items({
      sort: { attributes: ['category', 'price'], order: ['ASC', 'ASC'] },
    })
    .forEach((p) => {
      console.log(`  ${p.category} | $${p.price} - ${p.name}`);
    });

  // Step 12: Combine everything
  productList.catalog.query.clear();

  console.log('\n--- Complete Filter Example ---');
  console.log('Filters: Clothing OR Home & Garden, In Stock, Under $100');

  // Multiple categories
  productList.catalog.query.addCondition({
    attribute: 'category',
    operator: 'in',
    value: ['Clothing', 'Home & Garden'],
  });

  // Must be in stock
  productList.catalog.query.addCondition({
    attribute: 'inStock',
    operator: 'eq',
    value: true,
  });

  // Price under $100
  productList.catalog.query.addCondition({
    attribute: 'price',
    operator: 'lt',
    value: 100,
  });

  // Display results with sorting
  const results = productList.catalog.query.items({
    sort: { attributes: ['price'], order: ['ASC'] },
  });

  console.log(`\nFound ${results.length} products:\n`);
  results.forEach((p) => {
    console.log(`  ${p.name}`);
    console.log(`    Category: ${p.category} | Brand: ${p.brand}`);
    console.log(`    Price: $${p.price} | Rating: ${p.rating} stars`);
    console.log('');
  });

  // Show remaining filter options
  console.log('Brands available in filtered results:');
  productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
    console.log(`  [ ] ${f.value} (${f.size})`);
  });

  // Step 13: Use the FacetedSearch class
  // Reset and demonstrate the reusable class
  productList.catalog.query.clear();

  console.log('\n--- Using FacetedSearch ---');

  const search = new FacetedSearch(productList);

  // User selects a category, then two brands
  search.setFacet('category', ['Electronics']);
  console.log(`\nAfter selecting Electronics: ${search.getCount()} results`);

  search.setFacet('brand', ['TechCorp', 'ViewTech']);
  console.log(`After selecting TechCorp and ViewTech: ${search.getCount()} results`);

  // The brand facet keeps its unselected values and marks the selected ones
  console.log('\nBrand options:');
  search.getFacets('brand').forEach((f) => {
    console.log(`  ${f.selected ? '[x]' : '[ ]'} ${f.value} (${f.size})`);
  });

  // User sets a price range
  search.setRange('price', 50, 500);
  console.log(`\nAfter price $50 - $500: ${search.getCount()} results`);
  console.log('Active filters:', search.getActiveFilters().join('; '));
  search.getResults().forEach((p) => {
    console.log(`  ${p.name} - $${p.price}`);
  });

  // Clear and start over
  search.clearAll();
  console.log(`\nAfter clearing all: ${search.getCount()} results`);
}

main();

What you learned

In this tutorial, you learned how to:

  1. Define models with various property types including arrays
  2. Load a realistic product dataset
  3. Build faceted navigation with dynamic counts, sorted facet values and selected flags
  4. Combine filter conditions (AND across attributes, an 'and' group for a price range)
  5. Filter by multiple values using the in operator
  6. Filter by array properties (tags), and require several values with an 'and' group
  7. Get price statistics and aggregations
  8. Sort results by single and multiple attributes
  9. Combine filters for complex queries
  10. Manage filter selections in a reusable class

Key patterns

Faceted Navigation

// Get counts for filter UI
const facets = list.catalog.fn.facets({ attribute: 'category' });
// Returns: [{ key: 'Electronics', value: 'Electronics', size: 5, selected: false, ... }, ...]

Price Range Filter

// Both bounds in one 'and' group (addCondition would combine them with OR)
list.catalog.query.addGroup({
  groupKey: 'price',
  groupType: 'and',
  queries: [
    { attribute: 'price', operator: 'gte', value: 50 },
    { attribute: 'price', operator: 'lte', value: 200 },
  ],
});

Multiple Values

list.catalog.query.addCondition({
  attribute: 'brand',
  operator: 'in',
  value: ['Brand1', 'Brand2', 'Brand3'],
});

Clear and Reapply

list.catalog.query.clear(); // Remove all filters (and sort/paging options)
// Apply new filters

Common issues

Facets show wrong counts

Facet counts respect the list's current query, except the conditions on the faceted attribute itself: with Electronics selected, the brand facet counts only electronics, while the category facet still counts every category. Values with zero matches are left out (excludeZeros defaults to true). If you want global counts, use a list without conditions.

Price range matches every product

addCondition combines conditions on the same attribute with OR. That suits multiple values (two eq conditions on category match either category, like one in condition), but a range needs both bounds to hold:

// Wrong: price >= 50 OR price <= 200 matches everything
query.addCondition({ attribute: 'price', operator: 'gte', value: 50 });
query.addCondition({ attribute: 'price', operator: 'lte', value: 200 });

// Correct: one 'and' group
query.addGroup({
  groupKey: 'price',
  groupType: 'and',
  queries: [
    { attribute: 'price', operator: 'gte', value: 50 },
    { attribute: 'price', operator: 'lte', value: 200 },
  ],
});

Array property filtering

When filtering array properties like tags, the eq operator checks if the array contains the value:

// Product has tags: ['new', 'featured']
query.addCondition({ attribute: 'tags', operator: 'eq', value: 'new' }); // Matches!

To require several values, put them into one 'and' group (Step 9).