Jul 22, 2025

·

reflections-on-haskell-and-rust

Reflections on Haskell and Rust

Sibi Prabakaran

Introduction

For most of my professional experience, I have been writing production code in both Haskell and Rust, primarily focusing on web services, APIs, and HTTP stack development. My journey started with Haskell, followed by working with Rust, and most recently returning to the Haskell ecosystem.

This experience has given me perspective on both languages' strengths and limitations in real-world applications. Each language has aspects that I appreciate and miss when working with the other. This post examines the features and characteristics that stand out to me in each language.

Variable shadowing

Rust's ability to shadow variables seamlessly is something I came to appreciate. In Rust, you can write:

let config = load_config();
let config = validate_config(config)?;
let config = merge_defaults(config);

This pattern is common and encouraged in Rust, making code more readable by avoiding the need for intermediate variable names. In Haskell, you would typically need different names:

config <- loadConfig
config' <- validateConfig config
config'' <- mergeDefaults config'

Haskell's approach is slightly harder to read, while Rust's shadowing makes transformation pipelines more natural.

Sum types of records

Rust's enum system, particularly when combined with pattern matching, feels more robust than Haskell's sum types of records. When defining sum types of records in Haskell, there is a possibility of introducing partial record accessors which can cause runtime crashes, though recent versions of GHC now produce compile-time warnings for this pattern:

data Person = Student { name :: String, university :: String }
            | Worker { name :: String, company :: String }

-- This can crash if applied to a Student
getCompany :: Person -> String
getCompany p = company p  -- runtime error for Student

-- Safe approach requires pattern matching
getCompany :: Person -> Maybe String
getCompany (Worker _ c) = Just c
getCompany (Student _ _) = Nothing

Rust eliminates this class of errors by design:

enum Person {
    Student { name: String, university: String },
    Worker { name: String, company: String },
}

fn get_company(person: &Person) -> Option<&str> {
    match person {
        Person::Worker { company, .. } => Some(company),
        Person::Student { .. } => None,
    }
}

Enum variant namespacing

Rust allows multiple enum types to have the same variant names within the same module, while Haskell's constructor names must be unique within their scope. This leads to different patterns in the two languages.

In Rust, you can define:

enum HttpStatus {
    Success,
    Error,
}

enum DatabaseOperation {
    Success,
    Error,
}

// Usage is clear due to explicit namespacing
fn handle_request() {
    let http_result = HttpStatus::Success;
    let db_result = DatabaseOperation::Error;
}

The same approach in Haskell would cause a compile error:

-- This won't compile - duplicate constructor names
data HttpStatus = Success | Error
data DatabaseOperation = Success | Error  -- Error: duplicate constructors

In Haskell, you need unique constructor names:

data HttpStatus = HttpSuccess | HttpError
data DatabaseOperation = DbSuccess | DbError

-- Usage
handleRequest = do
    let httpResult = HttpSuccess
    let dbResult =

Alternatively, Haskell developers often use qualified imports syntax to achieve similar namespacing:

-- Using modules for namespacing
module Http where
data Status = Success | Error

module Database where
data Operation = Success | Error

-- Usage with qualified imports
import qualified Http
import qualified Database

handleRequest = do
    let httpResult = Http.Success
    let dbResult = Database.

The Typename::Variant syntax in Rust makes the intent clearer at the usage site. You immediately know which enum type you're working with, while Haskell's approach can sometimes require additional context or prefixing to achieve the same clarity.

Struct field visibility

Rust provides granular visibility control for struct fields, allowing you to expose only specific fields while keeping others private. This fine-grained control is built into the language:

pub struct User {
    pub name: String,        // publicly accessible
    pub email: String,       // publicly accessible
    created_at: DateTime,    // private field
    password_hash: String,   // private field
}

impl User {
    pub fn new(name: String, email: String, password: String) -> Self {
        User {
            name,
            email,
            created_at: Utc::now(),
            password_hash: hash_password(password),
        }
    }

    // Controlled access to private field
    pub fn created_at(&self) -> DateTime {
        self.created_at
    }
}

Rust offers even more granular visibility control beyond simple pub and private fields. You can specify exactly where a field should be accessible:

pub struct Config {
    pub name: String,              // accessible everywhere
    pub(crate) internal_id: u64,   // accessible within this crate only
    pub(super) parent_ref: String, // accessible to parent module only
    pub(in crate::utils) debug_info: String, // accessible within utils module only
    private_key: String,           // private to this module
}

These granular visibility modifiers (pub(crate), pub(super), pub(in path)) allow you to create sophisticated access patterns that match your module hierarchy and architectural boundaries. Some of these patterns are simply not possible to replicate in Haskell's module system.

In Haskell, record field visibility is controlled at the type level, not the field level. This means you typically either export all of a record's fields at once (using ..) or none of them. To achieve similar granular control, you need to use more awkward patterns:

-- All fields exported - no control
module User
  ( User(..)  -- exports User constructor and all fields
  ) where

data User = User
  { name :: String
  , email :: String
  , createdAt :: UTCTime
  , passwordHash :: String
  }

-- Or no fields exported, requiring accessor functions
module User
  ( User        -- only type constructor exported
  , mkUser      -- smart constructor
  , userName    -- accessor for name
  , userEmail   -- accessor for email
  , userCreatedAt -- accessor for createdAt
  -- passwordHash intentionally not exported
  ) where

data User = User
  { name :: String
  , email :: String
  , createdAt :: UTCTime
  , passwordHash :: String
  }

mkUser :: String -> String -> String -> IO User
mkUser name email password = do
  now <- getCurrentTime
  hash <- hashPassword password
  return $ User name email now hash

