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.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 areafn 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 valuefn 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 agefn main() {
let name = "Ada";
let age = 36;
println!("{name} is {age}");
}In Haskell the usual route is
Text.Printf with positional format specifiers. Rust's println! macro supports captured identifiers directly inside { } placeholders (since Rust 1.58), 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 &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's own bracket literal, [1, 2, 3], makes a fixed-size array; a growable Vec comes from the macro. 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 evensfn 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, but the type arguments come in opposite orders: Haskell puts the error type first (Left), Rust puts the success type first (Ok). The case names differ too — Left/Right become Err/Ok, trading a positional convention for names that say what they mean.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 writes the type prefix,
Shape::Circle, where Haskell writes the bare constructor Circle — unless the variants are brought into scope with use Shape::*;.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. A Rust enum is stored inline 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",
// Delete this arm and the compiler REJECTS the match outright —
// "non-exhaustive patterns" is a hard compile ERROR, where GHC
// accepts an incomplete case unless asked to warn:
_ => "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 accepts an incomplete case silently by default, warns about it under -Wincomplete-patterns (part of -Wall), and rejects it only with -Werror on top.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 }. Access differs: Rust writes point.x, while Haskell calls the field's accessor function, x point — or writes point.x under the OverloadedRecordDot extension.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: ..point MOVES each remaining field that is not Copy — a String, say — out of point, which is then partly moved and can no longer be used whole; a .clone() (and a #[derive(Clone)]) is the way to keep it. Both fields here are i32, which is Copy, so this example would keep point usable even without the clone. Haskell, with no move semantics at all, never has to think about it.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. The idiomatic Rust way to combine two functions is 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. Rust rejects an impl that leaves out a method with no default, where GHC only warns about the missing method and fails at run time if it is called; 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 ownerfn 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 expensivefn 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 chainedfn main() {
// Rust has no single "Monad" trait in the standard library — the
// short-circuiting behavior of "?" comes from the Try trait, which
// stable Rust implements only for its own types (Option, Result,
// ControlFlow, Poll), unlike Haskell's open Monad class. 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 ? works through a trait, Try, which the standard library implements for Option, Result, ControlFlow and Poll — but stable Rust does not let you implement Try for a type of your own, so there is no user-extensible route to ?-style short-circuiting the way a Haskell Monad instance provides.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.