Learn Effect Handlers

Master handler composition: understand built-in handlers, write custom interpreters, and compose handler stacks for production applications.

~25 min Advanced
Concept 1

What Are Handlers?

Interpreters that provide implementations for effects

Understanding What Are Handlers

Handlers are the runtime interpreters that give meaning to your effects.

Your program describes WHAT effects it needs. Handlers provide HOW those effects execute.

A handler intercepts effect operations and provides custom behavior—like logging to console, updating state, or handling errors.

Handlers are composable: stack multiple handlers to handle different effects, or override default behavior with custom implementations.

Code Example

import { Handler, stack } from "../typelang/mod.ts";
import { Console } from "../typelang/effects.ts";

// 1. Your program uses Console effect
const greet = (name: string) =>
  seq()
    .do(() => Console.op.log(`Hello, ${name}!`))
    .return(() => `Greeted ${name}`);

// 2. Define a handler that interprets Console operations
const consoleHandler = (): Handler => ({
  name: "Console",
  handles: {
    log: (instr, next, ctx) => {
      const [message] = instr.args;
      console.log(`[LOG] ${message}`);  // Actual side effect!
      return next(undefined);
    },
    warn: (instr, next, ctx) => {
      const [message] = instr.args;
      console.warn(`[WARN] ${message}`);
      return next(undefined);
    },
    error: (instr, next, ctx) => {
      const [message] = instr.args;
      console.error(`[ERROR] ${message}`);
      return next(undefined);
    },
  },
});

// 3. Run program with handler
const result = await stack(consoleHandler()).run(() => greet("Alice"));
// Prints: [LOG] Hello, Alice!
// Returns: "Greeted Alice"

Key Points

  • Handlers map effect operations to concrete implementations
  • Each handler has a name matching an effect type
  • handles object maps operation names to handler functions
  • Handler functions receive (instruction, next, ctx) for cancellation support
  • stack(...handlers).run(program) executes with handler stack
Concept 2

Built-in Handlers

Ready-to-use handlers for common effects

Understanding Built-in Handlers

typelang ships with production-ready handlers for all built-in effects.

Console handlers come in two flavors: live (prints to console) and capture (records to array).

State handler manages immutable state using a closure—no global variables.

Exception handler converts failures to Result<T,E> types—no thrown exceptions leak out.

Async handler integrates Promises and setTimeout without exposing them to your code.

Code Example

import { handlers, stack, seq } from "../typelang/mod.ts";
import { Console, State, Exception, Async } from "../typelang/effects.ts";

// Console.live() - prints to actual console
const liveProgram = () =>
  seq()
    .do(() => Console.op.log("This prints to console"))
    .return(() => 42);

await stack(handlers.Console.live()).run(liveProgram);
// Prints to actual console

// Console.capture() - records to array
const captureProgram = () =>
  seq()
    .do(() => Console.op.log("Captured"))
    .do(() => Console.op.warn("Also captured"))
    .return(() => "done");

const captured = await stack(handlers.Console.capture()).run(captureProgram);
// captured = { result: "done", logs: ["Captured"], warns: ["Also captured"], errors: [] }

// State.with(initial) - manages state
const stateProgram = () =>
  seq()
    .let(() => State.get<{count: number}>())
    .do((s) => State.put({count: s.count + 1}))
    .return(() => "incremented");

await stack(handlers.State.with({count: 0})).run(stateProgram);
// Returns: { result: "incremented", state: {count: 1} }

// Exception.tryCatch() - converts failures to Result
const failingProgram = () => Exception.op.fail({tag: "Error", msg: "Oops"});

const result = await stack(handlers.Exception.tryCatch()).run(failingProgram);
// result = { ok: false, error: {tag: "Error", msg: "Oops"} }

// Async.default() - handles sleep and await
const asyncProgram = () =>
  seq()
    .do(() => Async.op.sleep(100))
    .return(() => "waited");

await stack(handlers.Async.default()).run(asyncProgram);
// Waits 100ms, returns "waited"

