Home Fp For Ai Agents Article 1
Part 1 AI Agents

Why Functional Programming Is the Ideal Foundation for AI Agents

Image: AI-generated


Why Functional Programming Is the Ideal Foundation for AI Agents

How functional principles make the code that AI agents generate, call, and compose predictable and safe.


1. The Problem: Nondeterminism Meets Code

AI agents — whether ChatGPT, Claude, Copilot, or a custom agent — share a fundamental property: They are nondeterministic. The same prompt can produce different code variants.

"An LLM is nondeterministic — there is no sure or consistent output for a given input. Although the output is based on probabilities and isn't purely random, the user doesn't get to see what those probabilities are." — Jay Alammar & Maarten Grootendorst: Common Sense Guide to AI Engineering, p. 34

This means: Sometimes the agent generates a class, sometimes a function, sometimes global state. This variability is desirable in prose — in code, it's dangerous.

"An LLM may simply not listen to instructions. This is a very different experience from writing 'regular' code." — Jay Alammar & Maarten Grootendorst: Common Sense Guide to AI Engineering, p. 39

The solution: If the agent is nondeterministic, the code it calls and generates must be all the more deterministic. Functional Programming delivers exactly that.

2. Pure Functions: The Perfect Contract with AI

A pure function has two properties:

  1. Same input → same output — always
  2. No side effects — it changes nothing outside itself

For an AI agent, this means: When it calls a pure function, it can rely on the result.

❌ Impure: The Agent Cannot Reason

class TaskManagerImpure:
    _tasks: dict[str, dict] = {}
    _history: list[str] = []

    @classmethod
    def add_task(cls, task_id: str, title: str) -> dict:
        if task_id in cls._tasks:
            return {"error": f"Task {task_id} already exists"}
        task = {"id": task_id, "title": title, "done": False}
        cls._tasks[task_id] = task
        cls._history.append(f"added:{task_id}")
        return task

When the agent calls add_task("t1", "Test") twice, it gets a different result the second time — not because the function works differently, but because hidden state has changed.

✅ Pure: Predictable for Humans and Machines

@dataclass(frozen=True)
class Task:
    id: str
    title: str
    done: bool = False

@dataclass(frozen=True)
class TaskState:
    tasks: tuple[Task, ...] = ()
    history: tuple[str, ...] = ()

def add_task(state: TaskState, task_id: str, title: str
             ) -> tuple[TaskState, Task | str]:
    existing = next((t for t in state.tasks if t.id == task_id), None)
    if existing is not None:
        return state, f"Task {task_id} already exists"
    task = Task(id=task_id, title=title)
    new_state = TaskState(
        tasks=state.tasks + (task,),
        history=state.history + (f"added:{task_id}",),
    )
    return new_state, task

Now everything is explicit: The agent can see from the signature (TaskState, str, str) → (TaskState, Task | str) what goes in and what comes out. State is a parameter, not a secret.

"Referentially transparent functions are easier to test and understand in isolation. Furthermore, we can compose, call, and assemble functions that are referentially transparent without losing this property." — Chris Eidhof, Florian Kugler & Wouter Swierstra: Functional Programming in Swift, p. 80

And the crucial point: Same input → same output, always. The agent can call add_task(state, "t1", "Test") with the same state any number of times and always get the same result.

→ Full code: src/agent_pure_functions.py

3. Immutability: Protection Against Accidental Mutation

AI agents can introduce subtle bugs when they use mutable state. The classic aliasing problem becomes even more dangerous with agents:

❌ Mutable: Hidden Side Effects

def add_tag_mutable(items: list[dict], tag: str) -> list[dict]:
    for item in items:
        item["tags"] = item.get("tags", []) + [tag]
    return items

The agent calls this function and the original data gets modified. Every subsequent access to items sees the mutation — without knowing it.

✅ Immutable: Safe for Agents

@dataclass(frozen=True)
class Item:
    name: str
    tags: tuple[str, ...] = ()

def add_tag(items: tuple[Item, ...], tag: str) -> tuple[Item, ...]:
    return tuple(replace(item, tags=item.tags + (tag,)) for item in items)

