I Keep Getting Confused By let rec

Written by ettolrach, 2026-10-19.

Clickbait heading: let rec Considered Harmful

No LLMs were used to write this blog post.

One habit of mine (which many could argue is a poor habit) is to use several let bindings after one another which share the same name. Sometimes I do this to keep a better overview of a massively chained expression, and other times it's a handy way to refactor code by adding in an extra step in the middle of an existing function.

Languages like Rust let me do this. Take the below code, for example.

use std::path::PathBuf;
fn get_dirs() -> Vec<PathBuf> {
    // collect directories somehow.
}
fn main() {
    let dirs = get_dirs();
    let dirs = dirs.into_iter().filter(|d| d != "." && d != "..").collect::<Vec<_>>();
    // do something with `dirs`.
    println!("{dirs:?}");
}

Since Rust is the language I use most often, when I write the occasional Haskell, it ends up looking a little Rust-like. Suppose we wanted to write the above code in Haskell. My first attempt would look like this:

module Main where
import System.FilePath (FilePath)
getDirs :: [FilePath]
getDirs = -- collect directories somehow.
main :: IO ()
main = do
  let dirs = getDirs
  let dirs = filter (\d -> d /= "." && d /= "..") dirs
  print dirs

Perfect! So let's try to run it!

thing: Uncaught exception ghc-internal:GHC.Internal.Control.Exception.Base.NonTermination:

<<loop>>

Oh right. Haskell has let rec.

For those who haven't heard of it before, it's the short name for recursive let bindings. In Haskell, like in Rust, we want to be concise and want to refer to the same thing in a shorter way. That's why we invented let bindings (depending on who you ask)! In Haskell, we can also bind whole functions, including recursive functions. For example:

module Main where
main :: IO ()
main = do
  let repeatChar = \i c -> if i == 0 then "" else c : repeatChar (i - 1) c
  putStrLn $ repeatChar 7 'q' -- prints: qqqqqqq

We can't write the same code in Rust (let's ignore the inefficient use of String and +):

fn main() {
    let repeat_chars = |i, c| {
        if i == 0 { String::new() } else { String::from(c) + &repeat_chars(i - 1, c) }
    };
    print!("{}", repeat_chars(7, 'q'));
}

The name of the variable isn't bound in the body of the let binding. We get the following error:

error[E0425]: cannot find function `repeat_chars` in this scope
 --> thing.rs:3:63
  |
3 |         if i == 0 { String::new() } else { String::from(c) + &repeat_chars(i - 1, c) }
  |                                                               ^^^^^^^^^^^^ not found in this scope

Even if Rust eventually gets let rec/recursive closures, we wouldn't have the same problem that I had with dirs where I accidentally created a recursive function. I would get a type error, since I would be trying to filter a function. I would only have an issue if I wrote brackets to call the closure recursively dirs().

Other languages require you to write include the rec keyword. For example, in OCaml, we would write let rec repeat_chars = (* etc. *) which also prevents accidental usage.

… I don't really have a bigger point or statement to make. It's just annoyed me enough that I needed to let it out somehow. I suppose this is a sign that I need to write more Haskell to get used to it.