Key Points

  • handlers.Console.live() - prints to actual console
  • handlers.Console.capture() - records logs to array
  • handlers.State.with(initial) - manages immutable state
  • handlers.Exception.tryCatch() - converts failures to Result
  • handlers.Async.default() - handles sleep/await
  • All handlers are composable via stack()
Concept 3

Writing Custom Handlers

Implement your own effect interpreters

Understanding Writing Custom Handlers

Custom handlers let you control exactly how effects execute in your application.

Common use cases: logging to a file, sending metrics to a service, custom state persistence, or test mocks.

Handler functions receive (instruction, next) where instruction contains operation name and args, next resumes the program.

You can transform arguments, intercept operations, or provide completely custom behavior.

Code Example

import { Handler } from "../typelang/mod.ts";
import { defineEffect } from "../typelang/mod.ts";

// 1. Define custom effect
const Metrics = defineEffect<"Metrics", {
  count(name: string, value: number): void;
  gauge(name: string, value: number): void;
  histogram(name: string, value: number): void;
}>("Metrics");

// 2. Create handler that sends to monitoring service
const datadogMetricsHandler = (apiKey: string): Handler => {
  const send = async (type: string, name: string, value: number) => {
    await fetch("https://api.datadoghq.com/api/v1/series", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "DD-API-KEY": apiKey,
      },
      body: JSON.stringify({
        series: [{ metric: name, points: [[Date.now() / 1000, value]], type }],
      }),
    });
  };

  return {
    name: "Metrics",
    handles: {
      count: (instr, next, ctx) => {
        const [name, value] = instr.args;
        send("count", name, value);  // Fire and forget
        return next(undefined);
      },
      gauge: (instr, next, ctx) => {
        const [name, value] = instr.args;
        send("gauge", name, value);
        return next(undefined);
      },
      histogram: (instr, next, ctx) => {
        const [name, value] = instr.args;
        send("histogram", name, value);
        return next(undefined);
      },
    },
  };
};

// 3. Test handler that records to array (no network calls)
const testMetricsHandler = (): Handler => {
  const recorded: Array<{type: string; name: string; value: number}> = [];

  return {
    name: "Metrics",
    handles: {
      count: (instr, next, ctx) => {
        const [name, value] = instr.args;
        recorded.push({type: "count", name, value});
        return next(undefined);
      },
      gauge: (instr, next, ctx) => {
        const [name, value] = instr.args;
        recorded.push({type: "gauge", name, value});
        return next(undefined);
      },
      histogram: (instr, next, ctx) => {
        const [name, value] = instr.args;
        recorded.push({type: "histogram", name, value});
        return next(undefined);
      },
    },
    // Expose recorded data for test assertions
    recorded,
  };
};

// Use in production
await stack(datadogMetricsHandler(API_KEY)).run(myProgram);

// Use in tests
const mock = testMetricsHandler();
await stack(mock).run(myProgram);
console.log(mock.recorded);  // Check what metrics were sent

Key Points

  • Handler = { name, handles: {...} }
  • handles maps operation names to (instr, next, ctx) => result
  • instr.args contains operation arguments as array
  • next(value) resumes the program with value
  • ctx provides cancellation signal and cleanup registration
  • Handlers can have state (closures) for recording/caching
  • Same effect, different handlers = different behavior
Concept 4

Composing Handler Stacks

Layer multiple handlers for complex programs

Understanding Composing Handler Stacks

Real programs use multiple effects, so you need multiple handlers in a stack.

Handlers execute in order—later handlers can override earlier ones for the same effect.

The order matters for effects that interact (e.g., Exception should wrap Console to catch log failures).

stack() takes handlers in order and returns a runner that executes your program with all handlers active.

Code Example

import { handlers, stack, seq } from "../typelang/mod.ts";
import { Console, State, Exception, Async } from "../typelang/effects.ts";

// Multi-effect program
const complexProgram = () =>
  seq()
    .do(() => Console.op.log("Starting..."))
    .let(() => State.get<{count: number}>())
    .do(() => Async.op.sleep(50))
    .when(
      (state) => state.count < 0,
      () => Exception.op.fail({tag: "NegativeCount"}),
    )
    .do((state) => Console.op.log(`Count: ${state.count}`))
    .do((state) => State.put({count: state.count + 1}))
    .return((state) => state.count + 1);

