TypeScript Usage Guide

This guide covers comprehensive TypeScript usage with the dcupl SDK, including type definitions, generics, type inference, and advanced typing patterns.

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
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM"],
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true
  }
}

Basic Type Usage

Importing Types

import type { ModelDefinition, PropertyDefinition, IItem, IQuery, DcuplList } from '@dcupl/common';
import { Dcupl } from '@dcupl/core';

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:

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

// Base item interface extends IItem
interface Product extends IItem {
  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 IItem {
  name: string;
  slug: string;
  description: string;
  parentId: string | null;
}

interface Customer extends IItem {
  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 IItem {
  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

Use generics to get type-safe query results:

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

const dcupl = new Dcupl();

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

// Execute returns typed results
const products = productList.catalog.query.items() as 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,
    });
  }

  if (filters?.minPrice !== undefined) {
    list.catalog.query.addCondition(
      {
        operator: 'gte',
        attribute: 'price',
        value: filters.minPrice,
      },
      { mode: 'add' }
    );
  }

  if (filters?.maxPrice !== undefined) {
    list.catalog.query.addCondition(
      {
        operator: 'lte',
        attribute: 'price',
        value: filters.maxPrice,
      },
      { mode: 'add' }
    );
  }

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

  // Return typed results
  return list.catalog.query.items() as 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 IItem> {
  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();
    this.list.catalog.query.addCondition({
      operator: 'eq',
      attribute: '$.value',
      value: id,
    });
    return this.list.catalog.query.first() 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[]

Type Inference with execute()

The execute() method can infer types:

// 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 IItem>(list: DcuplList): T[] {
  return list.catalog.query.items() as T[];
}

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

Type-Safe Queries

Query Builder with Types

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

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

class TypedQueryBuilder<T extends IItem> {
  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();

    this.queries.forEach((query) => {
      this.list.catalog.query.addCondition(
        {
          operator: query.operator,
          attribute: query.attribute as string,
          value: query.value,
        },
        { mode: 'add' }
      );
    });

    return this.list.catalog.query.items() as 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 IItem> {
  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 IItem>(list: DcuplList): FilterConfig<T> {
  const applyFilter = (operator: QueryOperator, attribute: string, value: any) => {
    list.catalog.query.addCondition({ operator, attribute, value }, { mode: 'add' });
  };

  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
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: IItem): 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: IItem): item is Category {
  return (
    'name' in item &&
    'slug' in item &&
    typeof item.name === 'string' &&
    typeof item.slug === 'string'
  );
}

// Usage
const items = list.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: 'name' | 'sku' | 'price' | 'category' | 'brand' | 'inStock' | 'rating'

function queryByField<T extends IItem, 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() as 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' | 'find' | 'typeof' | 'isTruthy' | 'size'
  : T extends number
    ? 'eq' | 'gt' | 'gte' | 'lt' | 'lte' | 'typeof'
    : T extends boolean
      ? 'eq' | 'isTruthy'
      : 'eq' | 'typeof' | 'isTruthy';

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

function applyTypedFilter<T extends IItem, K extends keyof T>(
  list: DcuplList,
  filter: TypedFilter<T, K>
): T[] {
  list.catalog.query.addCondition({
    // Cast needed: conditional type not narrowed at runtime
    operator: filter.operator as QueryOperator,
    attribute: filter.attribute as string,
    value: filter.value,
  });
  return list.catalog.query.items() as 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];
    count: number;
    enabled: boolean;
  }>;
}

function getFacets<T extends IItem, K extends FacetableFields<T>>(
  list: DcuplList,
  attribute: K
): FacetResult<T, K> {
  const facetValues = list.catalog.fn.facets({
    attribute: attribute as string,
  });

  return {
    attribute,
    values: facetValues as FacetResult<T, K>['values'],
  };
}

// 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';

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

function aggregate<T extends IItem, K extends NumericFields<T>>(
  list: DcuplList,
  attribute: K,
  aggregation: AggregationType
): AggregationResult<T, K> {
  const value = list.catalog.fn.aggregate({
    attribute: attribute as string,
    aggregation,
  });

  return {
    attribute,
    aggregation,
    value: value || 0,
  };
}

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

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

Type-Safe Model Builder

class ModelBuilder<T extends Record<string, any>> {
  private properties: PropertyDefinition[] = [];

  string<K extends keyof T>(key: K, options?: { filter?: boolean; index?: boolean }): this {
    this.properties.push({
      key: key as string,
      type: 'string',
      ...options,
    });
    return this;
  }

  number<K extends keyof T>(key: K, options?: { filter?: boolean; aggregate?: boolean }): this {
    this.properties.push({
      key: key as string,
      type: 'float',
      ...options,
    });
    return this;
  }

