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/commonyarn add @dcupl/core @dcupl/commonpnpm 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.
{
"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()returnsany[]: a misspelled attribute isn't flagged.list.catalog.query.items<Product>()returnsProduct[]: a misspelled attribute is an error.dcupl.lists.create<Product>(...)returns aDcuplList<Product>, the same type as the annotationconst list: DcuplList<Product> = dcupl.lists.create(...). On that listitems()returns(Product & ListItem)[]andfirst()(Product & ListItem) | undefined: a misspelled attribute isanyand 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 typeCustom 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'); // ✗ ErrorStrict 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 {
// ...
}