Prompting with Type Systems: How Algebraic FP Keeps LLMs Honest

As AI tools continue to take over the mechanical labor of typing out syntax, software engineering jobs aren’t disappearing— they are shifting up the abstraction ladder. Moving away from procedural recipe writers and towards software design isn’t a bad thing; creating domain boundaries, rigorous type signatures, and sound logical invariants is clearly more rewarding than fixing syntax errors anyway.

I’ve found that constraining an LLM with functional programming principles and static type systems has been very useful. The prompt becomes the contract, the types define useful guardrails and the compiler acts as judge. The end result is a more reliable code generation practice. It nudges the LLM away from doing something procedurally, and instead focus on an algebraic equation.

I recently had to build a simple proxy service to translate an incoming token. Given an incoming OAuth token, this proxy must validate it, fetch or create a user in a local database (with cache support), and issue a downstream token.

If you ask an LLM to build this imperatively, it will write a sequence of steps. If a cache read fails or the JWT signature is invalid, the control flow is likely to explode.

Instead, we start by providing the LLM with explicit sum types for errors and a pure signature.

``` // Explicit Sum Type for Errors - No hidden runtime exceptions #[derive(Debug, PartialEq)] pub enum TokenError { InvalidToken(String), CacheFailure(String), DatabaseFailure(String), TokenGenerationFailure, }

// Product Types for Domain Data pub struct IncomingToken(pub String); pub struct UserClaims { pub user_id: String, pub email: String } pub struct User { pub id: String, pub role: String } pub struct ExchangedToken { pub token: String, pub expires_at: u64 } ```

Next, we establish the type signatures for each stage of the pipeline. In Rust, the Result<T, E> type acts as our Either monad. Every operation must return an explicit success or failure payload:

``` pub trait TokenExchangerPipeline { fn parse_token(&self, token: &IncomingToken) -> Result<UserClaims, TokenError>; fn find_or_create_user(&self, claims: &UserClaims) -> Result<User, TokenError>; fn issue_downstream_token(&self, user: &User) -> Result<ExchangedToken, TokenError>; } ```

Because every step yields a Result, the LLM can now be instructed to compose the entire workflow using monadic chaining (and_then), aka a Kleisei Arrow.

``` pub fn exchange_token<P: TokenExchangerPipeline>( pipeline: &P, token: IncomingToken, ) -> Result<ExchangedToken, TokenError> { pipeline .parse_token(&token) .and_then(|claims| pipeline.find_or_create_user(&claims)) .and_then(|user| pipeline.issue_downstream_token(&user)) } ```

Look at what happened to the state space:

Zero Leaky Failure Paths: The LLM cannot accidentally pass an unvalidated UserClaims struct into find_or_create_user, because parse_token wraps its output in a Result. The type checker won’t let it compile otherwise.

Deterministic Output: The compiler acts as an automated reviewer. If the LLM tries to hallucinate a shortcut or skip an error branch, rustc rejects the code instantly.

Refactoring Freedom: The underlying implementations (hitting Redis, querying PostgreSQL, calling ring/jsonwebtoken) can change entirely without altering the composed logic of exchange_token.

By treating code generation as a problem of type algebra rather than procedural instructions, we leverage the one thing LLMs are surprisingly good at: adhering to rigid, highly structured patterns.