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 }
getCompany :: Person -> String
getCompany p = company p
getCompany :: Person -> Maybe String
getCompany (Worker _ c) = Just c
getCompany (Student _ _) = NothingRust 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,
}
fn handle_request() {
let http_result = HttpStatus::Success;
let db_result = DatabaseOperation::Error;
}The same approach in Haskell would cause a compile error:
data HttpStatus = Success | Error
data DatabaseOperation = Success | Error
In Haskell, you need unique constructor names:
data HttpStatus = HttpSuccess | HttpError
data DatabaseOperation = DbSuccess | DbError
handleRequest = do
let httpResult = HttpSuccess
let dbResult =
Alternatively, Haskell developers often use qualified imports syntax to achieve similar namespacing:
module Http where
data Status = Success | Error
module Database where
data Operation = Success | Error
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,
pub email: String,
created_at: DateTime,
password_hash: String,
}
impl User {
pub fn new(name: String, email: String, password: String) -> Self {
User {
name,
email,
created_at: Utc::now(),
password_hash: hash_password(password),
}
}
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,
pub(crate) internal_id: u64,
pub(super) parent_ref: String,
pub(in crate::utils) debug_info: String,
private_key: String,
}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:
module User
( User(..)
) where
data User = User
{ name :: String
, email :: String
, createdAt :: UTCTime
, passwordHash :: String
}
module User
( User
, mkUser
, userName
, userEmail
, userCreatedAt
) 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 = createdAtThe 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.
add :: Int -> Int -> Int
add x y = x + y
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.
fn add_and_print(x: i32, y: i32) -> i32 {
println!("Adding numbers");
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
case parseJSON content of
Left err -> throwIO (ConfigParseError err)
Right config -> return configRust (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:
add :: Int -> Int -> Int
add a b = a + b
import Math
import Test.Hspec
spec :: Spec
spec = describe "add" $
it "adds two numbers" $
add 2 3 `shouldBe` 5Rust'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.
module Parser
( parseConfig
, InternalState(..)
, validateToken
) where
data InternalState = InternalState { ... }
validateToken :: Token -> Bool
validateToken = ... 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:
In Haskell, while we have excellent tools like fourmolu and ormolu, the lack of a single standard has led to configuration debates:
function :: Int -> Int -> Int -> IO Int
function arg1 arg2 arg3 =
someComputation arg1 arg2 arg3
function ::
Int
-> Int
-> Int
-> IO Int
function arg1 arg2 arg3 =
someComputation arg1 arg2 arg3I 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.