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.