PONY λ M2 Modula-2

Kotlin.CodeCompared.To/Haskell

An interactive executable cheatsheet comparing Kotlin and Haskell

Kotlin 2.3 GHC 9.12
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.