Home Fp Basics Python Article 5
Part 5 Functional Programming & Category Theory

Why FP and AI Agents Are a Dream Team

Image: AI-generated


Why FP and AI Agents Are a Dream Team

How functional principles make AI-generated code predictable, testable, and composable — plus a system prompt that ties it all together.


1. The Problem: AI Is Nondeterministic, Code Shouldn't Be

AI agents — whether ChatGPT, Claude, Copilot, or a custom agent — share a fundamental property: They are nondeterministic.

"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

The same prompt can produce different code variants. 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

If the AI agent is nondeterministic, the code it calls and generates must be all the more deterministic. FP delivers exactly that.

2. Pure Functions: The Perfect Contract with AI

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

❌ Impure: The Agent Cannot Reason

class UserServiceImpure:
    _users: dict[str, dict] = {}
    _audit_log: list[str] = []

    @classmethod
    def create_user(cls, name: str, email: str) -> dict:
        if name in cls._users:
            return {"error": f"User {name} already exists"}
        user = {"name": name, "email": email.lower(), "active": True}
        cls._users[name] = user
        cls._audit_log.append(f"created:{name}")
        return user

When an AI agent calls create_user("Alice", "alice@example.com") twice, it gets a different result the second time — because hidden state has changed.

✅ Pure: Predictable for Humans and Machines

@dataclass(frozen=True)
class UserState:
    users: tuple[User, ...] = ()
    audit_log: tuple[str, ...] = ()

def create_user(state: UserState, name: str, email: str
) -> tuple[UserState, User | str]:
    existing = next((u for u in state.users if u.name == name), None)
    if existing is not None:
        return state, f"User {name} already exists"
    user = User(name=name, email=email.lower())
    new_state = UserState(
        users=state.users + (user,),
        audit_log=state.audit_log + (f"created:{name}",),
    )
    return new_state, user

Everything explicit: The signature (UserState, str, str) → (UserState, User | str) shows what goes in and what comes out.

"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

→ Full code: src/ai_pure_vs_impure.py

3. Type Signatures as AI Tool Definitions

When an AI agent uses tools (function calling), it needs a description of each tool: name, parameters, return type. That is exactly what an FP function signature provides.

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

def validate_email(email: str) -> str:
    """Validates an email address and returns it normalized."""
    ...

def calculate_shipping(weight_kg: float, country: str) -> float:
    """Calculates shipping costs based on weight and destination country."""
    ...

These signatures can be automatically translated into tool definitions — directly into the JSON schema that OpenAI function calling expects. The signature A → B simultaneously serves as documentation, contract, and tool definition.

→ Full code: src/ai_tool_signatures.py

4. Immutability: Safe from Accidental Mutation

An AI agent can introduce subtle bugs when it uses mutable state:

# AI generates code that modifies a list...
def add_premium_badge(users: list[dict]) -> list[dict]:
    for user in users:
        user["badge"] = "premium"  # Mutates the original!
    return users

With immutable data structures, this is impossible:

@dataclass(frozen=True)
class User:
    name: str
    badge: str = ""

def add_premium_badge(users: tuple[User, ...]) -> tuple[User, ...]:
    return tuple(replace(u, badge="premium") for u in users)

"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

5. Composition: Small Building Blocks Instead of Monolithic Chunks

AI agents are especially good at solving small, clearly defined tasks:

"Composition is the essence of all things categorical. The key insight is that the sum of two parts is no more complex than the parts themselves." — Boris Marinov: Category Theory Illustrated, p. 29

  1. Small functions are easier to generate
  2. Small functions are easier to test
  3. Composition is mechanicalbind and map follow fixed rules

6. The Five Reasons at a Glance

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
Composition Small, testable building blocks
Result Monad Explicit error handling in the return type

"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

7. The FP System Prompt

Everything from five articles, distilled into a system prompt for AI agents:

## FP Rules for Code Generation

### Rule 1: Write Pure Functions
Same input  same output. No side effects. All dependencies as parameters.

### Rule 2: Use Immutable Data Structures
@dataclass(frozen=True), tuple instead of list. New objects instead of mutation.

### Rule 3: Use Expressive Type Signatures
Type hints for all parameters and return values. A  Result[B] for fallible operations.

### Rule 4: Compose Small Functions
Break complex logic into independently testable functions. Connect with bind, map, pipelines.

### Rule 5: Use Result Instead of Exceptions for Expected Errors
Ok/Err as return type. Exceptions only for unexpected errors.

### Rule 6: Declarative Over Imperative
map, filter, comprehensions, pattern matching instead of loops and if/else chains.

### Rule 7: Higher-Order Functions for Configuration
Configured functions with closures instead of parameter threading.

The prompt itself is modeled as an immutable data structure — of course with @dataclass(frozen=True). It is testable, composable code, not a magic string.

→ Full code: src/fp_system_prompt.py

Try it yourself

A mini tool registry as an AI agent would use it: pure functions act as tools, type signatures are translated automatically into a JSON-schema-like description, and a simulated agent call dispatches them deterministically.

import inspect
import json
from dataclasses import dataclass

# 1. Pure tools — same input → same output
def add(a: int, b: int) -> int:
    """Adds two integers."""
    return a + b

def greet(name: str, formal: bool) -> str:
    """Greets a user formally or informally."""
    return f"Good day, {name}." if formal else f"Hi, {name}!"

# 2. Automatic tool definition from the signature
PY_TO_JSON = {int: "integer", float: "number", str: "string", bool: "boolean"}

def tool_schema(fn):
    sig = inspect.signature(fn)
    params = {
        name: {"type": PY_TO_JSON.get(p.annotation, "string")}
        for name, p in sig.parameters.items()
    }
    return {
        "name": fn.__name__,
        "description": (fn.__doc__ or "").strip(),
        "parameters": {"type": "object", "properties": params,
                       "required": list(params)},
    }

REGISTRY = {fn.__name__: fn for fn in [add, greet]}

# 3. Simulated agent call (deterministic because tools are pure)
@dataclass(frozen=True)
class ToolCall:
    name: str
    args: dict

def dispatch(call: ToolCall):
    return REGISTRY[call.name](**call.args)

print(json.dumps([tool_schema(fn) for fn in REGISTRY.values()], indent=2))
print("---")
for call in [ToolCall("add", {"a": 2, "b": 3}),
             ToolCall("greet", {"name": "Alice", "formal": True}),
             ToolCall("greet", {"name": "Bob", "formal": False})]:
    print(f"{call.name}{call.args} → {dispatch(call)!r}")

Extension idea: add a third tool multiply(a: int, b: int) -> int — the registry and schema extend automatically without changing any other code.

Summary

Article Concept Tool
1 – FP Fundamentals Pure Functions, Immutability, Composition lambda, map, filter, reduce
2 – Algebraic Data Types Product Types, Sum Types, Pattern Matching @dataclass(frozen=True), match/case
3 – Functor, Applicative, Monad Transform and chain values in context Maybe, Result, Validation
4 – Shifting Complexity Pipeline instead of if/else bind, map, composition
5 – FP and AI Agents FP as foundation for AI-generated code System prompt, tool registry, code checks

The Key Insight

An AI agent is nondeterministic. The code it calls and generates should not be. Functional programming delivers predictable building blocks that an agent can reliably combine.


Going Deeper: FP & Category Theory for AI Agents

This article series laid the FP foundations. For a deeper dive — especially into the category theory behind the abstractions and how they can be concretely applied to AI agent architectures — see the follow-up series:

FP for AI Agents – Four articles on how functors, natural transformations, and monads form the universal control protocol for AI agent workflows.


Sources

Code Examples

📄 As PDF