  boolean<K extends keyof T>(key: K, options?: { filter?: boolean }): this {
    this.properties.push({
      key: key as string,
      type: 'boolean',
      ...options,
    });
    return this;
  }

  array<K extends keyof T>(key: K, itemType: string): this {
    this.properties.push({
      key: key as string,
      type: `Array<${itemType}>`,
    });
    return this;
  }

  build(modelKey: string): ModelDefinition {
    return {
      key: modelKey,
      properties: this.properties,
    };
  }
}

// Usage
interface ProductSchema {
  name: string;
  price: number;
  category: string;
  inStock: boolean;
  tags: string[];
}

const productModelDef = new ModelBuilder<ProductSchema>()
  .string('name')
  .number('price')
  .string('category')
  .boolean('inStock')
  .array('tags', 'string')
  .build('Product');

Strict Null Checks

Handle nullable values properly:

interface ProductWithOptionals extends IItem {
  name: string;
  price: number;
  description?: string; // Optional field
  discount: number | null; // Nullable field
  tags: string[];
}

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

function getDiscountedPrice(product: ProductWithOptionals): number {
  // Null check
  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];
}

Utility Types

Create reusable utility types:

// Extract IDs from items
type ItemId<T extends IItem> = T[']['value'];

// Pick specific fields
type ProductSummary = Pick<Product, 'name' | 'price' | 'category'>;

// Omit fields
type ProductWithoutMetadata = Omit<Product, ' | 'createdAt' | 'updatedAt'>;

// Partial updates
type ProductUpdate = Partial<ProductWithoutMetadata>;

// Required fields
type RequiredProduct = Required<Product>;

// Readonly
type ImmutableProduct = Readonly<Product>;

// Record type for indexed access
type ProductsById = Record<string, Product>;

Enums and Literal Types

Use enums or literal types for constrained values:

// Enum
enum ProductCategory {
  Electronics = 'Electronics',
  Clothing = 'Clothing',
  Books = 'Books',
  HomeAndGarden = 'Home & Garden',
}

// Literal type
type SortOrder = 'asc' | 'desc';
type SortAttribute = 'name' | 'price' | 'rating' | 'createdAt';

interface SortOptions {
  attribute: SortAttribute;
  order: SortOrder;
}

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

// 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

type DcuplEventType = 'data:updated' | 'data:added' | 'data:removed' | 'query:changed';

interface DcuplEvent<T extends DcuplEventType> {
  type: T;
  model?: string;
  data?: any;
}

type EventHandler<T extends DcuplEventType> = (event: DcuplEvent<T>) => void;

class TypedEventEmitter {
  private handlers = new Map<DcuplEventType, Set<EventHandler<any>>>();

  on<T extends DcuplEventType>(eventType: T, handler: EventHandler<T>): () => void {
    if (!this.handlers.has(eventType)) {
      this.handlers.set(eventType, new Set());
    }

    this.handlers.get(eventType)!.add(handler);

    // Return unsubscribe function
    return () => {
      this.handlers.get(eventType)?.delete(handler);
    };
  }

  emit<T extends DcuplEventType>(event: DcuplEvent<T>): void {
    this.handlers.get(event.type)?.forEach((handler) => {
      handler(event);
    });
  }
}

// Usage
const emitter = new TypedEventEmitter();

const unsubscribe = emitter.on('data:updated', (event) => {
  // event is typed as DcuplEvent<'data:updated'>
  console.log('Data updated for model:', event.model);
});

Testing with Types

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

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

  beforeEach(async () => {
    dcupl = new Dcupl();
    dcupl.models.set(productModel);
    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() as 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() as 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
interface Product extends IItem {
  name: string;
  price: number;
}

const products = list.catalog.query.items() as Product[];

// ✗ Bad: Using any
const products = list.catalog.query.items() as any[];

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 = list.catalog.query.items();
const products = items.filter(isValidProduct); // Type: Product[]

// ✗ Bad: Unsafe type assertion
const products = items as Product[];

3. Leverage Type Inference

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

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

4. Use Generics for Reusability

// ✓ Good: Generic reusable function
function findById<T extends IItem>(list: DcuplList, id: string): T | undefined {
  return list.catalog.query.first() as T | undefined;
}

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

5. Document Complex Types

/**
 * Represents a product in the catalog with all its attributes
 * @property {string} name - The product display name
 * @property {number} price - Price in USD cents (e.g., 1999 = $19.99)
 * @property {boolean} inStock - Whether the product is currently available
 */
interface Product extends IItem {
  name: string;
  price: number;
  inStock: boolean;
}

What's Next?

Now that you understand TypeScript usage with dcupl, explore: