Accidental Functional Programming in Rust (From an Epidemiologist's Perspective)

Author

Caroline Morton

Date

February 9, 2026

This post started as a talk I gave at Lambda World 2025. It’s the kind of conference where you end up in a two-hour conversation about Scala data streams despite not knowing Scala. If you’d rather watch than read, the video is embedded below.

Side note: ignore the worryingly smiley face in the thumbnail. Someone at the conference decided to run my headshot through an AI filter to increase its pixels and I ended up looking like a Pixar character. I don’t know why they thought that was a good idea, but here we are!

I don’t have a background in functional programming. I didn’t set out to write it. If anything, I tried to avoid it.

I’d seen enough nested, convoluted, so-called clever code that was impossible to read, never mind debug, and I was determined to write things scientists could actually read.

Somewhere between writing trait-based pipelines, composing data transformations, and leaning hard on Result, enums, and pattern matching, I started hearing: “That’s pretty functional.” Some of the code I write no longer looks like the object-oriented code that got me hooked on programming in the first place.

This post is about how I got here, the patterns I’ve accidentally adopted, the trade-offs I’ve made, and why familiar and usable beats elegant when you’re building tools for scientists.

In this post I’ll cover:

  • Why Rust nudges you towards functional patterns without you trying
  • The specific patterns I keep reaching for (Result, match, enums, iterator chains)
  • Where I deliberately stop being “idiomatic” for readability
  • Why this matters more in scientific software than in product code

A bit of context

I trained as an epidemiologist, then as a GP, and now I mostly write Rust. I contract as a senior Rust developer, and I co-founded a company called Clinical Metrics that builds simulation software for training doctors to manage busy emergency rooms. We wrote the backend in Rust because we needed the performance.

But this post isn’t about that. It’s about a different project: codelist-tools, an open-source library for working with medical codelists.

Codelists are collections of clinical codes (like SNOMED or ICD-10) that represent a disease or condition. They’re fundamental to epidemiological research, and they’re a mess. They get emailed around, saved to random SharePoint folders, printed at the end of PDFs.

I’ve written about what codelists are and how to build them elsewhere. The short version is that they’re essential, but they’re handled in ways that make reproducibility and quality control far harder than they need to be.

The goal with codelist-tools is to write the core logic in Rust (strongly typed, performant, correct) and then expose it to Python and R so scientists can use it without learning a new language. One implementation, multiple interfaces, consistent behaviour. You can use it in a web app or in your analytic script.

Pipelines and patterns

This brings us to the patterns.

The patterns

None of what follows is revolutionary to functional programmers. These patterns emerged naturally from solving real problems, not from reading theory. Rust nudged me, but the user experience mattered just as much: I want scientists to be able to read the code paths (or at least trust them), and I want failure modes to be explicit.

Result as a value

Codelists can be SNOMED, ICD-10, and a bunch of other types. Each has a format and a set of rules. ICD-10, for example, is 3 to 7 characters. The first is a letter, followed by two numbers. After that it gets fiddly.

Let’s ignore the fiddly bit and just validate the length.

A common Python pattern is to return True or False:

def validate_icd10_code(code: str) -> bool:
    if not code:
        return False
    return 3 <= len(code) <= 7

Or to raise an exception:

def validate_icd10_code(code: str) -> None:
    if not code:
        raise ValueError("Code is empty")
    if len(code) < 3 or len(code) > 7:
        raise ValueError(f"Invalid length: {len(code)}")

Both can be done well. In practice, in scientific code, both are frequently done poorly. Failures get converted into None, or warnings, or silently dropped rows. It’s not malicious. It’s usually time pressure and unfamiliarity with error-handling patterns.

Here’s the Rust version:

fn validate_icd10_code(code: &str) -> Result<(), CodeListValidatorError> {
    let code_len = code.len();

    if code_len < 3 {
        return Err(CodeListValidatorError::InvalidCodeLength {
            code: code.to_string(),
            reason: "Code is less than 3 characters in length".to_string(),
            codelist_type: "ICD10".to_string(),
        });
    }

    if code_len > 7 {
        return Err(CodeListValidatorError::InvalidCodeLength {
            code: code.to_string(),
            reason: "Code is greater than 7 characters in length".to_string(),
            codelist_type: "ICD10".to_string(),
        });
    }

    Ok(())
}

Result is the only thing that can come back. The caller must handle both cases because the compiler won’t let you forget.

In scientific code, silent failure is poison. If I give you my analysis code and you run it on the same data, you should get the same results. That’s not guaranteed if validation failures can be ignored accidentally.

There’s also a social reality in research: people will read your code. Reviewers, collaborators, other researchers who want to build on your work. They’ll decide whether they trust your results based partly on whether your code looks like it has a coherent, consistent approach to errors and edge cases.

Making errors into values you must deal with, not afterthoughts you might catch, is a genuine improvement. It makes the code honest. Honest code is trustworthy code.

Pattern matching

Pattern matching for explicit branches

Once errors are values, the next question is what you do with them.

When you’re processing messy data, different errors need different responses. Some are critical: stop immediately. Some are noise: log and skip. Some need human review.

In Python, the “best practice” approach is to use different exception types and catch them explicitly:

class InvalidLength(ValueError):
    pass

class InvalidCharacters(ValueError):
    pass

def validate_code(code: str) -> None:
    if not code:
        raise ValueError("empty")
    if len(code) < 3 or len(code) > 7:
        raise InvalidLength(f"length {len(code)}")
    # pretend we also validate characters
    # raise InvalidCharacters(...) as needed

validated = []
for code in codes:
    try:
        validate_code(code)
        validated.append(code)
    except InvalidLength:
        raise
    except InvalidCharacters as e:
        logger.warning(f"Skipping {code}: {e}")

This is the correct shape. It’s explicit, stable, and it avoids parsing strings.

The issue is not that Python cannot do this. The issue is that many researchers will not. Not because they are careless, but because they are not thinking in terms of custom classes and typed exceptions. They are thinking: “I need to call this function, and if it doesn’t work, I want to know why.” There is a lot of manual investigation of what went wrong, and it’s not always clear how to structure the code to support that.

This is part of why I’m building codelist-tools the way I am. The Rust code enforces best practice: explicit error types, exhaustive handling, no silent failures. But the scientists using it via Python or R don’t need to think about any of that. They call a function, it either works or gives them a clear error message. The rigour is baked in. They get the benefit without having to write the boilerplate.

So what you often see in researcher-written Python instead is one of these:

  1. Return flags and forget to check them consistently:
def validate_code(code: str) -> bool:
    return bool(code) and (3 <= len(code) <= 7)

for code in codes:
    validate_code(code)  # result ignored
    validated.append(code)
  1. Catch a broad exception and make a judgement call based on text:
for code in codes:
    try:
        validate_code(code)
        validated.append(code)
    except Exception as e:
        if "length" in str(e):
            raise
        logger.warning(f"Skipping {code}: {e}")

That second pattern is brittle, but it shows up. It’s what happens when a team does not have deep software engineering habits, and the code is being written under time pressure by domain experts.

Rust’s pattern matching makes the “explicit branches” approach the default. You match on structured data, not strings:

for code in &codes {
    match validate_code(code, CodeListType::ICD10) {
        Ok(()) => {
            validated.push(code.clone());
        }
        Err(CodeListValidatorError::InvalidCodeLength { code, reason, codelist_type }) => {
            // Wrong length is critical. Probably mixed SNOMED into an ICD-10 list.
            return Err(CodeListValidatorError::InvalidCodeLength {
                code,
                reason,
                codelist_type,
            });
        }
        Err(CodeListValidatorError::InvalidCodeContents { code, reason, .. }) => {
            // Weird characters might be a header row. Skip and log.
            log::warn!("Skipping code with invalid contents: {} - {}", code, reason);
        }
        Err(e) => {
            log::error!("Unexpected validation error: {}", e);
        }
    }
}

Every branch is explicit. You’re not parsing error messages. You’re matching on data the compiler knows about.

This level of control matters. If your analysis takes hours to run, you do not want to discover partway through that you accidentally mixed code systems. You want to fail fast with a message that tells you what happened and what to do next.

At the same time, you don’t want to crash on every malformed row. Real data is messy. CSV files have header rows that sneak through. There are trailing commas, invisible unicode characters, codes copy-pasted from Word with unexpected quotes or whitespace. You want to log those and skip them, not stop the whole process.

Decisions

Pattern matching lets you decide what matters, explicitly:

  • success: keep it
  • certain errors: stop immediately
  • other errors: log and skip
  • unknown errors: log loudly

One more thing: if you add a new error variant to CodeListValidatorError, the compiler can force you to handle it everywhere you match on it. In scientific code, that is a feature, not a burden.

Enums for domain logic

Different coding systems have different rules. ICD-10 codes are 3 to 7 characters and can be truncated (you can chop off the end to get a broader category). SNOMED codes are 6 to 18 digits and cannot be truncated. Each code is its own thing with no hierarchical relationship to similar-looking codes.

You can scatter these rules across the codebase as if statements. Or you can encode them in types.

What are we trying to achieve?

When I write code for scientists, I want the domain logic to be obvious. Not buried in conditionals, not spread across files, not implicit in a naming convention that made sense to someone in 2014. If there’s a rule about how ICD-10 behaves differently from SNOMED, that rule should be visible, explicit, and hard to forget.

Enums give you that. They let you define a closed set of possibilities. These are the code systems we support, and here are the rules for each one:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeListType {
    Snomed,
    ICD10,
    Opcs4,
    Bnf,
}

impl CodeListType {
    /// Is the `CodeListType` able to be truncated
    pub fn is_truncatable(&self) -> bool {
        matches!(self, CodeListType::ICD10)
    }

    /// Is the `CodeListType` able to have X added
    pub fn is_x_addable(&self) -> bool {
        matches!(self, CodeListType::ICD10)
    }
}

A scientist can look at is_truncatable() and understand what’s allowed. No hunting through scattered conditionals. The rules are right there.

Why are enums powerful in Rust?

Python has enums too, but in epidemiology code I rarely see them used. Most systems I’ve inherited pass strings around and branch on them. You end up with if code_type == "icd10" scattered throughout the codebase, and inevitably someone types "ICD10" or "icd-10" and things break in ways that can be surprisingly quiet.

Rust’s enums are different in a few ways that matter:

The compiler enforces exhaustiveness. Add a new variant and the compiler forces you to handle it everywhere.

Enums can carry data. For errors, this is invaluable:

#[derive(Debug, thiserror::Error, Clone)]
pub enum CodeListValidatorError {
    #[error("Code {code} is an invalid length for type {codelist_type}. Reason: {reason}")]
    InvalidCodeLength { code: String, reason: String, codelist_type: String },

    #[error("Code {code} contents is invalid for type {codelist_type}. Reason: {reason}")]
    InvalidCodeContents { code: String, reason: String, codelist_type: String },

    #[error("Some codes in the list are invalid. Details: {reasons:?}")]
    InvalidCodelist { reasons: Vec<String> },

    #[error("CodeType {code_type} is not supported")]
    UnsupportedCodeType { code_type: String },
}

InvalidCodeLength carries the code, the reason, and the codelist type. You can match on it and produce a useful message without guessing.

Traits give you surgical inheritance. In object-oriented code, you might reach for inheritance: a base class and subclasses for each system. Inheritance is blunt. You inherit everything, even the parts you don’t want. It’s like ordering a set menu when you just wanted the soup.

Rust doesn’t have inheritance. Instead, you have traits: small, focused interfaces you implement for your types. If CodeListType needs to be displayable, you implement Display. If it needs to be serialisable, you implement Serialize. You opt into exactly what you need.

For this domain, it means I can express the rules and behaviours I care about without dragging in a hierarchy of abstract base classes.

Back to the scientists

The goal is readability through explicitness:

  • a clear list of supported code systems
  • explicit rules (is_truncatable, is_x_addable)
  • errors that say exactly what went wrong

A reader does not need to be fluent in Rust to see that the rules are encoded clearly and enforced consistently.

I’ve written more about error handling fundamentals in Rust and using anyhow and thiserror if you want to dig deeper into the error side of this.

Chaining iterators

Chaining

This is where the functional style just happens. Here’s a typical pipeline:

let valid_codes: Vec<String> = raw_codes
    .iter()
    .map(|code| code.trim())
    .filter(|code| !code.is_empty())
    .filter(|code| validate_icd10_code(code).is_ok())
    .map(|code| code.to_string())
    .collect();

We take a list of raw ICD-10 codes, trim them, drop empties, keep the valid ones, collect the results.

There are no mutable buffers. No counters. It reads like a recipe.

If you’ve used pandas or dplyr, this should feel familiar. Data flows through a series of transformations, each step doing one thing. That’s deliberate. Most epidemiologists write their analysis code in R or Python, and they’re used to this pattern.

I resisted this style at first. Not because it’s clever, but because it’s easy to push it past the point of readability. Felienne Hermans’ The Programmer’s Brain is the best explanation I’ve seen of why: working memory is limited, and each extra concept costs you. (If you haven’t read it, I’d highly recommend it. One of the few programming books that’s actually changed how I think about code.)

What surprised me is that for scientists used to pandas or dplyr, chaining is often the most readable option. They already have the mental scaffolding for “transformations in a row”.

That said, there’s still a limit. I try to keep chains short. If the chain starts introducing unfamiliar vocabulary (and_then, map_err, ok, flatten) or mixing error propagation into the pipeline, I usually stop and switch to a loop.

Here’s the kind of “more functional” Rust that I avoid in scientist-facing code:

let valid_codes: Vec<String> = raw_codes
    .iter()
    .filter_map(|code| {
        let trimmed = code.trim();
        (!trimmed.is_empty())
            .then(|| validate_icd10_code(trimmed).ok())
            .flatten()
            .map(|_| trimmed.to_string())
    })
    .collect();

It’s fewer lines, but it’s more concepts. Concepts are what fill up working memory.

When to chain, when to loop

There isn’t a correct answer, just trade-offs. Here’s where I’ve landed:

Use chains when:

  • Each step does one obvious thing (trim, filter, map)
  • The operations are familiar to your audience
  • You can keep it to four steps or fewer
  • You’re not introducing too many new concepts at once

Use loops when:

  • You need early returns or complex branching
  • Error handling would add unfamiliar vocabulary (ok(), map_err, and_then)
  • You’re returning Result with custom error types that need explicit handling
  • You’re accumulating into multiple outputs
  • You catch yourself reaching for methods your readers won’t recognise

That last point matters. It’s not just about the number of operations in a chain. It’s about the number of new words the reader has to learn.

Loops also have a pragmatic advantage: they’re easier to debug. You can stick a println! or a breakpoint anywhere. With a chain, you often have to break it apart first, which usually means rewriting it as a loop anyway. And in practice, the person debugging might not be a developer. It might be a scientist trying to work out how the library works by scattering print statements through the code. We’ve all been there.

Here’s the same validation logic as a loop:

let mut valid_codes = Vec::new();
for code in &raw_codes {
    let trimmed = code.trim();

    // Skip empty codes
    if trimmed.is_empty() {
        continue;
    }

    // Only keep codes that pass validation
    if validate_icd10_code(trimmed).is_ok() {
        valid_codes.push(trimmed.to_string());
    }
}

Yes, it’s mutable. Yes, it’s a for loop. But if, continue, and push are words every programmer recognises. A scientist can read it line by line without needing to learn iterator combinators.

Readability is the guiding light, and what’s readable depends entirely on who’s reading. For my users, familiar beats elegant. Sometimes the less functional code is the right code.

Where I’m not functional (on purpose)

Rust has a strong idiomatic style, and it’s easy to drift into writing code that is optimised for Rust programmers rather than for the people you are trying to serve.

In codelist-tools, I deliberately avoid:

  • deep method chains that look clever but confuse readers
  • error-handling combinators that require Rust fluency to parse
  • APIs that force users to understand ownership to use them
  • jargon in error messages (no “failed to unwrap Option”, just “code was empty”)

Most epidemiologists don’t think in terms of objects and class hierarchies. They think in terms of data, transformations, and outputs. The API has to meet them where they are.

The Polars inspiration

I need to talk about Polars, because it’s the project that convinced me this whole approach was viable.

Polars is a DataFrame library written in Rust with bindings to Python, R, and other languages. It’s a genuinely impressive piece of engineering: lazy evaluation, query optimisation, multi-threaded execution. But here’s what matters for this discussion: if you’ve used pandas, you’ll feel at home.

I had a Jupyter notebook doing data cleaning on a few million rows. It took about two hours to run. I’d start it, go make a cup of tea, do some emails, maybe have lunch, and eventually it would finish. I rewrote the core logic using Polars, keeping the same style of operations the team was used to, and it dropped to about five minutes.

If it had been just me, I might have been tempted to rewrite the whole thing in Rust. But I was the only software engineer on the team. Everyone else knew Python and R. They needed to be able to read the code, modify it, and trust it.

Polars gave us the performance without forcing anyone to learn a new language.

More importantly, it proved something: you can have familiar APIs backed by serious infrastructure. Scientists don’t need to learn Rust to benefit from Rust. They need good bindings and thoughtful API design.

That’s the bar I’m aiming for with codelist-tools. I’m under no illusions that it’s anywhere near as sophisticated as Polars. And like many open source projects, it’s behind where I’d like it to be. Time is short, life gets in the way, and the backlog keeps growing. But I’ve blocked out a sprint in March (taking time out from my contracting role specifically for this) to push it forward properly.

The principle is the same though: Rust underneath, familiar interface on top, and a commitment to not making users learn a new language just to get their work done.

Familiar over perfect

The goal is one entry point, obvious behaviour, hidden complexity:

# What the scientist sees (via Python binding)
codelist = load_codelist("my_codes.csv")

Under the hood:

impl CodeListFactory {
    pub fn load_codelist(path: &Path) -> Result<CodeList, CodeListError> {
        match path.extension().and_then(|e| e.to_str()) {
            Some("csv") => Self::load_from_csv(path),
            Some("json") => Self::load_from_json(path),
            _ => Err(CodeListError::UnsupportedFormat(
                path.display().to_string()
            )),
        }
    }
}

Pattern matching routes to the right loader. Validation runs automatically. If it loads, it’s valid. Guaranteed.

This is also where binding design matters. Writing Rust is only half the job. The other half is deciding what the Python-facing API should look like so it fits how scientists already work.

I use PyO3 for the Python bindings. You write Rust, add annotations, and you get a Python module that feels like a normal package. Scientists import it, call functions, and never need to care that it’s Rust underneath.

I am starting to add in R bindings as well, using a similar approach. The goal is the same: a familiar interface that hides the Rust complexity.

Fighting the compiler is the feature

Every Rust programmer knows the feeling: you can’t even run your code because the compiler won’t let you. It feels adversarial at first.

In scientific code, this is exactly what you want.

The compiler says:

  • you haven’t handled this error case
  • this pattern match isn’t exhaustive
  • what if this is None

So you add another branch. You handle another edge case. You make the failure mode explicit. Then the code runs, and it handles that edge case, and you stop shipping silent assumptions into the world.

In a startup, you might ship it and fix it later. In research, your results inform policy. People make decisions based on your analysis. Decisions about real healthcare. You want to catch edge cases before you start, not discover them three hours into an overnight job, or worse, after you’ve published.

The upfront cost of satisfying the compiler pays off. Every time.

Newcomers to Rust often say: “The compiler is so strict! It won’t even let me run my code!”. Now I find it reassuring. When the compiler finally lets my code run, I know I’ve thought about the edge cases. I might still have bugs (I’m not that arrogant) but I’ve eliminated a whole class of them.

Rust’s types catch a lot, but not everything. Tests are where I convince myself the code is doing what I think it’s doing, not what I hope it’s doing. For scientific libraries I still write tests as if someone else is going to interrogate every assumption.

Wrapping up

I didn’t set out to write functional code. I tried to avoid it. I had opinions. I thought chaining was confusing. I thought inheritance was the correct way to write complex domain logic. I thought exceptions were fine as long as you used them properly.

I was wrong.

Rust kept nudging me towards these patterns: Result instead of exceptions, explicit branching via match, enums instead of stringly-typed logic, iterators instead of loops (sometimes). And it turns out these patterns solve real problems. They make scientific code more reproducible. They make errors harder to ignore. They make domain logic explicit and checkable.

If you’re a functional programmer curious about Rust, you’ll feel at home. These patterns are ergonomic and well-supported.

If you’re a Rust programmer wondering whether you’ve been functional all along, you probably have. That’s fine. Use what works.

If you’re a scientist considering Rust, you don’t have to learn it to benefit from it. Use the bindings. Let someone else fight the compiler. Get the speed and the confidence that if it runs, it has been forced to account for a lot of the failure modes that scientific code usually leaves implicit.

And if you’re working on epidemiology tools and want to collaborate, get in touch. This space needs more people thinking carefully about code quality, and I’d love to work with others who care about getting it right.

No formal theory required. Just real code, real problems, and a pragmatic perspective from someone who changed their mind.


The code in this post is from codelist-tools, an open-source project for working with medical codelists. If you’re interested in health data research, you might also want to read about what codelists are and how to build them.

Related Posts

graph_1 yellow

Halfloop

A post-market surveillance tool for medical devices

Read More
graph_1 yellow

Barely sufficient practices in scientific computing

An article in which we propose a minimal subset of common software engineering principles that enable FAIRness of computational research and can be used as a baseline for software engineering in any research discipline.

Read More
stethoscope green

What is a Codelist?

What are codelists and why do they matter? An accessible introduction to coding systems, clinical nuance, and research reproducibility.

Read More