.md

TypeScript usage guide

This guide shows how to type your dcupl code: which types the SDK exports, which methods take your item type as a generic, what an untyped list returns, and patterns for typed queries, facets and aggregations.

Installation

The dcupl SDK includes built-in TypeScript definitions:

npm install @dcupl/core @dcupl/common
yarn add @dcupl/core @dcupl/common
pnpm add @dcupl/core @dcupl/common

@dcupl/core depends on exactly its own version of @dcupl/common. Install both in the same version, or your type imports come from a different version of @dcupl/common than the one the SDK uses. The type definitions use NoInfer, so they need TypeScript 5.4 or later.

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM"],
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true
  }
}

Key types

Dcupl and DcuplList come from @dcupl/core, DcuplAppLoader from @dcupl/loader. The types come from @dcupl/common:

Type What it types
ModelDefinition, Property Models and their properties
ListItem A generic item: { key: string; [attribute: string]: any }
DcuplQuery, DcuplQueryGroup A query condition and a group of conditions
DcuplQueryOperator The operator names: 'eq', 'in', 'find', 'gt', …
DcuplListQueryOptions<T> The options of items(), first() and count()
SortDirection 'ASC' or 'DESC'
DcuplFacet One facet value: { key, value, size, selected, enabled, … }
Aggregation, AggregationType The result of fn.aggregate() and its types
DcuplUpdateMessage, DcuplUpdateType An event and the union of its type
DcuplInitOptions The options of new Dcupl()

