Functional Programming Increases LLM Signal

James Phoenix
James Phoenix

Errors as values, typed composition, and explicit effects give LLMs complete signal on what code actually does.


The Core Insight

Traditional exception-based code hides information from LLMs. When errors are implicit, the model can’t know what might fail or how to handle it.

Functional programming patterns expose this signal explicitly.


Effect.ts: Haskell-Level FP in TypeScript

Effect brings typed, composable effects to TypeScript:

  • Everything becomes a typed composable dynamic graph
  • LLMs have complete signal on errors AND values
  • Similar to Rust/Go/Haskell where errors are values in the function signature

The Problem: Implicit Exceptions

# Standard Python - errors are IMPLICIT
def do_something(x):
    try:
        if x < 0:
            raise ValueError("x must be >= 0")
        return x * 2
    except Exception as e:
        return e

What the LLM sees: A function that takes x and returns… something. The exception handling is invisible in the signature.

What’s hidden:

  • This function can fail
  • It fails with ValueError specifically
  • The caller has no typed signal about failure modes

The Solution: Errors as Values

# Errors as values - errors are EXPLICIT
def do_something(x) -> tuple[Exception | None, int | None]:
    if x < 0:
        return ValueError("x must be >= 0"), None
    return None, x * 2

What the LLM sees: A function that returns either an error OR a value. The signature tells the complete story.

What’s explicit:

  • Function can fail (return type includes Exception)
  • Success case returns int
  • Caller MUST handle both cases

Effect.ts Examples

Traditional TypeScript (Hidden Errors)

// Implicit failures - LLM has no signal
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error("Failed to fetch user");
  }
  return response.json();
}

// Caller has no idea this can fail
const user = await fetchUser("123");

Effect TypeScript (Explicit Errors)

import { Effect, Context } from "effect";

// Errors are in the type signature
class FetchError {
  readonly _tag = "FetchError";
  constructor(readonly message: string) {}
}

class ParseError {
  readonly _tag = "ParseError";
  constructor(readonly message: string) {}
}

// Effect<Success, Error, Requirements>
const fetchUser = (id: string): Effect.Effect<User, FetchError | ParseError> =>
  Effect.tryPromise({
    try: () => fetch(`/api/users/${id}`),
    catch: () => new FetchError("Network request failed"),
  }).pipe(
    Effect.flatMap((response) =>
      response.ok
        ? Effect.tryPromise({
            try: () => response.json() as Promise<User>,
            catch: () => new ParseError("Invalid JSON"),
          })
        : Effect.fail(new FetchError(`HTTP ${response.status}`))
    )
  );

// LLM knows EXACTLY what can fail
// Effect<User, FetchError | ParseError, never>

Why This Matters for LLMs

Aspect Implicit Exceptions Errors as Values
Type signature Lies about what happens Complete truth
Failure modes Hidden in implementation Visible in types
Composition Try/catch spaghetti Typed pipelines
LLM comprehension Must read all code Signature is enough

The Composability Advantage

Effect makes everything a typed, composable graph:

// Each step's errors are tracked and composed
const program = pipe(
  fetchUser(userId),           // Effect<User, FetchError | ParseError>
  Effect.flatMap(validateUser), // Effect<ValidUser, FetchError | ParseError | ValidationError>
  Effect.flatMap(saveToDb),     // Effect<void, FetchError | ParseError | ValidationError | DbError>
  Effect.catchTag("DbError", handleDbError), // Handle specific error
);

// Final type tells the COMPLETE story:
// Effect<void, FetchError | ParseError | ValidationError>

An LLM reading this code knows:

  1. What operations happen
  2. What can fail at each step
  3. Which errors are handled vs propagated
  4. The exact requirements and outputs

Go/Rust Pattern in TypeScript

The same principle, simpler syntax:

// Result type (like Rust's Result<T, E>)
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function divide(a: number, b: number): Result<number, "DivisionByZero"> {
  if (b === 0) {
    return { ok: false, error: "DivisionByZero" };
  }
  return { ok: true, value: a / b };
}

// Caller MUST handle both cases
const result = divide(10, 0);
if (!result.ok) {
  console.error(result.error); // TypeScript knows this is "DivisionByZero"
} else {
  console.log(result.value); // TypeScript knows this is number
}

Key Takeaway

When errors are values in the type signature, LLMs have complete signal. They don’t need to trace through implementations to understand what can fail.

Increased signal → Better LLM code generation → Fewer bugs

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course

Related


Resources

  • Effect.ts – Full FP effect system for TypeScript
  • neverthrow – Lighter Result type for TypeScript
  • ts-results – Rust-like Result/Option types
Topics
Error HandlingFunctional ProgrammingLlm SignalTyped CompositionTypescript

More Insights

Cover Image for Thought Leaders

Thought Leaders

People to follow for compound engineering, context engineering, and AI agent development.

James Phoenix
James Phoenix
Cover Image for Systems Thinking & Observability

Systems Thinking & Observability

Software should be treated as a measurable dynamical system, not as a collection of features.

James Phoenix
James Phoenix