Basics & Bindings
Hello, World
fun main() {
println("Hello, World!")
} main :: IO ()
main = putStrLn "Hello, World!" Look at the type:
main :: IO () says the entry point is an IO action producing nothing. That annotation is the whole philosophy in five characters — printing is an effect, effects live in IO, and the type system tracks them. Kotlin's main makes no such claim about what it might do.There is no var
Kotlin nudges you toward
val. Haskell removes the choice: every binding is immutable, and there is no reassignment operator in the language.fun main() {
val fixed = 1
var counter = 0
counter += 1
println(fixed + counter)
} main :: IO ()
main = do
let fixed = 1
let incremented = fixed + 1
print (fixed + incremented) A "changed" value is a new binding with a new name —
counter += 1 has no translation, only incremented = counter + 1. Every language on this site gestures at immutability; Haskell simply has nothing else, which turns out to be the enabling condition for the laziness and purity sections ahead.Inference plus signature culture
Both languages infer types. Haskell culture still writes a signature above every top-level definition — as checked documentation, not necessity.
fun double(number: Int): Int = number * 2
fun main() {
val answer = 42
println(double(answer))
} double :: Int -> Int
double number = number * 2
main :: IO ()
main = do
let answer = 42
print (double answer) The signature line
double :: Int -> Int is separate from the definition — Elm-style, and unlike Kotlin's inline annotations. Haskell's inference is stronger than Kotlin's (it can infer whole programs, generics included), which is exactly why the convention exists: signatures pin down what the compiler would otherwise generalize, and every arrow in one is meaningful, as the currying concept shows.Purity & IO
A pure function cannot println
The wall. In Kotlin, any function can log, mutate, or launch a network call — the signature never tells. In Haskell, a function typed
Int -> Int provably does none of those things.fun double(number: Int): Int {
println("sneaky side effect!")
return number * 2
}
fun main() {
println(double(21))
} double :: Int -> Int
double number = number * 2
-- Adding a putStrLn inside double would not compile:
-- printing is IO, and IO appears in the type or not at all.
announceAndDouble :: Int -> IO Int
announceAndDouble number = do
putStrLn "announced side effect!"
pure (number * 2)
main :: IO ()
main = do
print (double 21)
result <- announceAndDouble 21
print result To print, a function must return
IO Int instead of Int — the effect moves into the type, callers see it, and the compiler keeps pure and effectful code from mixing silently. Kotlin's suspend is the nearest experience (a colored function the call site must acknowledge); imagine that discipline applied to every effect, not just suspension.do is how IO sequences
Kotlin sequences effects with ordinary statements. Haskell sequences them with
do notation — <- binds an action's result, let binds a pure value.fun main() {
val greeting = "Hello"
val name = "Kotlin"
val message = "$greeting, $name!"
println(message)
println(message.length)
} main :: IO ()
main = do
let greeting = "Hello"
let name = "Haskell"
let message = greeting ++ ", " ++ name ++ "!"
putStrLn message
print (length message) Inside
do, Haskell looks almost imperative — deliberately, since it desugars to the monad plumbing in the type-classes section. The distinction to internalize: let for pure computation, <- for running an action and naming what it produced (line <- getLine). print is putStrLn . show — it renders any showable value, like println on a non-String.Laziness
Everything is a Sequence
Kotlin's
asSequence() makes evaluation lazy on request. Haskell evaluates nothing until needed, so an infinite list is an ordinary value.fun main() {
val evens = generateSequence(0) { it + 2 }
println(evens.take(5).toList())
val squares = generateSequence(1) { it + 1 }
.map { it * it }
.filter { it % 2 == 0 }
println(squares.take(3).toList())
} main :: IO ()
main = do
let evens = [0, 2 ..]
print (take 5 evens)
let squares = filter even (map (^ 2) [1 ..])
print (take 3 squares) [0, 2 ..] is all even numbers; take 5 forces just five of them. No generateSequence, no .asSequence() — laziness is the evaluation model, not a wrapper type, so every list pipeline gets Sequence behavior automatically. The cost is reasoning about when things evaluate (space leaks are Haskell's version of Kotlin's accidental-eager-chain), which is why strictness annotations exist for the hot paths.Functions & Currying
Every function is curried
The arrows in
Int -> Int -> Int are real: a two-argument Haskell function is a function returning a function, and partial application is just calling with fewer arguments.fun add(first: Int, second: Int): Int = first + second
fun main() {
val addTen = { second: Int -> add(10, second) }
println(addTen(5))
} add :: Int -> Int -> Int
add first second = first + second
addTen :: Int -> Int
addTen = add 10
main :: IO ()
main = print (addTen 5) Kotlin must wrap a lambda to partially apply; Haskell's
add 10 just is the one-argument function. Operators join in via sections — (+ 1), (* 2), (10 -) are all functions — which is what makes the pipelines in the collections section so terse.Anonymous functions
fun main() {
val tripled = listOf(1, 2, 3).map { it * 3 }
println(tripled)
} main :: IO ()
main = do
print (map (\number -> number * 3) [1, 2, 3])
print (map (* 3) [1, 2, 3]) The backslash is a lambda (squint and it is a λ):
\number -> … for Kotlin's { number -> … }. But note the second line — the operator section (* 3) says the same thing with no lambda at all, and idiomatic Haskell prefers it. There is no it; sections and composition fill that niche.Composition is an operator
Kotlin chains with dots on values. Haskell composes functions before any value arrives:
(.) glues, ($) applies.fun main() {
val describe = { number: Int -> "value: $number" }
val double = { number: Int -> number * 2 }
val describeDouble = { number: Int -> describe(double(number)) }
println(describeDouble(21))
} describe :: Int -> String
describe number = "value: " ++ show number
double :: Int -> Int
double number = number * 2
describeDouble :: Int -> String
describeDouble = describe . double
main :: IO ()
main = putStrLn $ describeDouble 21 describe . double reads right to left, like math's f∘g — no lambda, no parameter name, "point-free" style. The $ is just low-precedence application that saves trailing parentheses: putStrLn $ describeDouble 21 for putStrLn (describeDouble 21). Together they are why Haskell code has so few parentheses and so few variable names.No loops at all
Kotlin has
for, while, and higher-order functions. Haskell has recursion and higher-order functions — there is no loop statement to reach for.fun factorial(number: Int): Int {
var result = 1
for (value in 1..number) {
result *= value
}
return result
}
fun main() {
println(factorial(5))
} factorial :: Int -> Int
factorial 0 = 1
factorial number = number * factorial (number - 1)
main :: IO ()
main = do
print (factorial 5)
print (product [1 .. 5]) The loop-with-accumulator becomes either direct recursion (with pattern-matched equations — next section) or, more often, a library fold:
product [1 .. 5] is the factorial. In practice Haskell programmers write explicit recursion about as often as Kotlin programmers write manual while loops — the combinators cover the common shapes.Data Types
Sealed classes are data declarations
sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Rectangle(val width: Double, val height: Double) : Shape()
fun area(shape: Shape): Double = when (shape) {
is Circle -> 3.14159 * shape.radius * shape.radius
is Rectangle -> shape.width * shape.height
}
fun main() {
println(area(Rectangle(3.0, 4.0)))
} data Shape
= Circle Double
| Rectangle Double Double
area :: Shape -> Double
area (Circle radius) = 3.14159 * radius * radius
area (Rectangle width height) = width * height
main :: IO ()
main = print (area (Rectangle 3 4)) One
data declaration replaces the sealed hierarchy — this is the algebraic data type that sealed classes reconstruct on the JVM. The area equations pattern-match constructor payloads directly into names (no is checks, no smart casts), and GHC warns when a constructor goes unhandled, the same exhaustiveness when gives you.Records: the tables turn
For once Kotlin is the ergonomic one. Haskell's record syntax generates field accessors, but the fields are top-level functions — and updates use a clunky brace syntax.
data class Person(val name: String, val age: Int)
fun main() {
val person = Person("Ada", 36)
println(person.name)
val older = person.copy(age = 37)
println(older)
} data Person = Person
{ name :: String
, age :: Int
} deriving (Show)
main :: IO ()
main = do
let person = Person { name = "Ada", age = 36 }
putStrLn (name person)
let older = person { age = 37 }
print older Field access is a function call —
name person, not person.name — and person { age = 37 } is copy(age = 37). The wart Kotlin developers should know going in: those accessor functions share one namespace, so two record types with a name field collide (extensions like DuplicateRecordFields and lens libraries exist because of it). Haskell's data modeling shines at sum types; records are its humbler corner.value class is newtype
A genuine twin: Kotlin's
@JvmInline value class and Haskell's newtype both wrap a single value in a distinct compile-time type that costs nothing at runtime.@JvmInline
value class UserId(val raw: Int)
@JvmInline
value class ProductId(val raw: Int)
fun lookupUser(id: UserId) = "user #${id.raw}"
fun main() {
println(lookupUser(UserId(7)))
// lookupUser(ProductId(7)) ← compile error
} newtype UserId = UserId Int
newtype ProductId = ProductId Int
lookupUser :: UserId -> String
lookupUser (UserId raw) = "user #" ++ show raw
main :: IO ()
main = do
putStrLn (lookupUser (UserId 7))
-- lookupUser (ProductId 7) ← compile error Both erase at compile time — the runtime sees a bare
Int — while the type checker keeps a UserId out of a ProductId slot. Haskell code newtypes promiscuously (it is also how the same underlying type gets different type-class instances); bring the habit back to Kotlin, where value class is underused.data class members become deriving
data class Point(val x: Int, val y: Int)
fun main() {
val point = Point(3, 4)
println(point)
println(point == Point(3, 4))
println(listOf(Point(2, 2), Point(1, 1)).sortedBy { it.x })
} data Point = Point Int Int
deriving (Show, Eq, Ord)
main :: IO ()
main = do
let point = Point 3 4
print point
print (point == Point 3 4)
print (minimum [Point 2 2, Point 1 1]) What
data class bundles, deriving itemizes: Show is toString(), Eq is equals(), Ord is Comparable — each an instance of a type class (next section) the compiler writes for you. Like Rust's #[derive], the opt-in list means a type has exactly the capabilities it should.Pattern Matching & Guards
when moves into the equations
Haskell functions are usually written as several equations, one per pattern, tried top to bottom — Kotlin's
when dissolved into the definition itself.fun describe(numbers: List<Int>): String = when {
numbers.isEmpty() -> "empty"
numbers.size == 1 -> "one: ${numbers[0]}"
else -> "starts with ${numbers[0]}"
}
fun main() {
println(describe(listOf(7, 8, 9)))
} describe :: [Int] -> String
describe [] = "empty"
describe [single] = "one: " ++ show single
describe (first : _) = "starts with " ++ show first
main :: IO ()
main = putStrLn (describe [7, 8, 9]) The patterns do what Kotlin's conditions and indexing did:
[] matches empty, [single] matches exactly one element and binds it, (first : _) destructures head and tail. A case … of expression exists for matching mid-function, but the equation style is the idiom — and it is where Elixir's multi-clause functions came from.Guards with pipes
fun describe(number: Int): String = when {
number == 0 -> "zero"
number < 0 -> "negative"
number % 2 == 0 -> "positive even"
else -> "positive odd"
}
fun main() {
println(describe(-4))
} describe :: Int -> String
describe number
| number == 0 = "zero"
| number < 0 = "negative"
| even number = "positive even"
| otherwise = "positive odd"
main :: IO ()
main = putStrLn (describe (-4)) Guards are boolean conditions tried in order — Kotlin's subjectless
when ladder with pipes for arrows and otherwise (a synonym for True) as the else. They combine freely with patterns, and where clauses can share helper definitions across all of a function's guards and equations.Maybe & Either
T? becomes Maybe
Kotlin builds nullability into the type system; Haskell needs no special machinery —
Maybe is an ordinary two-constructor data type, and all the null-safety operators fall out of normal functions.fun main() {
val ages = mapOf("Ada" to 36)
val age: Int? = ages["Grace"]
println(age ?: 0)
println(ages["Ada"]?.plus(1) ?: 0)
} import Data.Maybe (fromMaybe)
main :: IO ()
main = do
let ages = [("Ada", 36)]
let grace = lookup "Grace" ages
print (fromMaybe 0 grace)
print (fromMaybe 0 (fmap (+ 1) (lookup "Ada" ages))) The translation table:
?: is fromMaybe, ?. is fmap, and ?.let chains are >>= (or a do block — Maybe sequences just like IO does). lookup returning Maybe Int is the ecosystem norm: absence is a value, never a null, and nothing unwraps without saying so.Exceptions become Either
fun safeDivide(numerator: Double, denominator: Double): Double {
require(denominator != 0.0) { "cannot divide by zero" }
return numerator / denominator
}
fun main() {
try {
println(safeDivide(10.0, 2.0))
} catch (exception: IllegalArgumentException) {
println(exception.message)
}
} safeDivide :: Double -> Double -> Either String Double
safeDivide _ 0 = Left "cannot divide by zero"
safeDivide numerator denominator = Right (numerator / denominator)
main :: IO ()
main = do
case safeDivide 10 2 of
Right quotient -> print quotient
Left reason -> putStrLn reason Either String Double is Left failure or Right success ("right" as in correct — the mnemonic is real). It is Rust's Result and Elm's Result by other names, and like them it chains: a do block over Either short-circuits on the first Left, which is Kotlin's runCatching chain without the exceptions underneath.?.let chains become do blocks
A pipeline of might-fail steps: Kotlin chains
?.let; Haskell writes a do block over Maybe, where the first Nothing stops everything.fun parse(text: String): Int? = text.toIntOrNull()
fun halveEven(number: Int): Int? =
if (number % 2 == 0) number / 2 else null
fun main() {
val result = parse("42")?.let { halveEven(it) }?.let { halveEven(it) }
println(result ?: -1)
val failed = parse("41")?.let { halveEven(it) }
println(failed ?: -1)
} import Text.Read (readMaybe)
halveEven :: Int -> Maybe Int
halveEven number
| even number = Just (number `div` 2)
| otherwise = Nothing
pipeline :: String -> Maybe Int
pipeline text = do
number <- readMaybe text
halved <- halveEven number
halveEven halved
main :: IO ()
main = do
print (pipeline "42")
print (pipeline "41") The same
do notation that sequences IO sequences Maybe — each <- unwraps a Just or aborts the block with Nothing. This is the monad idea sneaking up on you gently: one syntax for "and then" whose meaning (run an effect / continue if present / continue if Right) comes from the type. Backticks around div turn a named function into an infix operator.Type Classes
Interfaces become type classes
A type class looks like an interface, but conformance is declared separately from the type — and the compiler picks the instance from the types at the call site.
interface Describable {
fun describe(): String
}
class Dog : Describable {
override fun describe() = "a dog"
}
fun main() {
println(Dog().describe())
} class Describable a where
describe :: a -> String
data Dog = Dog
instance Describable Dog where
describe _ = "a dog"
main :: IO ()
main = putStrLn (describe Dog) The
instance block is Rust's separate impl taken further: instances resolve by type inference, not by a value carrying a vtable, so type classes can dispatch on return types (read "42" :: Int) and constrain generics (Describable a => a -> String) in ways JVM interfaces cannot. Show, Eq, Ord, and the Functor/Monad pair ahead are all just type classes.map generalized: fmap
Kotlin defines
map separately on List, Sequence, and (as let) nullable types. Haskell defines it once — fmap, the Functor type class — for every container.fun main() {
println(listOf(1, 2, 3).map { it * 10 })
val maybe: Int? = 4
println(maybe?.let { it * 10 })
val absent: Int? = null
println(absent?.let { it * 10 })
} main :: IO ()
main = do
print (fmap (* 10) [1, 2, 3])
print (fmap (* 10) (Just 4))
print (fmap (* 10) Nothing) One function, one law-governed concept — "apply inside the container, keep the shape" — working uniformly over lists,
Maybe, Either, IO, and anything else with a Functor instance. This is the abstraction level Haskell trades in: where Kotlin ships many concrete maps, Haskell ships one idea and lets types select the implementation.Lists & Comprehensions
Lists, ranges & cons
fun main() {
val numbers = listOf(1, 2, 3)
println(listOf(0) + numbers)
println((1..5).toList())
println((1..9 step 2).toList())
} main :: IO ()
main = do
let numbers = [1, 2, 3]
print (0 : numbers)
print [1 .. 5]
print [1, 3 .. 9] Haskell lists are immutable singly-linked cons lists:
0 : numbers prepends in constant time (Kotlin's listOf(0) + numbers copies). Ranges are literals, with the step given by example — [1, 3 .. 9] — rather than a keyword. Both ends inclusive, like Kotlin's ...map/filter/fold
fun main() {
val result = listOf(1, 2, 3, 4, 5, 6)
.filter { it % 2 == 0 }
.map { it * it }
.fold(0) { total, number -> total + number }
println(result)
} main :: IO ()
main = do
let result = sum (map (^ 2) (filter even [1, 2, 3, 4, 5, 6]))
print result
let folded = foldl (+) 0 (map (^ 2) (filter even [1 .. 6]))
print folded Same combinators, opposite direction: Kotlin chains left to right off the value; Haskell nests function applications right to left (pipeline-order alternatives exist via
&, but nesting is the norm). Kotlin's fold(0) { … } is foldl (+) 0 — and thanks to laziness, this "chain" never builds intermediate lists, behaving like asSequence() for free.List comprehensions
Kotlin never adopted comprehension syntax. Haskell's is the one Python borrowed — generators, filters, and an output expression in brackets.
fun main() {
val squares = (1..10)
.filter { it % 2 == 0 }
.map { it * it }
println(squares)
val pairs = (1..3).flatMap { first ->
(first..3).map { second -> first to second }
}
println(pairs)
} main :: IO ()
main = do
let squares = [number * number | number <- [1 .. 10], even number]
print squares
let pairs = [(first, second) | first <- [1 .. 3], second <- [first .. 3]]
print pairs The two-generator form shows the win: later generators see earlier bindings (
second <- [first .. 3]), collapsing Kotlin's flatMap-inside-map nesting into one readable line. Comprehensions are sugar for the same monad machinery as do — a comprehension is a do block over lists.Strings
String is a list of Char
A historical decision with daily consequences: Haskell's default
String is literally [Char] — a lazy linked list of characters.fun main() {
val greeting = "hello"
println(greeting + " world")
println(greeting.uppercase())
println(greeting.length)
} import Data.Char (toUpper)
main :: IO ()
main = do
let greeting = "hello"
putStrLn (greeting ++ " world")
putStrLn (map toUpper greeting)
print (length greeting) Concatenation is list append (
++), uppercasing is map toUpper, and length is the list function — elegant, and slow for real text work. Production Haskell uses the Text type (packed, Unicode-aware) with the OverloadedStrings extension making literals polymorphic; String remains the teaching-and-small-programs default. There is no interpolation in the base language — show plus ++, or quasi-quoter libraries.Errors Without Exceptions
Where did try/catch go?
Haskell technically has exceptions (
error aborts; IO exceptions exist and can be caught), but typed code culture routes expected failure through Maybe and Either — the types you have already met.fun parseAge(text: String): Int {
val number = text.toIntOrNull()
?: throw IllegalArgumentException("not a number: $text")
require(number >= 0) { "age cannot be negative" }
return number
}
fun main() {
try {
println(parseAge("36"))
println(parseAge("-1"))
} catch (exception: IllegalArgumentException) {
println(exception.message)
}
} import Text.Read (readMaybe)
parseAge :: String -> Either String Int
parseAge text =
case readMaybe text of
Nothing -> Left ("not a number: " ++ text)
Just number
| number < 0 -> Left "age cannot be negative"
| otherwise -> Right number
main :: IO ()
main = do
print (parseAge "36")
print (parseAge "-1")
print (parseAge "oops") The signature
String -> Either String Int carries what Kotlin's @Throws comment culture only gestures at, and purity makes it stick: a pure function cannot reach for a hidden exception channel without changing type. error "…" exists for genuine bugs (it is panic!, not throw), and IO-level exceptions handle the outside world's failures at the program edge — the same layering Rust and Elm arrived at.