These methods take your item type as a generic parameter: dcupl.lists.create<T>() (it types the list's items), items<T>(), execute<T>(), first<T>() and count<T>() on list.catalog.query, and one<T>(), many<T>() and execute<T>() on dcupl.query. See Types for the full reference.

Untyped lists return any

dcupl.lists.create() returns a DcuplList whose item type defaults to any. Without a generic, items() returns any[] and first() returns any, so TypeScript doesn't flag a misspelled attribute. The untyped call next to the ways to type the results:

  • list.catalog.query.items() returns any[]: a misspelled attribute isn't flagged.
  • list.catalog.query.items<Product>() returns Product[]: a misspelled attribute is an error.
  • dcupl.lists.create<Product>(...) returns a DcuplList<Product>, the same type as the annotation const list: DcuplList<Product> = dcupl.lists.create(...). On that list items() returns (Product & ListItem)[] and first() (Product & ListItem) | undefined: a misspelled attribute is any and isn't flagged.

items<Product>() is the stricter one: on a DcuplList<Product>, the known attributes are typed, but ListItem's index signature makes every other attribute any.

Generics and casts only tell TypeScript what shape to expect. Nothing checks the items at runtime: for that, use a type guard (Custom Type Guards) or the model's quality checks.

Basic type usage

Importing types

import type { ModelDefinition, Property, ListItem, DcuplQuery, DcuplQueryOperator } from '@dcupl/common';
import { Dcupl, DcuplList } from '@dcupl/core';

@dcupl/core exports the Dcupl and DcuplList classes; the types (models, queries, facets, events) come from @dcupl/common. A few helper types aren't exported, see Types.

Type-safe model definition

import type { ModelDefinition } from '@dcupl/common';

const productModel: ModelDefinition = {
  key: 'Product',
  properties: [
    { key: 'name', type: 'string' },
    { key: 'price', type: 'float' },
    { key: 'category', type: 'string' },
    { key: 'inStock', type: 'boolean' },
    { key: 'tags', type: 'Array<string>' },
  ],
};

// TypeScript will catch errors
const invalidModel: ModelDefinition = {
  key: 'Invalid',
  properties: [
    // Error: 'type' is required
    { key: 'name' },
    // Error: invalid type
    { key: 'price', type: 'invalid' },
  ],
};

Custom type definitions

Creating item interfaces

Define TypeScript interfaces that match your models. Every dcupl item has a string key. @dcupl/common types a generic item as ListItem ({ key: string; [attribute: string]: any }); its index signature makes every attribute any, so declare your own interfaces to get checked attribute names:

// Every dcupl item has a string key
interface DcuplItem {
  key: string;
}

interface Product extends DcuplItem {
  name: string;
  sku: string;
  price: number;
  category: string;
  brand: string;
  description: string;
  inStock: boolean;
  rating: number;
  tags: string[];
  createdAt: Date;
  updatedAt: Date;
}

interface Category extends DcuplItem {
  name: string;
  slug: string;
  description: string;
  parentId: string | null;
}

interface Customer extends DcuplItem {
  email: string;
  firstName: string;
  lastName: string;
  phone: string;
  address: Address;
}

interface Address {
  street: string;
  city: string;
  state: string;
  zipCode: string;
  country: string;
}

Complex nested types

interface Order extends DcuplItem {
  orderId: string;
  customerId: string;
  items: OrderItem[];
  total: number;
  status: OrderStatus;
  shippingAddress: Address;
  billingAddress: Address;
  createdAt: Date;
  updatedAt: Date;
}

interface OrderItem {
  productId: string;
  quantity: number;
  price: number;
  discount?: number;
}

type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';

Generic type usage

DcuplList with generics

items(), first() and execute() take the item type as a generic parameter, or you pass it once to dcupl.lists.create<Product>() (Untyped Lists Return any):

import { Dcupl, DcuplList } from '@dcupl/core';

const dcupl = new Dcupl();
// Setup: register the Product model and its data (dcupl.models.set / dcupl.data.set),
// then initialize — lists.create() throws for a model that isn't registered
await dcupl.init();

// Create a list
const productList: DcuplList = dcupl.lists.create({ modelKey: 'Product' });

// The generic parameter types the results (Product is the interface defined above)
const products = productList.catalog.query.items<Product>();

// TypeScript knows the shape
products.forEach((product) => {
  console.log(product.name); // ✓ TypeScript knows 'name' exists
  console.log(product.price); // ✓ TypeScript knows 'price' is a number
  console.log(product.invalid); // ✗ Error: Property doesn't exist
});

Type-safe query functions

Create reusable type-safe query functions:

function queryProducts(
  list: DcuplList,
  filters?: {
    category?: string;
    minPrice?: number;
    maxPrice?: number;
    inStock?: boolean;
  },
): Product[] {
  // Reset query
  list.catalog.query.clear();

  // Apply filters
  if (filters?.category) {
    list.catalog.query.addCondition({
      operator: 'eq',
      attribute: 'category',
      value: filters.category,
    });
  }

  // Conditions on the same attribute combine with OR,
  // so a price range needs its own 'and' group
  const priceRange: DcuplQuery[] = [];
  if (filters?.minPrice !== undefined) {
    priceRange.push({ operator: 'gte', attribute: 'price', value: filters.minPrice });
  }
  if (filters?.maxPrice !== undefined) {
    priceRange.push({ operator: 'lte', attribute: 'price', value: filters.maxPrice });
  }
  if (priceRange.length > 0) {
    list.catalog.query.addGroup({ groupKey: 'price', groupType: 'and', queries: priceRange });
  }

  if (filters?.inStock !== undefined) {
    list.catalog.query.addCondition({
      operator: 'eq',
      attribute: 'inStock',
      value: filters.inStock,
    });
  }

  // Return typed results
  return list.catalog.query.items<Product>();
}

// Usage with type safety
const electronics = queryProducts(productList, { category: 'Electronics' });
const affordable = queryProducts(productList, { maxPrice: 100 });
const available = queryProducts(productList, { inStock: true });

Generic repository pattern

class DcuplRepository<T extends DcuplItem> {
  private list: DcuplList;

  constructor(
    private dcupl: Dcupl,
    private modelKey: string,
  ) {
    this.list = dcupl.lists.create({ modelKey });
  }

  findAll(): T[] {
    this.list.catalog.query.clear();
    return this.list.catalog.query.items() as T[];
  }

  findById(id: string): T | undefined {
    this.list.catalog.query.clear();
    // one() looks the item up by key in the list's current result;
    // it returns null when the key isn't there: ?? turns that into undefined
    return (this.list.catalog.query.one({ itemKey: id }) ?? undefined) as T | undefined;
  }

  findByAttribute(attribute: keyof T, value: any): T[] {
    this.list.catalog.query.clear();
    this.list.catalog.query.addCondition({
      operator: 'eq',
      attribute: attribute as string,
      value,
    });
    return this.list.catalog.query.items() as T[];
  }

  findWhere(predicate: (item: T) => boolean): T[] {
    const allItems = this.findAll();
    return allItems.filter(predicate);
  }

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

// Usage
const productRepo = new DcuplRepository<Product>(dcupl, 'Product');
const allProducts = productRepo.findAll(); // Type: Product[]
const product = productRepo.findById('p1'); // Type: Product | undefined
const electronics = productRepo.findByAttribute('category', 'Electronics'); // Type: Product[]
const expensive = productRepo.findWhere((p) => p.price > 1000); // Type: Product[]

Typing results

execute() is an alias of items(): both return the type you pass as the generic parameter:

// Generic parameter
const products0 = productList.catalog.query.items<Product>();

// Explicit typing
const products1: Product[] = productList.catalog.query.items() as Product[];

// Type assertion
const products2 = productList.catalog.query.items() as Product[];

// With generic function
function getProducts<T extends DcuplItem>(list: DcuplList): T[] {
  return list.catalog.query.items<T>();
}

const products3 = getProducts<Product>(productList); // Type: Product[]

Type-safe queries

Query builder with types

import type { DcuplQuery, DcuplQueryOperator } from '@dcupl/common';

// 'eq' | 'in' | 'find' | 'gt' | 'gte' | 'lt' | 'lte' | 'typeof' | 'isTruthy' | 'size'
type QueryOperator = DcuplQueryOperator;

interface TypedQuery<T> {
  attribute: keyof T;
  operator: QueryOperator;
  value: any;
}

class TypedQueryBuilder<T extends DcuplItem> {
  private queries: TypedQuery<T>[] = [];

  constructor(private list: DcuplList) {}

  where(attribute: keyof T, operator: QueryOperator, value: any): this {
    this.queries.push({ attribute, operator, value });
    return this;
  }

  execute(): T[] {
    this.list.catalog.query.clear();

    // One 'and' group: every where() must match, also several on the same attribute
    // (addCondition would OR conditions that share an attribute)
    const queries: DcuplQuery[] = this.queries.map((query) => ({
      operator: query.operator,
      attribute: query.attribute as string,
      value: query.value,
    }));
    this.list.catalog.query.addGroup({ groupKey: 'typed', groupType: 'and', queries });

    return this.list.catalog.query.items<T>();
  }

  reset(): this {
    this.queries = [];
    this.list.catalog.query.clear();
    return this;
  }
}

// Usage with type safety
const builder = new TypedQueryBuilder<Product>(productList);

const results = builder
  .where('category', 'eq', 'Electronics') // ✓ 'category' is valid
  .where('price', 'gte', 100) // ✓ 'price' is valid
  .where('inStock', 'eq', true) // ✓ 'inStock' is valid
  .where('invalid', 'eq', 'value') // ✗ Error: 'invalid' doesn't exist
  .execute();

Strongly-typed filter helpers

type FilterValue<T, K extends keyof T> = T[K];

interface FilterConfig<T extends DcuplItem> {
  eq<K extends keyof T>(attribute: K, value: FilterValue<T, K>): void;
  find<K extends keyof T>(attribute: K, value: string): void;
  gt<K extends keyof T>(attribute: K, value: FilterValue<T, K>): void;
  gte<K extends keyof T>(attribute: K, value: FilterValue<T, K>): void;
  lt<K extends keyof T>(attribute: K, value: FilterValue<T, K>): void;
  lte<K extends keyof T>(attribute: K, value: FilterValue<T, K>): void;
  isTruthy<K extends keyof T>(attribute: K, value: boolean): void;
}

function createFilter<T extends DcuplItem>(list: DcuplList): FilterConfig<T> {
  // Conditions on different attributes combine with AND; a second condition
  // on the same attribute is ORed with the first
  const applyFilter = (operator: QueryOperator, attribute: string, value: any) => {
    list.catalog.query.addCondition({ operator, attribute, value });
  };

  return {
    eq: (attribute, value) => applyFilter('eq', attribute as string, value),
    find: (attribute, value) => applyFilter('find', attribute as string, value),
    gt: (attribute, value) => applyFilter('gt', attribute as string, value),
    gte: (attribute, value) => applyFilter('gte', attribute as string, value),
    lt: (attribute, value) => applyFilter('lt', attribute as string, value),
    lte: (attribute, value) => applyFilter('lte', attribute as string, value),
    isTruthy: (attribute, value) => applyFilter('isTruthy', attribute as string, value),
  };
}

// Usage
const filter = createFilter<Product>(productList);

filter.eq('category', 'Electronics'); // ✓ Type-safe
filter.gte('price', 100); // ✓ Type-safe
filter.eq('inStock', true); // ✓ Type-safe
filter.find('name', '/Laptop/'); // ✓ Type-safe text search: '/…/' matches a substring, a bare string the whole value
filter.eq('invalid', 'value'); // ✗ Error: Property doesn't exist
filter.eq('price', 'not-a-number'); // ✗ Error: Wrong type

Custom type guards

Create type guards for runtime type checking:

function isProduct(item: ListItem): item is Product {
  return (
    'name' in item &&
    'price' in item &&
    'category' in item &&
    typeof item.name === 'string' &&
    typeof item.price === 'number' &&
    typeof item.category === 'string'
  );
}

function isCategory(item: ListItem): item is Category {
  return 'name' in item && 'slug' in item && typeof item.name === 'string' && typeof item.slug === 'string';
}

// Usage
const items = productList.catalog.query.items();

items.forEach((item) => {
  if (isProduct(item)) {
    // TypeScript knows item is Product
    console.log(item.price);
  } else if (isCategory(item)) {
    // TypeScript knows item is Category
    console.log(item.slug);
  }
});

Advanced typing patterns

Mapped types for queries

type QueryableFields<T> = {
  [K in keyof T]: T[K] extends string | number | boolean ? K : never;
}[keyof T];

type QueryableProduct = QueryableFields<Product>;
// Type: 'key' | 'name' | 'sku' | 'price' | 'category' | 'brand' | 'description' | 'inStock' | 'rating'

function queryByField<T extends DcuplItem, K extends QueryableFields<T>>(list: DcuplList, field: K, value: T[K]): T[] {
  list.catalog.query.clear();
  list.catalog.query.addCondition({
    operator: 'eq',
    attribute: field as string,
    value,
  });
  return list.catalog.query.items<T>();
}

// Usage
const byName = queryByField<Product, 'name'>(productList, 'name', 'Laptop');
const byPrice = queryByField<Product, 'price'>(productList, 'price', 999);

Conditional types for filters

type FilterOperatorForType<T> = T extends string
  ? 'eq' | 'in' | 'find' | 'typeof' | 'isTruthy' | 'size'
  : T extends number
    ? 'eq' | 'in' | 'gt' | 'gte' | 'lt' | 'lte' | 'typeof'
    : T extends boolean
      ? 'eq' | 'isTruthy'
      : 'eq' | 'in' | 'typeof' | 'isTruthy';

interface TypedFilter<T extends DcuplItem, K extends keyof T> {
  attribute: K;
  operator: FilterOperatorForType<T[K]>;
  value: T[K] | T[K][];
}

function applyTypedFilter<T extends DcuplItem, K extends keyof T>(list: DcuplList, filter: TypedFilter<T, K>): T[] {
  // Cast needed: the generic attribute/operator pair isn't narrowed to one DcuplQuery variant
  list.catalog.query.addCondition({
    operator: filter.operator,
    attribute: filter.attribute as string,
    value: filter.value,
  } as DcuplQuery);
  return list.catalog.query.items<T>();
}

// Usage - TypeScript enforces valid operators per type
const stringFilter: TypedFilter<Product, 'name'> = {
  attribute: 'name',
  operator: 'find', // ✓ Valid for string
  value: 'Laptop',
};

const numberFilter: TypedFilter<Product, 'price'> = {
  attribute: 'price',
  operator: 'gte', // ✓ Valid for number
  value: 100,
};

const invalidFilter: TypedFilter<Product, 'price'> = {
  attribute: 'price',
  operator: 'find', // ✗ Error: 'find' not valid for number
  value: 100,
};

Utility types for facets

type FacetableFields<T> = {
  [K in keyof T]: T[K] extends string | number | boolean ? K : never;
}[keyof T];

interface FacetResult<T, K extends keyof T> {
  attribute: K;
  values: Array<{
    value: T[K];
    size: number; // number of items with this value
    enabled: boolean;
  }>;
}

function getFacets<T extends DcuplItem, K extends FacetableFields<T>>(
  list: DcuplList,
  attribute: K,
): FacetResult<T, K> {
  // DcuplFacet[]: { key, value, size, selected, enabled, ... }
  const facetValues = list.catalog.fn.facets({
    attribute: attribute as string,
  });

  return {
    attribute,
    values: facetValues.map((facet) => ({
      value: facet.value as T[K],
      size: facet.size,
      enabled: facet.enabled,
    })),
  };
}

// Usage
const categoryFacets = getFacets<Product, 'category'>(productList, 'category');
const brandFacets = getFacets<Product, 'brand'>(productList, 'brand');
const priceFacets = getFacets<Product, 'price'>(productList, 'price');

Type-safe aggregations

type NumericFields<T> = {
  [K in keyof T]: T[K] extends number ? K : never;
}[keyof T];

type AggregationType = 'sum' | 'avg' | 'min' | 'max' | 'count'; // a subset of AggregationType from @dcupl/common

interface AggregationResult<T extends DcuplItem, K extends keyof T> {
  attribute: K;
  aggregation: AggregationType;
  value: number;
}

function aggregate<T extends DcuplItem, K extends NumericFields<T>>(
  list: DcuplList,
  attribute: K,
  aggregation: AggregationType,
): AggregationResult<T, K> {
  // Returns { attribute, types, avg?, sum?, ... } with one field per requested type
  const result = list.catalog.fn.aggregate({
    attribute: attribute as string,
    types: [aggregation],
  });

  return {
    attribute,
    aggregation,
    value: result[aggregation] ?? 0,
  };
}

// Usage
const avgPrice = aggregate<Product, 'price'>(productList, 'price', 'avg');
const ratingSum = aggregate<Product, 'rating'>(productList, 'rating', 'sum');

// Error: Can't aggregate non-numeric field
const invalidAgg = aggregate<Product, 'name'>(productList, 'name', 'sum'); // ✗ Error

Strict null checks

Handle nullable values properly. An attribute the source row has no value for is absent from the item (undefined); an explicit null in the source stays null:

interface ProductWithOptionals extends DcuplItem {
  name: string;
  price: number;
  description?: string; // Optional field
  discount?: number | null; // Missing (undefined) or explicitly null
  tags?: string[];
}

function getProductDescription(product: ProductWithOptionals): string {
  // Nullish coalescing
  return product.description ?? 'No description available';
}

function getDiscountedPrice(product: ProductWithOptionals): number {
  // == null catches both null and a missing value
  if (product.discount == null) {
    return product.price;
  }
  return product.price * (1 - product.discount);
}

function getFirstTag(product: ProductWithOptionals): string | undefined {
  // Array access with optional chaining
  return product.tags?.[0];
}

Typed sorting

Sort orders are uppercase; SortDirection from @dcupl/common types them:

import type { SortDirection } from '@dcupl/common';

type SortAttribute = 'name' | 'price' | 'rating' | 'createdAt';

interface SortOptions {
  attribute: SortAttribute;
  order: SortDirection; // 'ASC' | 'DESC'
}

function sortProducts(list: DcuplList, options: SortOptions): Product[] {
  return list.catalog.query.items<Product>({
    sort: {
      attributes: [options.attribute],
      order: [options.order],
    },
  });
}

// Usage
const sorted = sortProducts(productList, {
  attribute: 'price', // ✓ Valid
  order: 'ASC', // ✓ Valid
});

const invalid = sortProducts(productList, {
  attribute: 'invalid', // ✗ Error: Invalid attribute
  order: 'ascending', // ✗ Error: Invalid order
});

Type-safe event handling

dcupl.on(), list.on() and list.onResultChange() pass a DcuplUpdateMessage and return an unsubscribe function. message.type is the DcuplUpdateType union, so TypeScript rejects event names that don't exist:

import type { DcuplUpdateMessage } from '@dcupl/common';

// Fires whenever the list's result changes (query change or dcupl.update())
const unsubscribe = productList.onResultChange((message: DcuplUpdateMessage) => {
  console.log(message.type, message.listKey); // 'result_updated', '<list key>'
  renderProducts(productList.catalog.query.items<Product>());
});

// Global events
dcupl.on((message) => {
  if (message.type === 'dcupl_updated_manually') {
    console.log('dcupl.update() finished');
  }
  if (message.type === 'data_updated') {
    // ✗ Error: 'data_updated' is not a DcuplUpdateType
  }
});

// Later
unsubscribe();

Testing with types

import { describe, it, expect, beforeEach } from 'vitest';
import { Dcupl, DcuplList } from '@dcupl/core';

describe('Product Queries', () => {
  let dcupl: Dcupl;
  let productList: DcuplList;

  beforeEach(async () => {
    dcupl = new Dcupl();
    dcupl.models.set(productModel);
    dcupl.data.set(
      [
        { key: 'p1', name: 'Laptop', price: 999, category: 'Electronics', inStock: true, tags: ['work'] },
        { key: 'p2', name: 'Desk', price: 299, category: 'Furniture', inStock: false, tags: [] },
      ],
      { model: 'Product' },
    );
    await dcupl.init();
    productList = dcupl.lists.create({ modelKey: 'Product' });
  });

  it('should filter products by category', () => {
    productList.catalog.query.addCondition({
      operator: 'eq',
      attribute: 'category',
      value: 'Electronics',
    });

    const results = productList.catalog.query.items<Product>();

    expect(results).toBeInstanceOf(Array);
    expect(results.length).toBeGreaterThan(0);
    expect(results.every((p) => p.category === 'Electronics')).toBe(true);
  });

  it('should type-check product properties', () => {
    const products = productList.catalog.query.items<Product>();
    const firstProduct = products[0];

    // TypeScript ensures these properties exist
    expect(typeof firstProduct.name).toBe('string');
    expect(typeof firstProduct.price).toBe('number');
    expect(typeof firstProduct.inStock).toBe('boolean');
  });
});

Best practices

1. Always define interfaces

// ✓ Good: Clear interface definition, passed as the generic
interface Product extends DcuplItem {
  name: string;
  price: number;
}

const products = productList.catalog.query.items<Product>();

// ✗ Bad: Untyped results are any[]
const untyped = productList.catalog.query.items();

The generic, like a cast, is a promise about the shape that nothing checks at runtime.

2. Use type guards

// ✓ Good: Type guard for runtime safety
function isValidProduct(item: any): item is Product {
  return (
    typeof item === 'object' &&
    'name' in item &&
    'price' in item &&
    typeof item.name === 'string' &&
    typeof item.price === 'number'
  );
}

const items = productList.catalog.query.items();
const products = items.filter(isValidProduct); // Type: Product[], checked at runtime

// ✗ Bad: Unchecked type assertion
const unchecked = items as Product[];

3. Leverage type inference

// ✓ Good: Let TypeScript infer when possible
const products = getProducts<Product>(productList);

// ✗ Bad: Redundant type annotations
const annotated: Product[] = getProducts<Product>(productList);

4. Use generics for reusability

// ✓ Good: Generic reusable function
function findById<T extends DcuplItem>(list: DcuplList, id: string): T | undefined {
  // one() returns null for a key that isn't in the list's result: ?? turns that into undefined
  return (list.catalog.query.one({ itemKey: id }) ?? undefined) as T | undefined;
}

// ✗ Bad: Specific implementations
function findProductById(list: DcuplList, id: string): Product | undefined {
  // ...
}
function findCategoryById(list: DcuplList, id: string): Category | undefined {
  // ...
}