Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Building with TypeScript

This guide covers building Tasker step handlers with TypeScript using the @tasker-systems/tasker package in a Bun application.

Quick Start

Install the package:

bun add @tasker-systems/tasker
# Or with npm
npm install @tasker-systems/tasker

Generate a step handler with tasker-ctl:

tasker-ctl template generate step_handler \
  --language typescript \
  --param name=ValidateCart

This creates a DSL-style handler with typed inputs that delegates to a service function.

Writing a Handler (DSL)

Every handler follows the three-layer pattern: type definition, handler declaration, service delegation.

// src/services/types.ts — the contract
export interface CartItem {
  sku: string;
  name: string;
  price: number;
  quantity: number;
}

// src/handlers/ecommerce.ts — the handler
import { defineHandler } from '@tasker-systems/tasker';
import type { CartItem } from '../services/types';
import * as svc from '../services/ecommerce';

export const ValidateCartHandler = defineHandler(
  'Ecommerce.StepHandlers.ValidateCartHandler',
  { inputs: { cartItems: 'cart_items' } },
  async ({ cartItems }) => svc.validateCartItems(cartItems as CartItem[] | undefined),
);

The defineHandler factory registers a handler by name. The inputs config maps camelCase parameter names to snake_case YAML field names. The async callback receives typed arguments and delegates to a service function.

Type System

TypeScript handlers use standard interfaces for both input and result types. The DSL injects values from the task context and upstream results as plain objects matching these interfaces.

Input types:

export interface CartItem {
  sku: string;
  name: string;
  price: number;
  quantity: number;
}

export interface PaymentInfo {
  method: string;
  card_last_four?: string;
  token: string;
  amount: number;
}

Result types describe what a handler returns (used by downstream depends):

export interface EcommerceValidateCartResult {
  [key: string]: unknown;
  validated_items: CartItem[];
  item_count: number;
  subtotal: number;
  tax: number;
  total: number;
}

export interface EcommerceProcessPaymentResult {
  [key: string]: unknown;
  payment_id: string;
  transaction_id: string;
  amount_charged: number;
  status: string;
}

The [key: string]: unknown index signature allows result objects to carry additional fields without type errors when accessing known fields.

Accessing Task Context

The inputs config in defineHandler extracts fields from the task context. Each entry maps a camelCase parameter name to the snake_case field name in the submitted JSON:

export const ValidateCartHandler = defineHandler(
  'Ecommerce.StepHandlers.ValidateCartHandler',
  { inputs: { cartItems: 'cart_items' } },
  async ({ cartItems }) => svc.validateCartItems(cartItems as CartItem[] | undefined),
);

The callback receives cartItems directly — no need to parse raw JSON.

Working with Dependencies

The depends config injects results from upstream steps. Each entry maps a camelCase parameter name to the upstream step name:

export const ProcessPaymentHandler = defineHandler(
  'Ecommerce.StepHandlers.ProcessPaymentHandler',
  {
    depends: { cartResult: 'validate_cart' },
    inputs: { paymentInfo: 'payment_info' },
  },
  async ({ cartResult, paymentInfo }) =>
    svc.processPayment(
      cartResult as Record<string, unknown>,
      paymentInfo as PaymentInfo | undefined,
    ),
);

Handlers can reference any ancestor step in the DAG — not just direct predecessors. Here’s a convergence handler that accesses three upstream steps plus task inputs:

export const CreateOrderHandler = defineHandler(
  'Ecommerce.StepHandlers.CreateOrderHandler',
  {
    depends: {
      cartResult: 'validate_cart',
      paymentResult: 'process_payment',
      inventoryResult: 'update_inventory',
    },
    inputs: { customerEmail: 'customer_email' },
  },
  async ({ cartResult, paymentResult, inventoryResult, customerEmail }) =>
    svc.createOrder(
      cartResult as Record<string, unknown>,
      paymentResult as Record<string, unknown>,
      inventoryResult as Record<string, unknown>,
      customerEmail as string | undefined,
    ),
);

Multi-Step Example: Data Pipeline

The data pipeline workflow demonstrates a parallel DAG — three independent extract branches, each feeding its own transform, converging at aggregation:

extract_sales    extract_inventory    extract_customers
     │                  │                    │
     ▼                  ▼                    ▼
