Learn Algebraic Effects

Deep dive into typelang's effect system: defining custom effects, using built-in effects, and composing multiple effects in real programs.

~20 min Intermediate
Concept 1

What Are Algebraic Effects?

Understanding effects as capabilities tracked by types

Understanding What Are Algebraic Effects?

Effects are capabilities your program needs to execute—like logging, reading state, failing with errors, or waiting on async operations.

In typelang, effects are NOT performed directly. Instead, you create effect operations that describe what you want to do.

The runtime resolves these operations through handlers, keeping your code pure and testable.

Think of effects as 'resumable exceptions'—you can intercept them, transform them, or provide custom behavior without touching the original code.

Code Example

// Traditional imperative (hidden effects)
function processUser(id: string) {
  console.log(`Processing ${id}`);  // Hidden effect!
  const user = fetchUser(id);          // Hidden IO!
  if (!user) throw new Error("404");   // Hidden exception!
  return user;
}

// typelang (explicit effects)
const processUser = (id: string) =>
  seq()
    .do(() => Console.op.log(`Processing ${id}`))
    .let(() => fetchUser(id))
    .let((user) =>
      match(option(user), {
        None: () => Exception.op.fail({tag: "NotFound", id}),
        Some: ({value}) => value,
      })
    )
    .return((result) => result);

// Type signature reveals all effects using record-based capabilities
type ProcessUserCaps = Readonly<{
  console: typeof Console.spec;
  exception: typeof Exception.spec;
  http: typeof Http.spec;
}>;

// Explicit signature: Eff<User, ProcessUserCaps>

Key Points

  • Effects are tracked in the type signature as Eff<A, Caps>
  • A is the return value, Caps are the required capabilities (use record syntax)
  • Record-based capabilities: { console: Console; http: Http } are self-documenting
  • Operations like Console.op.log() return Eff, not void
  • Handlers interpret effects at runtime—swap handlers for different contexts
  • Your code stays pure; side effects happen in handlers
Concept 2

Defining Custom Effects

Create your own effect types with defineEffect()

Understanding Defining Custom Effects

Custom effects let you model domain-specific capabilities like logging, metrics, HTTP calls, or database access.

Use defineEffect() to declare an effect name and its operation signatures.

The type system tracks which effects your program uses—unhandled effects cause compile errors.

Effect definitions are pure data—no implementation yet. Handlers provide the implementation.

Code Example

// 1. Define the effect specification
import { defineEffect, type Eff } from "../typelang/mod.ts";

type LogLevel = "debug" | "info" | "warn" | "error";

const Logger = defineEffect<"Logger", {
  log(level: LogLevel, message: string): void;
  metric(name: string, value: number): void;
}>("Logger");

// 2. Use the effect in your program
const recordRequest = (path: string, durationMs: number) =>
  seq()
    .do(() => Logger.op.log("info", `Request: ${path}`))
    .do(() => Logger.op.metric("request.duration", durationMs))
    .return(() => ({path, durationMs}));

// 3. Type signature shows Logger capability needed
// recordRequest: (path: string, duration: number) =>
//   Eff<{path: string, durationMs: number}, Capability<"Logger", ...>>

// 4. Provide handler at runtime (next section!)

Key Points

  • defineEffect<Name, Spec>(name) creates an effect definition
  • Name is a unique string identifier for the effect
  • Spec defines operations as function signatures
  • Access operations via effect.op.operationName(...args)
  • Operations return Eff<ReturnType, Capability<Name, Spec>>
Concept 3

Built-in Effects

Console, State, Exception, Async, and more

Understanding Built-in Effects

typelang ships with 5 core effects that cover most use cases: Console, State, Exception, Async, and Env.

Console provides log(), warn(), and error() for structured logging without side effects.

State gives you get(), put(), and modify() for immutable state threading.

Exception enables fail() for typed error handling—no thrown exceptions!

Async offers sleep() and await() for async operations without exposing Promises.

Code Example

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