The original always remains unchanged. The agent can call this function any number of times without side effects.

"A large class of software bugs boil down to one section of code modifying data in another section in an unexpected way. By making our data immutable, we can avoid this class of bugs altogether." — Stuart Sierra & Luke VanderHart: Functional Patterns, p. 27

Immutable Workflow State

The same principle applies to agent workflows: Each step produces a new state instead of mutating the old one:

@dataclass(frozen=True)
class WorkflowState:
    steps: tuple[WorkflowStep, ...] = ()
    current_index: int = 0

def complete_current_step(state: WorkflowState, result: str
                          ) -> WorkflowState:
    # ... produces new state, original remains unchanged

The agent can trace every intermediate state — and simply return to a previous state on errors.

→ Full code: src/agent_immutability.py

4. Explicit Signatures: The Language Between Human and Agent

Type signatures are especially valuable for AI agents. They serve simultaneously as documentation, contract, and machine-readable specification.

"Understanding and using type signatures is a critical part of coding." — Scott Wlaschin: Domain Modeling Made Functional, p. 72

Compare:

# Implicit: What goes in? What comes out? Can it fail?
def process(data):
    ...

# Explicit: Everything is in the signature
def process(data: TaskState) -> tuple[TaskState, Task | str]:
    ...

For an AI agent, the explicit signature is a contract: It sees exactly which types are expected and what comes back. Implicit contracts — global state, hidden exceptions, undocumented side effects — are invisible to agents.

When the agent calls tools via function calling, it needs a description: name, parameters, return type. The FP signature A → B provides exactly that — automatically.

5. The FP Checklist for AI Code

Property Why It's Good for AI Agents
Pure Functions Predictable behavior — the agent can anticipate the result
Immutability No accidental overwriting — no hidden state
Type Signatures Self-documenting tool definitions — the signature is the contract
Explicit State The agent sees all dependencies in the parameters
No Side Effects The agent can test and compose functions in isolation

The common idea:

"Programming is not about data. It's about transforming data. Every program we write takes some input and transforms it into some output." — Dave Thomas & Bruce Tate: Functional Anthology, p. 115

When code is organized as data transformation — small functions that turn input into output — an AI agent can understand, generate, test, and compose every single step.

Try It Yourself

Exactly what holds this article together can be tried in a few lines, right in the browser: a TaskState built from frozen dataclasses and a pure add_task function with the signature (state, task_id, title) → (TaskState, Task | str). No global state, no side effects — the same input always produces the same output, and the original state remains unchanged.

from dataclasses import dataclass

@dataclass(frozen=True)
class Task:
    id: str
    title: str
    done: bool = False

@dataclass(frozen=True)
class TaskState:
    tasks: tuple = ()
    history: tuple = ()

def add_task(state: TaskState, task_id: str, title: str):
    """Pure function: returns (new_state, result)."""
    if any(t.id == task_id for t in state.tasks):
        return state, f"Task {task_id} already exists"
    task = Task(id=task_id, title=title)
    new_state = TaskState(
        tasks=state.tasks + (task,),
        history=state.history + (f"added:{task_id}",),
    )
    return new_state, task

s0 = TaskState()
s1, r1 = add_task(s0, "t1", "Write spec")
s2, r2 = add_task(s1, "t2", "Make tests green")
s3, r3 = add_task(s2, "t1", "duplicate insert")  # intentional conflict

print("r1:", r1)
print("r2:", r2)
print("r3:", r3)
print("Original s0 unchanged:", s0)
print("Tasks in s2:", len(s2.tasks), "| in s3:", len(s3.tasks))

Extension idea: write a matching complete_task(state, task_id) → (TaskState, Task | str) that immutably sets the task to done=True – the signature stays the contract the agent relies on for composition.

Outlook

These five properties are not accidental — they have a formal foundation in category theory. In the next article, we show what categories, functors, and natural transformations are — and why they are relevant for AI agent architecture.


Sources

Code Examples

📄 As PDF