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
ValueErrorspecifically - 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:
- What operations happen
- What can fail at each step
- Which errors are handled vs propagated
- 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
Related
- 12 Factor Agents – Factor 4: Tools Are Just Structured Outputs
- Context-Efficient Backpressure
- Systems Thinking
Resources
- Effect.ts – Full FP effect system for TypeScript
- neverthrow – Lighter Result type for TypeScript
- ts-results – Rust-like Result/Option types