// Console effect
const greet = (name: string) =>
  seq()
    .do(() => Console.op.log(`Hello, ${name}!`))
    .do(() => Console.op.warn("This is a warning"))
    .return(() => `Greeted ${name}`);

// State effect
const increment = () =>
  seq()
    .let(() => State.get<{count: number}>())
    .let((current) => ({count: current.count + 1}))
    .do((next) => State.put(next))
    .return((next) => next.count);

// Exception effect
const divide = (a: number, b: number) =>
  seq()
    .when(
      () => b === 0,
      () => Exception.op.fail({tag: "DivisionByZero"}),
    )
    .return(() => a / b);

// Async effect
const delay = (ms: number, message: string) =>
  seq()
    .do(() => Async.op.sleep(ms))
    .do(() => Console.op.log(message))
    .return(() => message);

// Combine multiple effects
const complexProgram = () =>
  seq()
    .let(() => State.get<{count: number}>())
    .do((state) => Console.op.log(`Count: ${state.count}`))
    .do(() => Async.op.sleep(100))
    .let((_, ctx) => ({count: (ctx!["v1"] as any).count + 1}))
    .do((next) => State.put(next))
    .return((next) => next);

Key Points

  • Console: log(), warn(), error() for structured logging
  • State: get(), put(), modify() for immutable state
  • Exception: fail() for typed errors (no throws!)
  • Async: sleep(), await() for async operations
  • Env: getEnv() for environment variable access
  • All effects compose naturally with seq() and par()
Concept 4

Composing Multiple Effects

Combine effects naturally with seq() and par()

Understanding Composing Multiple Effects

Programs often need multiple effects: logging + state + async + error handling.

The type system tracks the union of all effects used in your program.

seq() threads effects sequentially—each step can use any effect.

par() runs effects concurrently—great for independent operations.

The runtime automatically manages the handler stack for all effects.

Code Example

// Multi-effect program demonstrating record-based capabilities
import { Eff, seq, par } from "../typelang/mod.ts";
import { Console, State, Exception, Async } from "../typelang/effects.ts";

type AppState = Readonly<{
  users: readonly User[];
  lastFetch: string;
}>;

// Multi-capability type alias: explicit, self-documenting dependencies
type UserCacheCaps = Readonly<{
  console: typeof Console.spec;
  state: ReturnType<typeof State.spec<AppState>>;
  exception: typeof Exception.spec;
  async: typeof Async.spec;
}>;

// Explicit type signature shows all required capabilities at a glance
const fetchAndCacheUsers = (): Eff<readonly User[], UserCacheCaps> =>
  seq()
    // Console effect
    .do(() => Console.op.log("Fetching users..."))

    // Async effect (parallel fetch)
    .let(() =>
      par.all({
        active: () => fetchActiveUsers(),
        inactive: () => fetchInactiveUsers(),
      })
    )

    // State effect (update cache)
    .let((users) => [...users.active, ...users.inactive])
    .do((allUsers) =>
      State.put<AppState>({
        users: allUsers,
        lastFetch: new Date().toISOString(),
      })
    )

    // Exception effect (validate)
    .when(
      (allUsers) => allUsers.length === 0,
      () => Exception.op.fail({tag: "NoUsers"}),
    )

    // Console effect (success)
    .do((allUsers) =>
      Console.op.log(`Cached ${allUsers.length} users`)
    )

    .return((allUsers) => allUsers);

// Benefits of record-based capabilities:
// ✅ Order-independent ({ console, state } = { state, console })
// ✅ Self-documenting (see dependencies without looking up types)
// ✅ No type explosion (no need for ConsoleAndState, ConsoleStateAndAsync, etc.)
// ✅ Compiler-enforced (missing caps = type error)

Key Points

  • Record-based capabilities: Eff<A, { console: Console; state: State }>
  • Order-independent, self-documenting—no need to look up composite types
  • seq() allows any effect in any step
  • par() runs effects concurrently when possible
  • Handlers resolve effects in order—last registered wins
  • No limit on how many effects you can combine