PONY λ M2 Modula-2

Haskell.CodeCompared.To/Rust

An interactive executable cheatsheet comparing Haskell and Rust

GHC 9.12 Rust 1.95
Output & Basics
Hello, World
main :: IO () main = putStrLn "Hello, World!"
fn main() { println!("Hello, World!"); }
Haskell requires a named main :: IO () entry point that the runtime calls once. Rust requires the same shape, fn main() { ... } — both languages agree that a program needs exactly one designated entry point, unlike the top-level-script style this site's F#/Roc/ReScript pages use.
Sequencing multiple outputs
main :: IO () main = do putStrLn "First" putStrLn "Second" putStrLn "Third"
fn main() { println!("First"); println!("Second"); println!("Third"); }
Haskell needs do-notation to sequence IO actions, since it desugars to chained monadic binds. Rust is strict, so statements inside main simply execute one after another in the order written — no monad or block syntax required for plain sequencing.
Printing a value for inspection
main :: IO () main = print (Just [1, 2, 3])
fn main() { let value: Option<Vec<i32>> = Some(vec![1, 2, 3]); println!("{:?}", value); }
Haskell's print uses the Show typeclass, deriving a textual representation for (almost) any value automatically. Rust's {:?} debug formatter relies on the Debug trait, which standard types implement out of the box — the output Some([1, 2, 3]) needs no extra formatting call, much like Haskell's derived Show.
Values & Bindings
let is immutable in both languages by default
main :: IO () main = let radius = 5.0 area = pi * radius * radius in print area
fn main() { let radius: f64 = 5.0; let area = std::f64::consts::PI * radius * radius; println!("{}", area); }
Haskell's let ... in bindings and Rust's let both bind an immutable name once by default — reassigning a plain Rust let is a compile error, matching Haskell's single-assignment discipline exactly, unlike Rust's separate let mut (next row).
Opting in to mutation with mut
import Data.IORef main :: IO () main = do ref <- newIORef 0 modifyIORef ref (+1) value <- readIORef ref print value
fn main() { let mut count = 0; count += 1; println!("{count}"); }
Haskell reaches for IORef and threads it explicitly through IO with newIORef/modifyIORef/readIORef. Rust's mut keyword offers a plain, in-place mutable local variable directly — no wrapper type, no monad required — and Rust's borrow checker (see the Ownership section) further guarantees that no other alias to count can exist while it is being mutated, a guarantee Haskell's IORef does not provide at all.
Types & Inference
Local inference feels familiar; signatures do not
double :: Int -> Int double x = x * 2 main :: IO () main = print (double 21)
fn double(x: i32) -> i32 { x * 2 } fn main() { println!("{}", double(21)); }
Rust infers the type of local values the way Haskell does, so everyday code reads almost as unannotated as Haskell. The difference surfaces at function boundaries: Rust parameter types and return types are always explicit, the one place Haskell's inference goes further than Rust's.
Generic functions
firstOf :: [a] -> Maybe a firstOf [] = Nothing firstOf (x:_) = Just x main :: IO () main = print (firstOf [1, 2, 3])
fn first_of<T: Clone>(list: &[T]) -> Option<T> { list.first().cloned() } fn main() { println!("{:?}", first_of(&[1, 2, 3])); }
Haskell's lowercase type variable a and Rust's angle-bracketed <T> both introduce a generic type parameter, but Rust requires it to be written explicitly and — since Rust has no garbage collector — commonly requires a trait bound like Clone to specify how the generic value may be handled, something Haskell's GC-backed sharing model never needs to think about at all.
Strings
String concatenation
main :: IO () main = putStrLn ("Hello, " ++ "World!")
fn main() { println!("{}", "Hello, ".to_string() + "World!"); }
Haskell's ++ is the general list-concatenation operator, since a String is really [Char]. Rust's + concatenates an owned String with a borrowed &str, one of the first places a Haskeller runs into Rust's owned-versus-borrowed string distinction, which has no Haskell parallel at all.
String interpolation
import Text.Printf (printf) main :: IO () main = do let name = "Ada" age = 36 :: Int printf "%s is %d\n" name age
fn main() { let name = "Ada"; let age = 36; println!("{name} is {age}"); }
Haskell has no native interpolation — the idiomatic route is Text.Printf with positional format specifiers. Rust's println! macro supports captured identifiers directly inside { } placeholders (since Rust 2021), closer to Python f-strings or Ruby's #{}, and needs no import.
A string is a list of characters — or two genuinely distinct types
main :: IO () main = print (length "hello")
fn main() { println!("{}", "hello".chars().count()); }
Haskell's String is literally a type alias for [Char], so ordinary list functions like length apply directly and count characters. Rust splits strings into two distinct types — the owned, growable String and the borrowed, fixed &str — and neither is a character list: .len() counts UTF-8 BYTES, not characters, so counting actual characters requires the explicit .chars().count() shown here, a genuine trap for anyone assuming .len() means what Haskell's length means.
Collections
Vec literals
main :: IO () main = print [1, 2, 3, 4, 5]
fn main() { println!("{:?}", vec![1, 2, 3, 4, 5]); }
Haskell's bracket literal [1, 2, 3] becomes Rust's vec![1, 2, 3] macro call — Rust has no dedicated list-literal syntax the way Haskell does. Under the hood the representations differ completely: a Haskell list is a lazy, singly-linked chain of cons cells, while Rust's Vec is contiguous, heap-allocated, and growable, matching most mainstream languages rather than the functional-language linked-list convention.
map and filter
main :: IO () main = let doubled = map (* 2) [1, 2, 3, 4, 5] evens = filter even doubled in print evens
fn main() { let doubled: Vec<i32> = vec![1, 2, 3, 4, 5].iter().map(|n| n * 2).collect(); let evens: Vec<i32> = doubled.iter().filter(|n| *n % 2 == 0).cloned().collect(); println!("{:?}", evens); }
Both languages call these operations map and filter. Rust routes them through the Iterator trait, requiring .iter() to enter iterator-land and a terminal .collect() to leave it and materialize a concrete Vec again; Haskell's lists are already the collection, so map/filter apply directly with no separate iterator representation to enter or exit.
Folding a collection
main :: IO () main = print (foldl (+) 0 [1, 2, 3, 4, 5])
fn main() { let total = vec![1, 2, 3, 4, 5].iter().fold(0, |accumulator, n| accumulator + n); println!("{total}"); }
Haskell's foldl and Rust's .fold take the same pieces — initial accumulator, then a combining function — in the same order, and even reuse the same name, one of the closer standard-library vocabulary matches on the site.
Key/value maps
import qualified Data.Map as Map main :: IO () main = let ages = Map.fromList [("Ada", 36), ("Alan", 41)] in print (Map.lookup "Ada" ages)
use std::collections::HashMap; fn main() { let ages = HashMap::from([("Ada", 36), ("Alan", 41)]); println!("{:?}", ages.get("Ada")); }
Both languages provide a map as a first-class collection: Haskell's Data.Map (usually imported qualified, ordered by key) and Rust's HashMap (unordered, needing an explicit use). Both a missing-key Map.lookup and Rust's .get return an optional value rather than throwing/panicking on a missing key.
Maybe/Either vs. Option/Result
Maybe becomes Option — one of the closest matches on the site
findUser :: Int -> Maybe String findUser 1 = Just "Ada" findUser _ = Nothing main :: IO () main = print (findUser 1)
fn find_user(id: i32) -> Option<String> { if id == 1 { Some(String::from("Ada")) } else { None } } fn main() { println!("{:?}", find_user(1)); }
Haskell's Maybe a (Just x/Nothing) and Rust's Option<T> (Some(x)/None) are essentially the same type with renamed constructors — both replace null with an explicit, compiler-enforced "value or absence" type, and both are the idiomatic default in their respective languages, not a niche escape hatch.
Either becomes Result — same shape, argument order flips
divide :: Int -> Int -> Either String Int divide _ 0 = Left "divide by zero" divide x y = Right (x `div` y) main :: IO () main = print (divide 10 0)
fn divide(x: i32, y: i32) -> Result<i32, String> { if y == 0 { Err(String::from("divide by zero")) } else { Ok(x / y) } } fn main() { println!("{:?}", divide(10, 0)); }
Haskell's Either String Int and Rust's Result<i32, String> carry the same two cases in the same order (success type first) — only the case names differ: Haskell's Left/Right become Rust's Err/Ok, trading positional convention for explicitly meaningful names.
Skipping the failure case triggers a compiler warning in both
divide :: Int -> Int -> Either String Int divide _ 0 = Left "divide by zero" divide x y = Right (x `div` y) main :: IO () main = case divide 10 0 of Right value -> print value Left reason -> putStrLn ("Error: " ++ reason)
fn divide(x: i32, y: i32) -> Result<i32, String> { if y == 0 { Err(String::from("divide by zero")) } else { Ok(x / y) } } fn main() { match divide(10, 0) { Ok(value) => println!("{value}"), Err(reason) => println!("Error: {reason}"), } }
Both compilers push back on treating a Result/Either as if it were the bare success value: Haskell rejects it as a type error outright, while Rust's #[must_use] attribute on Result emits an "unused Result that must be used" WARNING if it is silently dropped rather than matched or explicitly handled. Matching both branches — or explicitly calling a combinator that forces a decision, like Rust's .unwrap_or or Haskell's either function — is the idiomatic way to satisfy either language.
Algebraic Data Types vs. Enums
Modeling "one of several shapes"
data Shape = Circle Double | Rectangle Double Double area :: Shape -> Double area (Circle radius) = pi * radius * radius area (Rectangle width height) = width * height main :: IO () main = print (map area [Circle 5.0, Rectangle 3.0 4.0])
#[derive(Debug)] enum Shape { Circle(f64), Rectangle(f64, f64), } fn area(shape: &Shape) -> f64 { match shape { Shape::Circle(radius) => std::f64::consts::PI * radius * radius, Shape::Rectangle(width, height) => width * height, } } fn main() { let shapes = vec![Shape::Circle(5.0), Shape::Rectangle(3.0, 4.0)]; let areas: Vec<f64> = shapes.iter().map(area).collect(); println!("{:?}", areas); }
Haskell's algebraic data types and Rust's enums with associated data are close structural cousins — both declare a closed set of tagged, optionally payload-carrying variants, deconstructed with pattern matching the compiler checks for exhaustiveness. Rust requires the type prefix Shape::Circle at the construction site, where Haskell just writes the bare constructor Circle.
Payload-free constructors
data Direction = North | South | East | West deriving (Show) main :: IO () main = print North
#[derive(Debug)] enum Direction { North, South, East, West, } fn main() { println!("{:?}", Direction::North); }
A payload-free Haskell constructor and a payload-free Rust enum variant translate almost mechanically — Haskell needs an explicit deriving (Show) clause to make the type printable; Rust needs the equivalent explicit #[derive(Debug)] attribute, the same opt-in idea spelled as an attribute rather than a keyword clause.
Recursive data types
data Tree = Leaf | Node Tree Int Tree sumTree :: Tree -> Int sumTree Leaf = 0 sumTree (Node left value right) = sumTree left + value + sumTree right main :: IO () main = print (sumTree (Node (Node Leaf 1 Leaf) 2 (Node Leaf 3 Leaf)))
enum Tree { Leaf, Node(Box<Tree>, i32, Box<Tree>), } fn sum_tree(tree: &Tree) -> i32 { match tree { Tree::Leaf => 0, Tree::Node(left, value, right) => sum_tree(left) + value + sum_tree(right), } } fn main() { let tree = Tree::Node( Box::new(Tree::Node(Box::new(Tree::Leaf), 1, Box::new(Tree::Leaf))), 2, Box::new(Tree::Node(Box::new(Tree::Leaf), 3, Box::new(Tree::Leaf))), ); println!("{}", sum_tree(&tree)); }
Haskell needs no special marker for a self-referencing data type — every constructor is already heap-allocated behind a pointer by the runtime. Rust enums are stack-allocated by default with a fixed, known size, so a directly self-referencing variant would have unbounded size; the explicit Box<Tree> heap-allocates that field instead, exactly the representation Haskell uses automatically and silently.
Pattern Matching
case versus match
describe :: Int -> String describe n = case n of 0 -> "zero" n | n > 0 -> "positive" _ -> "negative" main :: IO () main = putStrLn (describe (-5))
fn describe(n: i32) -> &'static str { match n { 0 => "zero", n if n > 0 => "positive", _ => "negative", } } fn main() { println!("{}", describe(-5)); }
Haskell's case ... of and Rust's match { } read as close dialects of the same construct: patterns tried top to bottom, a guard attachable to a pattern (both reuse | for Haskell, if for Rust's match guards), and a wildcard _ catch-all.
Both compilers check exhaustiveness
-- GHC warns here (with -Wincomplete-patterns) since the -- negative case is missing entirely: classify :: Int -> String classify 0 = "zero" classify n | n > 0 = "positive" main :: IO () main = putStrLn (classify 5)
fn classify(n: i32) -> &'static str { match n { 0 => "zero", n if n > 0 => "positive", // Rust's compiler REJECTS this match outright — "non-exhaustive // patterns" is a hard compile ERROR, not a configurable warning // the way GHC's incomplete-pattern check defaults to: _ => "negative", } } fn main() { println!("{}", classify(5)); }
Both compilers verify a match covers every case, but Rust is stricter by default: a non-exhaustive match is an unconditional compile error in Rust, where GHC's equivalent gap is a configurable warning (elevated to an error only under stricter flags like -Werror).
Destructuring a tuple
main :: IO () main = let point = (3, 4) (x, y) = point in print (x + y)
fn main() { let point = (3, 4); let (x, y) = point; println!("{}", x + y); }
Tuple destructuring is essentially identical syntax in both languages — a parenthesized, comma-separated pattern on the left of a binding, matched positionally against the tuple's shape.
Records vs. Structs
Records versus a genuine struct
data Point = Point { x :: Int, y :: Int } deriving (Show) main :: IO () main = let point = Point { x = 3, y = 4 } in print (x point, y point)
#[derive(Debug)] struct Point { x: i32, y: i32, } fn main() { let point = Point { x: 3, y: 4 }; println!("{:?}", (point.x, point.y)); }
Both define a named product type with labeled fields, using near-identical construction and access syntax — Point { x = 3, y = 4 } versus Point { x: 3, y: 4 }, and both accessed with the same dot notation, arguably the closest struct-literal syntax match on the whole site.
Record update syntax versus struct update syntax
data Point = Point { x :: Int, y :: Int } deriving (Show) main :: IO () main = let point = Point { x = 3, y = 4 } moved = point { y = 99 } in print moved
#[derive(Debug, Clone)] struct Point { x: i32, y: i32, } fn main() { let point = Point { x: 3, y: 4 }; let moved = Point { y: 99, ..point.clone() }; println!("{:?}", moved); }
Haskell's point { y = 99 } and Rust's Point { y: 99, ..point } both produce a new value with one field replaced and every other field copied — nearly identical syntax, with the spread ..point in Rust needing to come last. One real difference: Rust's struct-update syntax MOVES the remaining fields out of point by default, so an explicit .clone() (and a #[derive(Clone)]) is needed here to keep using the original point afterward — a consequence of ownership that Haskell, with no move semantics at all, never has to think about.
Functions & Currying
Anonymous functions (closures)
main :: IO () main = print (map (\x -> x * x) [1, 2, 3, 4])
fn main() { let squares: Vec<i32> = vec![1, 2, 3, 4].iter().map(|x| x * x).collect(); println!("{:?}", squares); }
Haskell spells a lambda \x -> ... (the backslash evoking the Greek letter lambda). Rust spells the same idea |x| ..., pipe-delimited parameters with no arrow before the body — visually close to Ruby's block-parameter syntax, if a Haskeller has that reference point instead.
Currying: automatic in Haskell, manual in Rust
add :: Int -> Int -> Int add x y = x + y main :: IO () main = let addFive = add 5 in print (addFive 10)
fn add(x: i32, y: i32) -> i32 { x + y } fn main() { let add_five = |y: i32| add(5, y); println!("{}", add_five(10)); }
In Haskell, add 5 alone is already valid — every function is secretly a chain of one-argument functions. Rust functions take a fixed argument list with no automatic partial application at all; getting the same behavior means writing the currying out explicitly, a closure that captures the first argument and calls the original function — the result behaves identically to Haskell's automatic currying, but the intent must be spelled out by hand.
Function composition: built-in versus manual
main :: IO () main = let addOneThenDouble = (* 2) . (+ 1) in print (addOneThenDouble 5)
fn main() { let add_one_then_double = |x: i32| (x + 1) * 2; println!("{}", add_one_then_double(5)); }
Haskell's . composes function VALUES directly, point-free, with no reference to the argument at all. Rust has no built-in function-composition operator; the idiomatic Rust way to combine two functions is simply to write a closure that calls one, then the other, explicitly naming the intermediate value the way an ordinary imperative language would.
Type Classes vs. Traits
class ↔ trait — one of the closest parallels on the site
class Describable a where describe :: a -> String data Season = Season String instance Describable Season where describe (Season name) = "The season is " ++ name main :: IO () main = putStrLn (describe (Season "spring"))
trait Describable { fn describe(&self) -> String; } struct Season(String); impl Describable for Season { fn describe(&self) -> String { format!("The season is {}", self.0) } } fn main() { let spring = Season(String::from("spring")); println!("{}", spring.describe()); }
This is one of the closest parallels between the two languages on the whole site. Haskell's class keyword defines an interface (confusingly named — it has nothing to do with object-oriented classes); Rust's trait does the same thing. Haskell implements a typeclass for a type with instance Class Type where; Rust implements a trait for a type with impl Trait for Type. Both compilers require every method to be provided (unless a default is supplied) and both reject calling a typeclass/trait method on a type with no matching instance/impl.
Default method implementations transfer directly
class Greet a where greetingName :: a -> String greeting :: a -> String greeting value = "Hello, " ++ greetingName value ++ "!" -- default implementation data Person = Person String instance Greet Person where greetingName (Person personName) = personName -- greeting is inherited from the default above main :: IO () main = putStrLn (greeting (Person "Alice"))
trait Greet { fn name(&self) -> String; fn greeting(&self) -> String { format!("Hello, {}!", self.name()) // default implementation } } struct Person(String); impl Greet for Person { fn name(&self) -> String { self.0.clone() } // greeting is inherited from the default above } fn main() { let alice = Person(String::from("Alice")); println!("{}", alice.greeting()); }
Both traits and typeclasses can supply a default implementation for a method, inherited automatically by any implementing type that does not override it — structurally identical in both languages, just spelled differently: directly in the trait/class body in both cases.
Typeclass constraints versus trait bounds
describeAll :: (Show a) => [a] -> [String] describeAll = map show main :: IO () main = print (describeAll [1, 2, 3])
fn describe_all<T: std::fmt::Debug>(items: &[T]) -> Vec<String> { items.iter().map(|item| format!("{:?}", item)).collect() } fn main() { println!("{:?}", describe_all(&[1, 2, 3])); }
Haskell's (Show a) => constraint and Rust's T: std::fmt::Debug trait bound both restrict a generic function to types that implement a specific interface — the syntax position differs (Haskell's constraint precedes the signature with =>; Rust's bound is attached directly to the type parameter with :), but the underlying idea, "generic over any type with this capability," is identical.
Garbage Collection vs. Ownership
No ownership model in Haskell at all
main :: IO () main = do let owner = "hello" -- There is no borrowing, no lifetimes, and no ownership transfer here. -- "reference" below is simply another name bound to the same immutable -- value; both names remain valid for as long as anything needs them, -- and the garbage collector reclaims the value once nothing does. let reference = owner putStrLn reference putStrLn owner
fn main() { let owner = String::from("hello"); let borrowed = &owner; // borrow — compiler tracks lifetime println!("{borrowed}"); // owner is still valid here because the borrow ended above println!("{owner}"); }
This is the single biggest structural difference between the two languages, and it runs the opposite direction from every other language on this site: here Rust is the one with the genuinely NEW, foreign concept. Every Haskell value is managed by a tracing garbage collector, so there is no concept of "moving" a value, no lifetimes to annotate, and no possibility of a use-after-move or dangling-reference compile error. Rust's compiler instead enforces ownership and borrowing rules so that memory is freed deterministically with no garbage collector at all — a genuinely different tradeoff, not a strictly better or worse one.
Sharing values freely versus Rc
main :: IO () main = do let shared = [1, 2, 3] :: [Int] let cloneOne = shared let cloneTwo = shared print shared print cloneOne print cloneTwo -- There is no reference count to inspect: the garbage collector -- decides independently when the underlying memory can be reclaimed.
use std::rc::Rc; fn main() { let shared = Rc::new(vec![1, 2, 3]); let clone_one = Rc::clone(&shared); let clone_two = Rc::clone(&shared); println!("{:?} {:?} {:?}", shared, clone_one, clone_two); println!("reference count: {}", Rc::strong_count(&shared)); }
Haskell requires no wrapper type at all to share a value among as many names as you like, since the garbage collector (not an ownership graph) decides when memory is reclaimed. Rust needs an explicit reference-counted wrapper type like Rc<T> (single-threaded) or Arc<T> (thread-safe) whenever a value must genuinely have multiple owners, because its ownership model otherwise only permits one owner at a time.
No lifetime annotations to write, ever
describeFirst :: [Int] -> String describeFirst [] = "empty" describeFirst (x:_) = "first is " ++ show x main :: IO () main = putStrLn (describeFirst [1, 2, 3])
fn describe_first(numbers: &[i32]) -> String { match numbers.first() { Some(x) => format!("first is {x}"), None => String::from("empty"), } } fn main() { println!("{}", describe_first(&[1, 2, 3])); }
This particular example happens not to need an explicit lifetime annotation (Rust's "lifetime elision" rules cover this common shape automatically), but many real Rust functions returning a reference derived from a parameter DO require one, written as &'a syntax tied to a specific parameter's scope. Haskell has no lifetime concept whatsoever, in any function, ever — every value simply lives as long as the garbage collector determines something still needs it.
Laziness vs. Strictness
Infinite lists exist only because Haskell is lazy
main :: IO () main = print (take 5 [1..])
fn main() { let numbers: Vec<i32> = (1..).take(5).collect(); println!("{:?}", numbers); }
Haskell's [1..] is an ordinary list that only works because Haskell is lazy, generating each element on demand as take asks for it. Rust's (1..) range is not a list at all — it is a lazy Iterator, a completely separate type from the strict, fully-realized Vec used everywhere else on this page, and .take(5).collect() is what actually materializes a concrete, finite Vec from it.
No thunks: every binding is fully evaluated
main :: IO () main = do let expensive = 2 ^ 20 -- not computed until actually demanded putStrLn "before use" print expensive
fn main() { let expensive = 2_i64.pow(20); // computed right here, immediately println!("before use"); println!("{expensive}"); }
In Haskell, every binding is lazy unless forced otherwise — expensive is not actually computed until print demands it. Rust is strict by default, matching most mainstream languages: expensive is computed the moment its binding line runs, in program order, with no thunk or deferred computation involved.
do-notation vs. the ? Operator
do-notation for IO
main :: IO () main = do putStrLn "What is your name?" let name = "Ada" -- stand-in for getLine, to keep this example self-contained putStrLn ("Hello, " ++ name ++ "!")
fn main() { println!("What is your name?"); let name = "Ada"; // stand-in for reading stdin, to keep this self-contained println!("Hello, {name}!"); }
Haskell's do-notation is syntactic sugar over the general Monad typeclass — it works identically for IO, Maybe, lists, and any other type with a Monad instance. Rust has no IO monad and no need for one: ordinary strict imperative code already sequences actions without needing any special notation, since there is no IO value to thread through pure code the way Haskell does.
do-notation over Maybe becomes the ? operator on Option
import Text.Read (readMaybe) parseAndDouble :: String -> Maybe Int parseAndDouble text = do number <- readMaybe text return (number * 2) main :: IO () main = print (parseAndDouble "21")
fn parse_and_double(text: &str) -> Option<i32> { let number: i32 = text.parse().ok()?; // early-returns None on failure Some(number * 2) } fn main() { println!("{:?}", parse_and_double("21")); }
Haskell's do-notation over Maybe short-circuits on the first Nothing via the generic Monad typeclass — each <- line is a bind. Rust's ? operator on Option/Result is Rust's closest brush with monadic chaining: it threads a computation forward and short-circuits at the first failure, unwrapping the success case automatically at each step — the same practical effect, reached through a language-level operator rather than a generalized typeclass abstraction.
Monad generalizes across types; ? does not
-- Haskell's do-notation works UNCHANGED for any Monad — IO, Maybe, -- lists, or a custom type — because Monad is one typeclass with one -- bind operation (>>=) that every instance implements: main :: IO () main = do let chained = do x <- Just 3 y <- Just (x * 2) return (y + 1) print chained
fn main() { // Rust has no single "Monad" trait in the standard library — the // short-circuiting behavior of "?" is built into the language for // Option and Result specifically, rather than being a general, // user-extensible interface the way Haskell's Monad is. The closest // manual equivalent for Option is chained "and_then" calls: let chained = Some(3).and_then(|x| Some(x * 2)).and_then(|y| Some(y + 1)); println!("{:?}", chained); }
This is the sharpest conceptual gap between the two languages. Haskell's Monad is one typeclass; any type that implements >>= gets do-notation for free, and the same block of syntax works for IO today and a custom parser type tomorrow. Rust's ? operator is special-cased at the language level for Option and Result specifically — there is no way to define your own type and get ?-style short-circuiting through a general, user-extensible mechanism the way Haskell's Monad instance would provide.
Gotchas for Haskell Developers
Move semantics: a genuinely new failure mode for a Haskeller
-- Haskell has no concept of "using a value after moving it" at all — -- every value can be referenced as many times as needed, always: main :: IO () main = let numbers = [1, 2, 3] :: [Int] total = sum numbers in do print total print numbers -- perfectly fine — nothing was "moved"
fn main() { let numbers = vec![1, 2, 3]; let total: i32 = numbers.iter().sum(); println!("{total}"); // The line below would be a COMPILE ERROR if "numbers" had instead // been passed BY VALUE into a function that consumed it — Rust // would say "value used here after move." Using ".iter()" above // (borrowing rather than consuming) is what keeps this line legal: println!("{:?}", numbers); }
Haskell structurally cannot have a "use after move" bug — every value can be referenced from as many places as the program needs, for as long as it needs, full stop. Rust's ownership model means passing a non-Copy value BY VALUE into a function (rather than borrowing it with &) transfers ownership there permanently; using the original binding afterward is a compile error the Rust compiler calls a "move." This is the single most common category of new compile error a Haskeller will hit learning Rust, with literally no prior category of Haskell error to compare it to.
.unwrap() is an escape hatch back to Haskell-style crashes
main :: IO () main = print (Nothing :: Maybe Int)
fn main() { let maybe_value: Option<i32> = None; // The line below would panic at runtime with "called `Option::unwrap()` // on a `None` value" — Rust's ".unwrap()" forces extracting the value // without checking, an explicit escape hatch back to the kind of // partial-function crash Haskell's "fromJust" also allows: // println!("{}", maybe_value.unwrap()); println!("{:?}", maybe_value); }
Rust's Option/Result are meant to eliminate unchecked null/error access, but .unwrap() (and its cousin .expect(message)) is a deliberate, always-available escape hatch that panics immediately if the value is actually absent/an error — functionally identical to Haskell's fromJust, which is exactly as unsafe and exactly as tempting to reach for. Idiomatic style in both languages treats this method/function as a last resort, not a first instinct, and both ecosystems have linters that commonly flag its use in production code.