userName :: User -> String
userName = name

userEmail :: User -> String
userEmail = email

userCreatedAt :: User -> UTCTime
userCreatedAt = createdAt

The Haskell approach requires writing boilerplate accessor functions and losing the convenient record syntax for the fields you want to keep private. Rust's per-field visibility eliminates this awkwardness while maintaining the benefits of direct field access for public fields.

Purity and Referential Transparency

One of Haskell's most significant strengths is its commitment to purity. Pure functions, which have no side effects, are easier to reason about, test, and debug. Referential transparency—the principle that a function call can be replaced by its resulting value without changing the program's behavior—is a direct benefit of this purity.

In Haskell, the type system explicitly tracks effects through monads like IO, making it clear which parts of the code interact with the outside world. This separation of pure and impure code is a powerful tool for building reliable software.

-- Pure function: predictable and testable
add :: Int -> Int -> Int
add x y = x + y

-- Impure action: clearly marked by the IO type
printAndAdd :: Int -> Int -> IO Int
printAndAdd x y = do
  putStrLn "Adding numbers"
  return (x + y

While Rust encourages a similar separation of concerns, it does not enforce it at the language level in the same way. A function in Rust can perform I/O or mutate state without any explicit indication in its type signature (beyond &mut for mutable borrows). This means that while you can write pure functions in Rust, the language doesn't provide the same strong guarantees as Haskell.

// This function looks pure, but isn't
fn add_and_print(x: i32, y: i32) -> i32 {
    println!("Adding numbers"); // side effect
    x + y
}

This lack of enforced purity in Rust means you lose some of the strong reasoning and refactoring guarantees that are a hallmark of Haskell development.

Error handling

Rust's explicit error handling through Result<T, E> removes the cognitive overhead of exceptions. Compare these approaches:

Haskell (with potential exceptions):

parseConfig :: FilePath -> IO Config
parseConfig path = do
    content <- readFile path  -- can throw IOException
    case parseJSON content of  -- direct error handling
        Left err -> throwIO (ConfigParseError err)
        Right config -> return config

Rust (explicit throughout):

fn parse_config(path: &str) -> Result<Config, ConfigError> {
    let content = std::fs::read_to_string(path)?;
    let config = serde_json::from_str(&content)?;
    Ok(config)
}

In Rust, the ? operator makes error propagation clean while keeping the flow clear.

Unit tests as part of source code

Rust's built-in support for unit tests within the same file as the code being tested is convenient:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }
}

In Haskell, tests are typically in separate files:

-- src/Math.hs
add :: Int -> Int -> Int
add a b = a + b

-- test/MathSpec.hs
import Math
import Test.Hspec

spec :: Spec
spec = describe "add" $
    it "adds two numbers" $
        add 2 3 `shouldBe` 5

Rust's co-location makes tests harder to forget and easier to maintain. Additionally, in Haskell you often need to export internal types and functions from your modules just to make them accessible for testing, which can pollute your public API.

-- src/Parser.hs
module Parser
  ( parseConfig
  , InternalState(..)  -- exported only for testing
  , validateToken      -- exported only for testing
  ) where

data InternalState = InternalState { ... }

validateToken :: Token -> Bool
validateToken = ...  -- internal function we want to test

Rust's #[cfg(test)] attribute means test code has access to private functions and types without exposing them publicly.

Standard formatting

Rust's rustfmt provides a standard formatting tool that the entire community has adopted:

$ cargo fmt  # formats entire project consistently

In Haskell, while we have excellent tools like fourmolu and ormolu, the lack of a single standard has led to configuration debates:

-- Which style?
function :: Int -> Int -> Int -> IO Int
function arg1 arg2 arg3 =
    someComputation arg1 arg2 arg3

-- Or:
function ::
	   Int
	-> Int
	-> Int
	-> IO Int
function arg1 arg2 arg3 =
    someComputation arg1 arg2 arg3

I have witnessed significant time spent on style discussions when team members are reluctant to adopt formatting tools.

Language server support

While Haskell Language Server (HLS) has improved significantly, it still struggles with larger projects. Basic functionality like variable renaming can fail in certain scenarios, particularly in Template Haskell heavy codebases.

rust-analyzer provides a more reliable experience across different project sizes, with features like "go to definition" working consistently even in large monorepos. One feature I'm particularly fond of is rust-analyzer's ability to jump into the definitions of standard library functions and external dependencies. This seamless navigation into library code is something I miss when using HLS, even on smaller projects, where such functionality is not currently possible.

Another feature I extensively use in rust-analyzer is the ability to run tests inline directly from the editor. This functionality is currently missing in HLS, though there is an open issue tracking this feature request.

Compilation time

Despite Rust's reputation for slow compilation, I've found it consistently faster than Haskell for equivalent services. The Rust team has made significant efforts to optimize the compiler over the years, and these improvements are noticeable in practice. In contrast, Haskell compilation times have remained slow, and newer GHC versions unfortunately don't seem to provide meaningful improvements in this area.

Interactive development experience

I appreciate Haskell's REPL (Read-eval-print loop) for rapid prototyping and experimentation. Not having a native REPL in Rust noticeably slows down development when you need to try things out quickly or explore library APIs interactively. In GHCi, you can load your existing codebase and experiment around it, making it easy to test functions, try different inputs, and explore how your code behaves.

As an alternative, I have been using org babel in rustic mode for interactive Rust development. While this provides some level of interactivity within Emacs, it feels more like a band-aid than an actual solution for quickly experimenting with code. The workflow is more cumbersome compared to the direct approach of typing expressions directly into GHCi and seeing results instantly.