# 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](/sdk/advanced/faceted-search).

## Prerequisites

- Completed the [Build your first dcupl application](/learn/tutorials/first-app) tutorial
- A supported Node.js version, see [Compatibility](/sdk/compatibility#nodejs) (`node -v` to check)
- Basic TypeScript/JavaScript knowledge

## Step 1: set up the project

Create a new project directory and install dcupl (for yarn and pnpm, see the [Quick start](/sdk/quick-start#install)):

```bash filename="terminal"
mkdir product-catalog
cd product-catalog
npm init -y
npm install @dcupl/core
```

Create the main application file:

```typescript filename="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:

```typescript filename="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:

```typescript title="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:

```typescript title="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:

```bash filename="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.

```typescript title="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":

```typescript title="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`:

```typescript title="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:

```typescript title="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:

```typescript title="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:

```typescript title="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:

```typescript title="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:

```typescript title="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:

```typescript title="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()`):

```typescript title="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()`:

```typescript title="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:

```typescript filename="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**

```typescript
// 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**

```typescript
// 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**

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

**Clear and Reapply**

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

## Next steps

- [Real-time data updates](/learn/tutorials/real-time-updates) — Keep facets current when data changes
- [Faceted search](/sdk/advanced/faceted-search) — The facet patterns in depth
- [Facets](/sdk/fn/facets) — All facet options
- [Querying: Filtering](/sdk/queries/query-filter) — All filter operators

## 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:

```typescript
// 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:

```typescript
// 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).