// Stack handlers in order
const result = await stack(
  handlers.Console.capture(),       // Capture logs
  handlers.State.with({count: 0}),  // Initial state
  handlers.Exception.tryCatch(),    // Convert failures to Result
  handlers.Async.default(),         // Handle sleep
).run(complexProgram);

// Result contains everything:
// {
//   ok: true,
//   value: { result: 1, state: {count: 1} },
//   logs: ["Starting...", "Count: 0"],
//   warns: [],
//   errors: []
// }

// Handler order matters!
// ❌ Wrong: Exception inside State means exception doesn't wrap state
await stack(
  handlers.State.with({count: 0}),
  handlers.Exception.tryCatch(),
).run(program);

// ✅ Right: Exception wraps State so failures capture final state
await stack(
  handlers.Exception.tryCatch(),
  handlers.State.with({count: 0}),
).run(program);

Key Points

  • stack(...handlers) composes multiple handlers
  • Order matters—later handlers can override earlier ones
  • Exception handler should be outer to catch all failures
  • State handler should be inner to track state through failures
  • Console.capture() should be outer to record all logs
  • Test with different stacks: production vs development vs test
Concept 5

Cancellation & Cleanup

Automatic resource disposal and graceful shutdown

Understanding Cancellation & Cleanup

typelang v0.3.0 introduces automatic cancellation and cleanup inspired by Effection's resource management.

Cancellation is completely transparent: you never pass AbortSignal manually—it's handled automatically through the CancellationContext.

Handlers receive a third parameter 'ctx' that provides access to the cancellation signal and cleanup registration.

When Ctrl-C is pressed (SIGINT/SIGTERM), all registered cleanup callbacks execute in LIFO order (reverse of acquisition).

Parallel operations (par.race, par.all) automatically cancel losing/failed branches and run their cleanup callbacks.

Code Example

import { Handler, stack } from "../typelang/mod.ts";

// 1. Cancelable HTTP request handler
const httpHandler = (): Handler => ({
  name: "Http",
  handles: {
    get: async (instr, next, ctx) => {
      const [url] = instr.args;
      // Pass ctx.signal to fetch for automatic cancellation
      return await fetch(url, { signal: ctx.signal });
    },
  },
});

// 2. File handler with cleanup
const fileHandler = (): Handler => ({
  name: "File",
  handles: {
    open: async (instr, next, ctx) => {
      const [path] = instr.args;
      const file = await Deno.open(path, { read: true });

      // Register cleanup callback (runs in LIFO order)
      ctx.onCancel(async () => {
        await file.close();
        console.log(`Cleaned up file: ${path}`);
      });

      return file;
    },
  },
});

// 3. Timer handler with cancellation
const timerHandler = (): Handler => ({
  name: "Timer",
  handles: {
    after: (instr, next, ctx) =>
      new Promise((resolve) => {
        const [ms, value] = instr.args;
        const timerId = setTimeout(() => resolve(value), ms);

        // Cleanup timer on cancellation
        ctx.onCancel(() => clearTimeout(timerId));
      }),
  },
});

// Usage: Press Ctrl-C → cleanup runs automatically
const program = () =>
  seq()
    .let(() => File.op.open("/tmp/data.txt"))
    .let(() => Timer.op.after(5000, "timeout"))
    .return((result) => result);

await stack(fileHandler(), timerHandler()).run(program);
// Ctrl-C → clearTimeout() and file.close() called in LIFO order

Key Points

  • ctx.signal provides AbortSignal for cancelable APIs (fetch, setTimeout)
  • ctx.onCancel(cleanup) registers cleanup callbacks in LIFO order
  • SIGINT/SIGTERM automatically trigger cleanup and graceful shutdown
  • par.race() cancels losers; par.all() cancels siblings on failure
  • Cleanup errors are logged but don't propagate (fail-safe)
  • 5-second default timeout prevents hung cleanup callbacks
  • Register cleanup IMMEDIATELY after resource acquisition