transform_sales  transform_inventory  transform_customers
     │                  │                    │
     └──────────────────┼────────────────────┘
                        ▼
               aggregate_metrics
                        │
                        ▼
              generate_insights

TypeScript handlers follow the same concise pattern:

import { defineHandler } from '@tasker-systems/tasker';
import type {
  PipelineExtractSalesResult,
  PipelineTransformSalesResult,
  PipelineTransformInventoryResult,
  PipelineTransformCustomersResult,
} from '../services/types';
import * as svc from '../services/data_pipeline';

// Extract — no dependencies, runs in parallel
export const ExtractSalesDataHandler = defineHandler(
  'DataPipeline.StepHandlers.ExtractSalesDataHandler',
  { inputs: { source: 'source', dateRangeStart: 'date_range_start' } },
  async ({ source, dateRangeStart }) =>
    svc.extractSalesData(source as string, dateRangeStart as string | undefined),
);

// Transform — depends on one extract branch
export const TransformSalesHandler = defineHandler(
  'DataPipeline.StepHandlers.TransformSalesHandler',
  { depends: { salesData: 'extract_sales_data' } },
  async ({ salesData }) =>
    svc.transformSales(salesData as PipelineExtractSalesResult),
);

// Aggregate — converges three transform branches
export const AggregateMetricsHandler = defineHandler(
  'DataPipeline.StepHandlers.AggregateMetricsHandler',
  {
    depends: {
      salesTransform: 'transform_sales',
      inventoryTransform: 'transform_inventory',
      customersTransform: 'transform_customers',
    },
  },
  async ({ salesTransform, inventoryTransform, customersTransform }) =>
    svc.aggregateMetrics(
      salesTransform as PipelineTransformSalesResult,
      inventoryTransform as PipelineTransformInventoryResult,
      customersTransform as PipelineTransformCustomersResult,
    ),
);

Error Handling

Throw PermanentError or RetryableError from your handler or service functions:

import { PermanentError, RetryableError } from '@tasker-systems/tasker';

// Non-retryable validation failure
throw new PermanentError('Payment declined: insufficient funds', 'PAYMENT_DECLINED');

// Retryable transient failure
throw new RetryableError('Payment gateway temporarily unavailable', 'GATEWAY_ERROR');

Testing

DSL handlers are exported constants — test them with Vitest by calling the handler’s async callback directly, or by testing the service functions:

import { describe, it, expect, vi } from 'vitest';
import * as svc from '../services/ecommerce';

describe('ValidateCartHandler', () => {
  it('delegates to service', async () => {
    const mockResult = { validated_items: [], total: 64.79 };
    vi.spyOn(svc, 'validateCartItems').mockResolvedValue(mockResult);

    const cartItems = [{ sku: 'SKU-001', name: 'Widget', price: 29.99, quantity: 2 }];
    const result = await svc.validateCartItems(cartItems);

    expect(result.total).toBe(64.79);
  });
});

For handlers with dependencies, test the service functions with typed arguments:

describe('CreateOrderHandler', () => {
  it('creates order from upstream data', async () => {
    const mockResult = { order_id: 'ORD-001' };
    vi.spyOn(svc, 'createOrder').mockResolvedValue(mockResult);

    const result = await svc.createOrder(
      { total: 64.79, validated_items: [] },
      { payment_id: 'pay_abc', transaction_id: 'txn_xyz' },
      { inventory_log_id: 'log_123' },
      'test@example.com',
    );

    expect(result.order_id).toBe('ORD-001');
  });
});

Because handlers delegate to service functions, you can test the services directly without any Tasker infrastructure.

Handler Variants

API Handler

Adds HTTP client methods with error classification using the native fetch API. Extends ApiHandler with the class-based pattern. See Class-Based Handlers — API Handler.

Decision Handler

Adds workflow routing with decisionSuccess() for activating downstream step sets. Extends DecisionHandler with the class-based pattern. See Conditional Workflows.

Batchable Handler

Adds batch processing with Analyzer/Worker pattern using BatchableStepHandler. See Class-Based Handlers — Batchable Handler and Batch Processing.

Class-Based Alternative

If you prefer class inheritance, all handler types support a class-based pattern where you extend StepHandler and implement async call(context). See Class-Based Handlers for the full reference.

Next Steps