Hello World & The Platform Model
Hello, World
A Roc program is a definition of
main!, a function the platform calls with the command-line arguments. The ! at the end of a name means "this performs effects", and the compiler enforces it — the same idea as suspend, applied to every effect rather than only to suspension.fun main() {
println("Hello, World!")
}main! = |_args| {
echo!("Hello, World!")
Ok({})
}The
_args parameter is named with a leading underscore because it is required by the signature and unused — Kotlin uses the same convention for an ignored lambda parameter. Ok({}) is the return value, where {} is the empty record: Roc's Unit.Where the standard library comes from
Kotlin runs on a platform someone else started — a JVM, a JavaScript runtime, or a native target — and what is available depends on which. Roc formalizes that: an application declares the platform it is compiled against, and the platform supplies the complete list of effects it may perform.
fun main() {
// The JVM is simply there: java.io, java.net,
// threads and the collector all exist because
// the runtime was started before your code ran.
println("the runtime came with the platform")
}main! = |_args| {
# Nothing is ambient. This program was built
# against a platform providing exactly echo!,
# so echo! is the only effect it can perform.
echo!("every effect comes from the platform")
Ok({})
}The difference is that the list is checked at compile time rather than discovered at link time. A Roc application cannot reach for an effect its platform does not provide, which is the compiled-in version of what Kotlin Multiplatform's
expect/actual declarations are reaching for.How a program reports failure
Kotlin signals failure by throwing, or by calling
exitProcess somewhere along the way. Roc's main! returns a Try: Ok for success and Err for failure, and the platform turns that into whatever the operating system wants.import kotlin.system.exitProcess
fun run(): Int {
println("all good")
return 0
}
fun main() {
val status = run()
if (status != 0) exitProcess(status)
}main! = |_args| {
echo!("all good")
Ok({})
}Because the exit status is the function's return value, the compiler type-checks it. There is no way to call
exitProcess from deep inside a library and skip every finally block on the way out.Comments
Comments start with
#, and there is no block form and no documentation-comment syntax, so a multi-line comment is several # lines.fun main() {
// A single-line comment
val count = 42 // an inline comment
/* A block comment,
which can span lines. */
println(count)
}main! = |_args| {
# A single-line comment
count : I64
count = 42 # an inline comment
# Roc has no block comment and no KDoc — every
# comment line starts with its own #.
echo!(count.to_str())
Ok({})
}The
count : I64 line is a type annotation on its own line above the definition, rather than after the name as in Kotlin. Writing it is optional, but it pins down which number type this is — and that decides what gets printed.Types & Inference
Inference covers whole programs
Kotlin's inference is good and stops at the function boundary: parameters always need types, and so does any return type that is part of a public API. Roc infers across the whole program, so an annotation is a claim you choose to state.
// Kotlin infers locals and expression bodies, but
// never a parameter type or a public return type.
fun double(number: Int): Int = number * 2
fun main() {
val result = double(21)
println(result)
}# Roc infers the whole signature from the body and
# the call sites, so the annotation is optional.
double = |number| number * 2
main! = |_args| {
result : I64
result = double(21)
echo!(result.to_str())
Ok({})
}The trade is readability at a distance, and Roc convention answers it the same way Kotlin's explicit-API mode does: annotate top-level functions. The difference is that the compiler checks a Roc annotation against the inferred type rather than taking it as the definition.
Records are structural, not nominal
A Roc record type is its set of fields, so a literal with the right fields already is one. Kotlin's
data class is nominal: two classes with identical constructors are different types.data class Point(val x: Int, val y: Int)
fun describe(point: Point): String = "(${point.x}, ${point.y})"
fun main() {
println(describe(Point(1, 2)))
// An identically-shaped class of another name
// would NOT be accepted.
}Point : { x : I64, y : I64 }
describe : Point -> Str
describe = |point| "(${point.x.to_str()}, ${point.y.to_str()})"
main! = |_args| {
echo!(describe({ x: 1, y: 2 }))
Ok({})
}Point here is an alias, not a class — nothing is constructed and no name appears at the call site. When you want Kotlin's nominal behavior, := instead of : gives it to you; see the value-class row in Gotchas.No casts, and no smart cast to need
Kotlin's smart cast is an excellent repair to a problem Roc does not have: there is no
Any, no is check and no cast, because a value's type is never in doubt.fun describe(value: Any): String {
if (value is Int) return "a number: $value"
if (value is String) return "a string: $value"
return "something else"
}
fun main() {
println(describe(42))
println(describe("hi"))
println(describe(2.5))
}describe : [Number(I64), Text(Str)] -> Str
describe = |value| match value {
Number(number) => "a number: ${number.to_str()}"
Text(text) => "a string: ${text}"
}
main! = |_args| {
echo!(describe(Number(42)))
echo!(describe(Text("hi")))
Ok({})
}The Roc version has two cases and needs no fallback, because its type says there are exactly two. The Kotlin version needs a third branch for everything it was not designed for, and that branch is where a mistake lands silently.
Null Safety, Taken Further
There is no null to be safe about
Kotlin made nullability part of the type system, which is most of the way there. Roc removes the concept: there is no
null, no ? suffix, and absence is a tag that names what is absent.fun findUser(userId: Int): String? =
if (userId == 1) "Ada" else null
fun main() {
val name = findUser(1)
if (name != null) {
println("found $name")
} else {
println("missing")
}
}find_user : U32 -> [Found(Str), Missing]
find_user = |user_id| {
if user_id == 1 {
Found("Ada")
} else {
Missing
}
}
main! = |_args| {
match find_user(1) {
Found(name) => echo!("found ${name}")
Missing => echo!("missing")
}
Ok({})
}The practical difference is that a Roc "nothing" can say which nothing.
Missing, NotYetLoaded and Refused are three different values in the same union, where Kotlin would have one null for all three and a comment explaining which is meant.There is no !! and no platform type
Kotlin's null safety has two deliberate holes:
!!, which asserts non-null without a check, and platform types, which arrive unannotated from Java and are treated as whatever you claim. Roc has neither, because it has nothing to assert about.fun main() {
val text: String? = "42"
// !! promises the compiler something it cannot
// check. Get it wrong and you get an NPE.
println(text!!.toInt())
// A value arriving from Java is a PLATFORM type:
// nullable or not, unknown, unchecked.
}main! = |_args| {
# There is nothing to assert away. A Try must be
# unwrapped by handling both cases, or by
# supplying a default with ??.
length = I64.from_str("42") ?? 0
echo!(length.to_str())
Ok({})
}Those two holes are the reason a Kotlin codebase can still throw
NullPointerException. They exist for a good reason — interoperating with a hundred million lines of Java — and a language with no Java behind it does not need them.The Elvis operator becomes ??
Roc's
?? reads almost exactly like Kotlin's ?: and does the corresponding job: it takes the value out of an Ok and supplies the fallback for an Err.fun main() {
val numbers = emptyList<Int>()
val first = numbers.firstOrNull() ?: 0
println(first)
val settings = mapOf("verbose" to "true")
println(settings["retries"] ?: "3")
}main! = |_args| {
numbers : List(I64)
numbers = []
first = numbers.first() ?? 0
echo!(first.to_str())
settings = Dict.empty().insert("verbose", "true")
echo!(settings.get("retries") ?? "3")
Ok({})
}The difference is what it tests. Kotlin's Elvis tests for
null, so a function must choose null as its failure signal and cannot say why. ?? tests whether the operation succeeded, and the Err it discards could have carried a reason.No ?. — a field always exists
There is no
?. in Roc, because a record field always exists — asking whether it is there would have only one answer. Where the value might be absent, that is said in the type, with a tag.data class Address(val city: String?)
data class Person(val address: Address?)
fun main() {
val person = Person(Address("Cambridge"))
println(person.address?.city ?: "unknown")
val unknown = Person(null)
println(unknown.address?.city ?: "unknown")
}Address : { city : [Known(Str), Unknown] }
Person : { address : [Known(Address), Unknown] }
city_of : Person -> Str
city_of = |person| match person.address {
Unknown => "unknown"
Known(address) => match address.city {
Unknown => "unknown"
Known(city) => city
}
}
main! = |_args| {
echo!(city_of({ address: Known({ city: Known("Cambridge") }) }))
echo!(city_of({ address: Unknown }))
Ok({})
}The Roc column is longer, and that is the honest trade:
?. chains are genuinely concise. What they hide is that each link silently short-circuits the whole expression, so a null four levels down and a null at the top are indistinguishable at the end of the chain — the match version has to say which.val, var & Deep Immutability
val protects the binding; Roc protects the value
val means the name cannot be reassigned. It says nothing about what the name points at, which is why a val holding a MutableList is mutated all day long.fun main() {
val numbers = mutableListOf(1, 2, 3)
val updated = numbers
updated[1] = 99 // val, and still mutated
println(updated)
println(numbers) // the same list, both times
}main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3]
# There is no mutable list to hold. Building a
# changed one leaves the original intact:
updated = numbers.set(1, 99) ?? numbers
echo!(Str.inspect(updated))
echo!(Str.inspect(numbers))
Ok({})
}The Kotlin column prints
[1, 99, 3] twice, because updated and numbers are the same list; the Roc column prints the changed one and then the original, untouched. Kotlin's List versus MutableList split is a real improvement over a language with only one, and it is a read-only view: the same underlying list can still be mutated through another reference to it. In Roc there is no mutable version to be a view of.A name is bound once
Roc's
= defines a name once. A second definition in the same scope is an error rather than a reassignment, and there is no var keyword at the top level to opt out with.fun main() {
var greeting = "hello"
greeting = "rebound"
println(greeting)
}main! = |_args| {
greeting = "hello"
# greeting = "rebound"
# ^ COMPILE ERROR: duplicate definition
echo!(greeting)
Ok({})
}Shadowing is unavailable too, which is stricter than
val in a nested block. Each step of a calculation gets its own name, so a variable cannot quietly mean something different fifteen lines further down.Opting in to mutation
Roc borrows the keyword and adds a sigil:
var declares it and $ marks every use, so mutation is visible where you read it rather than at a declaration further up.fun main() {
var total = 0
total = total + 5
total = total + 10
println(total)
}main! = |_args| {
var $total = 0.I64
$total = $total + 5
$total = $total + 10
echo!($total.to_str())
Ok({})
}A
var is local to its function and cannot escape, so there is no top-level mutable state and nothing a second part of the program could reach. Kotlin's var can be a property, a top-level declaration, or captured by a lambda.Destructuring, without componentN
Tuple destructuring works the way Kotlin's does. Record destructuring is different in an important way: it matches by name, not by position.
data class Person(val name: String, val age: Int)
fun main() {
val (x, y) = Pair(3, 4)
println("$x, $y")
val (name, age) = Person("Grace", 85)
println("$name: $age")
}main! = |_args| {
(x, y) = (3.I64, 4.I64)
echo!("${x.to_str()}, ${y.to_str()}")
person = { name: "Grace", age: 85.I64 }
{ name, age } = person
echo!("${name}: ${age.to_str()}")
Ok({})
}Kotlin's destructuring is positional, driven by
component1(), component2() and so on — so reordering two fields of a data class silently swaps them at every destructuring site. Naming the fields cannot go wrong that way.Sealed Classes vs Tag Unions
A sealed hierarchy becomes two lines
This is the row where Kotlin is already close. A sealed class with an exhaustive
when is a tagged union with exhaustiveness checking, and both compilers reject an incomplete match.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(Circle(2.0)))
println(area(Rectangle(3.0, 4.0)))
}Shape := [Circle(Dec), Rectangle(Dec, Dec)]
area : Shape -> Dec
area = |shape| match shape {
Circle(radius) => 3.14159 * radius * radius
Rectangle(width, height) => width * height
}
main! = |_args| {
echo!(area(Shape.Circle(2)).to_str())
echo!(area(Shape.Rectangle(3, 4)).to_str())
Ok({})
}What Roc removes is the hierarchy. There is no base class, no subclass per variant, no
is check and no requirement that they live in the same module — the union is written where it is used, in one line, and the variants are values rather than types.Tags that need no declaration at all
A tag can be used with no declaration anywhere.
Morning is a value the moment you write it, and its type is inferred as the set of tags that can reach that position.enum class Period { MORNING, AFTERNOON }
fun main() {
val hour = 14
val period = if (hour < 12) Period.MORNING else Period.AFTERNOON
val label = when (period) {
Period.MORNING -> "AM"
Period.AFTERNOON -> "PM"
}
println(label)
}main! = |_args| {
hour : I64
hour = 14
period = if hour < 12 { Morning } else { Afternoon }
label = match period {
Morning => "AM"
Afternoon => "PM"
}
echo!(label)
Ok({})
}Kotlin needs the
enum class line and the qualified Period.MORNING at every mention. The exhaustiveness guarantee is the same — both compilers reject a missing branch — but Roc gets it without a declaration, which changes how readily you reach for a union at all.Open unions, which sealed cannot be
The
.. in the type means "and possibly other tags". The function handles two by name and everything else with a wildcard, and callers may pass tags that did not exist when it was written.// A sealed hierarchy is CLOSED by definition —
// that is what makes it exhaustive. Anything open
// gives up the checking entirely.
fun describe(signal: Any): String = when (signal) {
"go" -> "go"
"stop" -> "stop"
else -> "something else"
}
fun main() {
println(describe("go"))
println(describe(7))
}describe : [Go, Stop, ..] -> Str
describe = |signal| match signal {
Go => "go"
Stop => "stop"
_ => "something else"
}
main! = |_args| {
echo!(describe(Go))
echo!(describe(Custom(7.I64)))
Ok({})
}This has no Kotlin equivalent. A sealed hierarchy is exhaustive precisely because it is closed, so "these two, plus whatever else turns up" means falling back to
Any and losing the check on the two you did name. Roc keeps the closed part closed and the open part open, in one type.Recursive data structures
A declared union may mention itself, which is how trees and syntax trees are written. No indirection is spelled out — the compiler works out where a pointer is needed.
sealed class Tree
data class Leaf(val value: Int) : Tree()
data class Node(val left: Tree, val right: Tree) : Tree()
fun sumTree(tree: Tree): Int = when (tree) {
is Leaf -> tree.value
is Node -> sumTree(tree.left) + sumTree(tree.right)
}
fun main() {
val tree = Node(Leaf(1), Node(Leaf(2), Leaf(3)))
println(sumTree(tree))
}Tree := [Leaf(I64), Node(Tree, Tree)]
sum_tree : Tree -> I64
sum_tree = |tree| match tree {
Leaf(value) => value
Node(left, right) => sum_tree(left) + sum_tree(right)
}
main! = |_args| {
tree = Tree.Node(Tree.Leaf(1), Tree.Node(Tree.Leaf(2), Tree.Leaf(3)))
echo!(sum_tree(tree).to_str())
Ok({})
}Both columns are checked and both are exhaustive; the difference is nine lines against six, and the absence of a class per case. A Kotlin
Tree is also a reference, so every node is a heap allocation the collector has to trace; the Roc one is reference counted with no collector behind it.Exhaustiveness, in both languages
Both compilers name the case you forgot. This is the strongest thing Kotlin has in common with Roc and the reason a Kotlin programmer takes to tag unions immediately.
enum class Color { RED, GREEN, BLUE }
fun toHex(color: Color): String = when (color) {
Color.RED -> "#FF0000"
Color.GREEN -> "#00FF00"
// Deleting BLUE is a COMPILE ERROR in an
// expression-position when.
Color.BLUE -> "#0000FF"
}
fun main() {
println(toHex(Color.GREEN))
println(toHex(Color.BLUE))
}Color := [Red, Green, Blue]
to_hex : Color -> Str
to_hex = |color| match color {
Red => "#FF0000"
Green => "#00FF00"
# Deleting the next line is a COMPILE ERROR
# naming Blue as the case not handled.
Blue => "#0000FF"
}
main! = |_args| {
echo!(to_hex(Color.Green))
echo!(to_hex(Color.Blue))
Ok({})
}The one asymmetry is where the check applies. Kotlin requires exhaustiveness when the
when is used as an expression, and — before it was tightened — a statement-position when over a sealed type could silently do nothing. Every Roc match is an expression, so the question does not arise.Numbers
A familiar menu, one size wider
Roc has
I8 through I128, U8 through U128, F32 and F64 — Kotlin's menu with the unsigned types promoted from experimental and a 128-bit width added.fun main() {
val byteValue: UByte = 255u
val ratio: Double = 2.5
println("$byteValue $ratio")
}main! = |_args| {
byte : U8
byte = 255
ratio : F64
ratio = 2.5
echo!("${byte.to_str()} ${ratio.to_str()}")
Ok({})
}Neither language widens implicitly, so mixing sizes means converting. What Roc adds is
Dec, a fixed-point decimal type with no Kotlin equivalent short of java.math.BigDecimal; what it removes is boxing, since a Roc number is never an object.Exact decimals, without BigDecimal
Dec is a fixed-point decimal type and an ordinary member of the number menu — same operators, same literals — and it is what an unannotated decimal literal becomes.import java.math.BigDecimal
fun main() {
println(0.1 + 0.2)
println(BigDecimal("0.1").add(BigDecimal("0.2")))
}main! = |_args| {
lossy : F64
lossy = 0.1 + 0.2
echo!(lossy.to_str())
precise : Dec
precise = 0.1 + 0.2
echo!(precise.to_str())
Ok({})
}BigDecimal is the Kotlin answer and it costs a different type, methods instead of operators, and a constructor that must take a string or silently inherit the float error it was meant to avoid. The difference between the two Roc lines is one annotation.Integer division and remainder
Roc separates the two divisions into two operators:
// floors and / divides. Kotlin uses / for both and decides which you meant from the operand types.fun main() {
println(17 / 5) // 3: both operands are Int
println(17 % 5)
println(17.0 / 5.0)
}main! = |_args| {
quotient : I64
quotient = 17 // 5
echo!(quotient.to_str())
remainder : I64
remainder = 17 % 5
echo!(remainder.to_str())
exact : Dec
exact = 17 / 5
echo!(exact.to_str())
Ok({})
}That decision is the trap:
17 / 5 and 17.0 / 5.0 are different operations spelled almost identically, and changing a variable from Int to Double silently changes which one runs. Two operators cannot be confused that way.Overflow is caught, not wrapped
Roc's integer arithmetic is checked. An addition that would pass the top of the range is an error, and when both operands are known at compile time — as here — it is caught before the program runs.
fun main() {
val big = Long.MAX_VALUE
println(big + 1) // wraps, silently
}main! = |_args| {
big : I64
big = 9_223_372_036_854_775_807
# echo!((big + 1).to_str())
# ^ COMPILE ERROR: "Integer addition overflowed!"
echo!(big.to_str())
Ok({})
}The Kotlin column prints the smallest
Long, because the JVM defines signed overflow as wrapping. That is predictable and it is still a bug every time it happens. Underscores as digit separators work in both languages.Strings
Interpolation, almost unchanged
Roc uses
${} for every interpolation, with no bare-$name shorthand. The one substantive difference is that interpolation takes a Str and will not convert a number for you.fun main() {
val name = "Roc bird"
val age = 10
println("$name is $age")
val message = "$name turns ${age + 1}"
println(message)
}main! = |_args| {
name = "Roc bird"
age : I64
age = 10
echo!("${name} is ${age.to_str()}")
message = "${name} turns ${(age + 1).to_str()}"
echo!(message)
Ok({})
}Kotlin's interpolation calls
toString() on whatever it finds, which is how a data class's generated toString ends up in a user-facing message. "${age}" in Roc is a type error naming I64 where Str was expected.Concatenation without +
Roc reserves
+ for numbers. Joining two strings is concat, either as a method on the left-hand string or as a plain function.fun main() {
println("Fast " + "and friendly")
println("count: " + 5) // + accepts anything
// with a toString
}main! = |_args| {
echo!("Fast ".concat("and friendly"))
echo!(Str.concat("also", " works"))
# "count: ".concat(5) does not compile:
# concat takes two Str values.
Ok({})
}Kotlin's
String.plus accepts Any?, so "count: " + someObject compiles for every object and prints whatever toString gives — including a default like Foo@1b6d3586. Requiring two strings makes that a compile error.Everyday string methods
Almost the same names, converted to snake case.
Str.inspect turns a non-string value into something printable, which is the job Kotlin's println(Any?) does implicitly.fun main() {
val padded = " systems "
println(padded.trim())
println("ab".repeat(3))
println("systems".startsWith("sys"))
println("systems".contains("stem"))
}main! = |_args| {
padded = " systems "
echo!(padded.trim())
echo!("ab".repeat(3))
echo!(Str.inspect("systems".starts_with("sys")))
echo!(Str.inspect("systems".contains("stem")))
Ok({})
}Method syntax here is sugar:
"ab".repeat(3) is resolved at compile time to Str.repeat("ab", 3). That is the same relationship a Kotlin extension function has to its receiver, which is why the two read alike.Splitting and joining
Splitting is
split_on and joining is Str.join_with, which takes the list first and the separator second.fun main() {
val parts = "red,green,blue".split(",")
println(parts.size)
println(parts.joinToString(" | "))
}main! = |_args| {
parts = "red,green,blue".split_on(",")
echo!(parts.len().to_str())
echo!(Str.join_with(parts, " | "))
Ok({})
}What is missing is the rest of it: no
Regex, no joinToString prefix, postfix or transform arguments, and no trimIndent. Kotlin's string library is unusually rich and this build's is unusually thin.UTF-8 bytes rather than UTF-16 chars
A Kotlin
String on the JVM is a sequence of UTF-16 code units, so an emoji has a length of two and needs a surrogate pair to write. A Roc Str is UTF-8 bytes, and the escape takes the code point directly.fun main() {
println("rocket: \uD83D\uDE80")
println("héllo".length) // 5 chars
println("héllo".toByteArray().size) // 6 bytes
}main! = |_args| {
echo!("rocket: \u(1F680)")
echo!("héllo".count_utf8_bytes().to_str())
Ok({})
}Roc has no
Char type and no way to subscript a string by position, which sounds restrictive until you count how much JVM string code is quietly wrong for anything outside the Basic Multilingual Plane.Lists vs Kotlin Collections
One list type, and it is a flat array
Roc has a literal syntax for lists and exactly one list type — a flat array of one element type, contiguous in memory with no boxing.
fun main() {
val numbers = listOf(3, 1, 4, 1, 5)
println(numbers.size)
println(numbers)
}main! = |_args| {
numbers : List(I64)
numbers = [3, 1, 4, 1, 5]
echo!(numbers.len().to_str())
echo!(Str.inspect(numbers))
Ok({})
}Kotlin has
List, MutableList, ArrayList, Array and the primitive arrays, and choosing between them is a real decision with real performance consequences. A Roc List(I64) stores the integers themselves, where a Kotlin List<Int> stores boxed Integer objects.map and filter, without the sequence question
The same chain in the same order, with
keep_if in place of filter and an explicit parameter in place of it.fun main() {
val numbers = listOf(1, 2, 3, 4, 5, 6)
val doubledEvens = numbers
.filter { it % 2 == 0 }
.map { it * 2 }
println(doubledEvens)
}main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4, 5, 6]
doubled_evens = numbers
.keep_if(|number| number % 2 == 0)
.map(|number| number * 2)
echo!(Str.inspect(doubled_evens))
Ok({})
}Roc has no
Sequence and no asSequence(), so there is no eager-versus-lazy decision and no intermediate-collection question to think about. That is simpler and it is also less powerful — a chain over a large list really does build each intermediate result.fold and sum
Same name, same argument order, same meaning. The lambda is a parameter rather than a trailing block.
fun main() {
val numbers = listOf(1, 2, 3, 4)
println(numbers.fold(0) { accumulator, number -> accumulator + number })
println(numbers.sum())
}main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4]
total = numbers.fold(0, |accumulator, number| accumulator + number)
echo!(total.to_str())
echo!(numbers.sum().to_str())
Ok({})
}Roc has no trailing-lambda syntax and no
it, so every parameter is named. That costs a few characters per call and removes the question of what it refers to in a nested chain.Indexing cannot throw
Roc has one subscript operation and it returns a
Try. Kotlin has two — [], which throws, and getOrNull, which does not — and the shorter one is the throwing one.fun main() {
val numbers = listOf(10, 20, 30)
println(numbers[1])
println(numbers.getOrNull(9) ?: "out of bounds")
// numbers[9] throws IndexOutOfBoundsException
}main! = |_args| {
numbers : List(I64)
numbers = [10, 20, 30]
match numbers.get(9) {
Ok(value) => echo!(value.to_str())
Err(_) => echo!("out of bounds")
}
fallback = numbers.get(1) ?? 0
echo!(fallback.to_str())
Ok({})
}That asymmetry is the whole difference. The safe Kotlin form is longer than the unsafe one, so the unsafe one is what gets written; in Roc the safe form is the only form, and
?? makes it about as short as a subscript.Sorting returns a new list
sort returns a new list and orders by the element type. This is Kotlin's sorted(), and Roc has no counterpart to sort(), the in-place version.fun main() {
val numbers = listOf(3, 1, 2)
println(numbers.sorted())
println(numbers.sortedDescending())
println(numbers)
}main! = |_args| {
numbers : List(I64)
numbers = [3, 1, 2]
echo!(Str.inspect(numbers.sort()))
echo!(Str.inspect(numbers.sort_reversed()))
echo!(Str.inspect(numbers))
Ok({})
}Kotlin already made the right default here —
sorted() on a read-only List returns a copy, and only MutableList gets sort(). Roc arrives at the same place by having nothing to sort in place.any, all and find
any and all carry over by name; firstOrNull becomes find_first, which returns a Try.fun main() {
val numbers = listOf(2, 4, 6, 7)
println(numbers.any { it % 2 == 1 })
println(numbers.all { it > 0 })
val found = numbers.firstOrNull { it > 5 }
println(if (found != null) "found $found" else "none")
}main! = |_args| {
numbers : List(I64)
numbers = [2, 4, 6, 7]
echo!(Str.inspect(numbers.any(|number| number % 2 == 1)))
echo!(Str.inspect(numbers.all(|number| number > 0)))
match numbers.find_first(|number| number > 5) {
Ok(found) => echo!("found ${found.to_str()}")
Err(_) => echo!("none")
}
Ok({})
}firstOrNull on a List<Int?> cannot distinguish "no match" from "matched a null", which is a small hole and a real one. A Try has no such ambiguity because the wrapper and the value are different things.Index and element together
Roc's
map passes only the element, so the index needs a different method — map_with_index, which is mapIndexed under another name.fun main() {
val words = listOf("one", "two")
val labeled = words.mapIndexed { index, word -> "$index:$word" }
println(labeled)
}main! = |_args| {
words = ["one", "two"]
labeled = words.map_with_index(|word, index| "${index.to_str()}:${word}")
echo!(Str.inspect(labeled))
Ok({})
}Note the argument order is reversed: Kotlin gives the index first, Roc gives the element first. Getting it backwards still type-checks whenever both happen to be numbers, so it is worth reading twice the first few times.
Data Classes vs Records
A data class becomes an alias
What
data class generates — a constructor, equals, hashCode, toString, copy — Roc gives every record for free, so naming the shape is a one-line alias.data class Employee(val name: String, val department: String)
fun describe(employee: Employee): String =
"${employee.name} works in ${employee.department}"
fun main() {
println(describe(Employee("Nia", "Compilers")))
}Employee : { name : Str, department : Str }
describe : Employee -> Str
describe = |employee| "${employee.name} works in ${employee.department}"
main! = |_args| {
employee = { name: "Nia", department: "Compilers" }
echo!(describe(employee))
Ok({})
}Nothing constructs an
Employee: the literal already is one, because it has those fields with those types. The cost is that Roc's version is structural, so any record with the same fields fits — which is a feature until you wanted the two to be distinct.copy() becomes a spread
Roc's
.. spread is copy(): it copies a record and overrides named fields in one expression, and it cannot introduce a field the record did not already have.data class Config(
val verbose: Boolean,
val retries: Int,
val timeoutSeconds: Int,
)
fun main() {
val defaults = Config(verbose = false, retries = 3, timeoutSeconds = 30)
val custom = defaults.copy(retries = 5)
println(custom)
println(defaults)
}main! = |_args| {
defaults = { verbose: Bool.False, retries: 3.I64, timeout_seconds: 30.I64 }
custom = { ..defaults, retries: 5 }
echo!(Str.inspect(custom))
echo!(Str.inspect(defaults))
Ok({})
}This is another place Kotlin is already there. The difference is that
copy() is generated per data class, so it exists only where someone wrote data; the spread works on every record because every record has a known field list.Map becomes Dict
When the keys really are data, Roc has
Dict. Every key shares one type and so does every value, exactly as in a Kotlin Map.fun main() {
val scores = mapOf("math" to 90, "art" to 95)
println(scores.size)
println(scores["art"] ?: 0)
println(scores["music"] ?: 0)
}main! = |_args| {
scores = Dict.empty()
.insert("math", 90.I64)
.insert("art", 95.I64)
echo!(scores.len().to_str())
echo!((scores.get("art") ?? 0).to_str())
echo!((scores.get("music") ?? 0).to_str())
Ok({})
}insert returns a new dict rather than changing the old one, which is why the calls chain — the read-only Map plus plus() shape rather than MutableMap. get returns a Try where Kotlin's [] returns a nullable.Tuples, which Kotlin stops at two
Roc has tuples of any size as a language feature, indexed with a dot. Kotlin has
Pair and Triple as library classes and nothing beyond them.fun divide(numerator: Int, denominator: Int): Pair<Int, Int> =
Pair(numerator / denominator, numerator % denominator)
fun main() {
val (quotient, remainder) = divide(17, 5)
println("$quotient remainder $remainder")
// Pair and Triple, and then you write a class.
}divide : I64, I64 -> (I64, I64)
divide = |numerator, denominator|
(numerator // denominator, numerator % denominator)
main! = |_args| {
(quotient, remainder) = divide(17, 5)
echo!("${quotient.to_str()} remainder ${remainder.to_str()}")
pairs = [divide(17, 5), divide(9, 2)]
echo!(Str.inspect(pairs))
Ok({})
}The second Roc line shows what that buys — a list of pairs, with no type declared. Kotlin can write
List<Pair<Int, Int>>, and at four elements it runs out of classes and you write a data class instead.when vs match
when becomes match
The same construct with different punctuation:
=> for ->, _ for else, and braces around the subject dropped.fun main() {
val statusCode = 404
val message = when (statusCode) {
200 -> "ok"
404 -> "not found"
else -> "something else"
}
println(message)
}main! = |_args| {
status_code : I64
status_code = 404
message = match status_code {
200 => "ok"
404 => "not found"
_ => "something else"
}
echo!(message)
Ok({})
}Both are expressions, both require every arm to produce the same type, and neither falls through. This is the row where a Kotlin programmer will feel most at home on the whole page.
Guards
Roc attaches a condition to a pattern with
if, so one match mixes literal patterns and guards. Kotlin has two forms instead: when (subject) for patterns and subjectless when for conditions.fun describe(number: Int): String = when {
number == 0 -> "zero"
number < 0 -> "negative"
number % 2 == 0 -> "positive even"
else -> "positive odd"
}
fun main() {
println(describe(0))
println(describe(-5))
println(describe(8))
}describe : I64 -> Str
describe = |number| match number {
0 => "zero"
n if n < 0 => "negative"
n if n % 2 == 0 => "positive even"
_ => "positive odd"
}
main! = |_args| {
echo!(describe(0))
echo!(describe(-5))
echo!(describe(8))
Ok({})
}The first arm shows why that matters:
0 is a pattern the compiler counts toward exhaustiveness, while every arm of a subjectless Kotlin when is a condition it cannot reason about. A guarded arm never counts in either language, which is why the wildcard is still required.Matching on a list's shape
A Roc pattern can describe a list's shape directly — empty, exactly one element, or a first element plus the rest — and bind the pieces in the same breath.
fun describe(numbers: List<Int>): String = when (numbers.size) {
0 -> "empty"
1 -> "one: ${numbers[0]}"
else -> "first ${numbers[0]}, ${numbers.size - 1} more"
}
fun main() {
println(describe(emptyList()))
println(describe(listOf(7)))
println(describe(listOf(1, 2, 3)))
}describe : List(I64) -> Str
describe = |numbers| match numbers {
[] => "empty"
[single] => "one: ${single.to_str()}"
[first, .. as rest] => "first ${first.to_str()}, ${rest.len().to_str()} more"
}
main! = |_args| {
echo!(describe([]))
echo!(describe([7]))
echo!(describe([1, 2, 3]))
Ok({})
}Kotlin has no list pattern, so the same logic switches on a size and then indexes, and the compiler cannot connect the two:
numbers[0] in the size-1 arm is bounds-checked at run time like any other subscript. In the Roc version the binding is the check.Or-patterns
Alternatives within one branch are written with
| rather than a comma.fun sizeClass(number: Int): String = when (number) {
1, 2, 3 -> "small"
else -> "big"
}
fun main() {
println(sizeClass(2))
println(sizeClass(9))
}size_class : I64 -> Str
size_class = |number| match number {
1 | 2 | 3 => "small"
_ => "big"
}
main! = |_args| {
echo!(size_class(2))
echo!(size_class(9))
Ok({})
}It matters more in Roc than in Kotlin, because the alternatives can be tags carrying payloads rather than only constants — and the bound names have to agree across them, which the compiler checks.
Try vs Exceptions and Result
try/catch becomes a returned value
Roc has no exceptions and no stack unwinding. A function that can fail returns
Try(ok, err), and the caller gets the failure as an ordinary value to match on.fun parseScore(text: String): Int =
text.trim().toInt() // throws NumberFormatException
fun main() {
for (candidate in listOf("95", "not a number")) {
try {
println("score: ${parseScore(candidate)}")
} catch (error: NumberFormatException) {
println("bad score: $candidate")
}
}
}parse_score : Str -> Try(I64, [BadScore(Str)])
parse_score = |text| match I64.from_str(text.trim()) {
Ok(score) => Ok(score)
Err(_) => Err(BadScore(text))
}
main! = |_args| {
for candidate in ["95", "not a number"] {
match parse_score(candidate) {
Ok(score) => echo!("score: ${score.to_str()}")
Err(BadScore(bad)) => echo!("bad score: ${bad}")
}
}
Ok({})
}The important difference is the signature.
parse_score announces that it can fail and names how; Kotlin removed checked exceptions deliberately, so toInt() gives no hint at the call site that a catch is needed. toIntOrNull() is the closest Kotlin gets, and it loses the reason.Result becomes the ordinary way
Kotlin has
Result and it is the same idea — except that it is a library type, its error side is always Throwable, and it cannot be used as a return type in a public API without an opt-in.fun parseScore(text: String): Result<Int> =
runCatching { text.trim().toInt() }
fun main() {
val outcome = parseScore("95")
outcome.fold(
onSuccess = { println("score: $it") },
onFailure = { println("failed: ${it::class.simpleName}") },
)
}parse_score : Str -> Try(I64, [BadScore(Str)])
parse_score = |text| match I64.from_str(text.trim()) {
Ok(score) => Ok(score)
Err(_) => Err(BadScore(text))
}
main! = |_args| {
match parse_score("95") {
Ok(score) => echo!("score: ${score.to_str()}")
Err(BadScore(bad)) => echo!("bad score: ${bad}")
}
Ok({})
}The Roc version names its failure as a tag, so the signature lists exactly what can go wrong and a
match that misses one does not compile. Result<Int> says only "an Int, or some Throwable", which puts the reader back to reading documentation.Letting a failure bubble up
A thrown exception propagates invisibly. Roc's
? is the explicit version: it unwraps an Ok and returns early from the enclosing function on an Err.fun showFirst(numbers: List<Int>) {
val first = numbers.first() // throws if empty
println("first: ${first * 2}")
}
fun main() {
showFirst(listOf(5, 6, 7))
}show_first! = |numbers| {
first = numbers.first()?
echo!("first: ${(first * 2).to_str()}")
Ok({})
}
main! = |_args| {
numbers : List(I64)
numbers = [5, 6, 7]
show_first!(numbers)
}One character marks every place a function can exit early, so reading the body tells you its failure paths. In Kotlin any call at all might throw —
first() above looks like a plain accessor and is in fact a hidden exit.crash, and why there is nothing to catch it
There is exactly one way to stop a Roc program abruptly, and it is deliberately unlike an exception:
crash cannot be caught, so it can only ever mean "this state is impossible".fun divide(numerator: Int, denominator: Int): Int {
require(denominator != 0) { "impossible: checked upstream" }
return numerator / denominator
}
fun main() {
println(divide(10, 2))
}divide : I64, I64 -> I64
divide = |numerator, denominator|
if denominator == 0 {
# crash is not catchable. It is for states
# the program has already established
# cannot happen.
crash "impossible: checked upstream"
} else {
numerator // denominator
}
main! = |_args| {
echo!(divide(10, 2).to_str())
Ok({})
}Kotlin's
require throws IllegalArgumentException, which a catch (error: Exception) three frames up will happily swallow — turning a genuine invariant violation into a logged warning and a program that carries on with the state that caused it.Functions & Closures
One function form
Roc has one way to write a function, and it is the anonymous one. A named function is a name bound to a closure, so the top-level and local forms are identical.
fun add(left: Int, right: Int): Int = left + right
fun main() {
val alsoAdd = { left: Int, right: Int -> left + right }
println(add(2, 3))
println(alsoAdd(2, 3))
}add : I64, I64 -> I64
add = |left, right| left + right
main! = |_args| {
also_add = |left, right| left + right
echo!(add(2, 3).to_str())
echo!(also_add(2.I64, 3.I64).to_str())
Ok({})
}Kotlin has two, and they differ in more than syntax: a
fun can be an extension, can have default parameters and can be inlined, while a lambda cannot. Roc's single form has none of those distinctions and none of the corresponding capabilities.Closures capture values, not variables
A Kotlin lambda captures the variable — unlike Java, it can capture a
var and see later writes to it. A Roc closure captures the value, and the value cannot change.fun main() {
var amount = 10
val addAmount = { number: Int -> number + amount }
amount = 1000 // the lambda sees this
println(addAmount(5))
}main! = |_args| {
amount : I64
amount = 10
add_amount = |number| number + amount
# There is no second assignment to "amount",
# so nothing can change under the closure.
echo!(add_amount(5).to_str())
Ok({})
}The Kotlin column prints
1005, not 15. That is a deliberate Kotlin improvement over Java's effectively-final rule, and it is also a way for a lambda's result to depend on when it is called rather than on when it was made.No default or named arguments
Roc functions take a fixed number of positional arguments. There are no defaults, no named arguments and no
vararg — the pattern that replaces all three is a record of options with a named set of defaults.fun connect(
host: String,
port: UShort = 8080u,
verbose: Boolean = false,
): String = "$host:$port verbose=$verbose"
fun main() {
println(connect("example.com"))
println(connect("example.com", verbose = true))
}Options : { host : Str, port : U16, verbose : Bool }
connect : Options -> Str
connect = |options|
"${options.host}:${options.port.to_str()} verbose=${Str.inspect(options.verbose)}"
main! = |_args| {
defaults = { host: "example.com", port: 8080.U16, verbose: Bool.False }
echo!(connect(defaults))
echo!(connect({ ..defaults, verbose: Bool.True }))
Ok({})
}This is a genuine step backwards from Kotlin, whose default-and-named arguments are among its best features. What the record buys is that the option set has a name and a type, and that adding an option changes one record rather than every call site.
Generic functions
A lowercase name in a Roc signature is a type variable, with no separate parameter list to declare it in.
a -> a says the function returns exactly the type it was given.fun <T> identity(value: T): T = value
fun main() {
println(identity("same"))
println(identity(7))
}identity : a -> a
identity = |value| value
main! = |_args| {
echo!(identity("same"))
echo!(identity(7.I64).to_str())
Ok({})
}Roc's generics are also not erased. A Kotlin
List<Int> is a List of boxed objects at run time, which is why reified exists and why is List<String> cannot be checked; Roc monomorphizes, so the type is real all the way down.An interface constraint becomes a where clause
A
where clause states what the function needs from its type variable — here, a describe method with that signature. It is Kotlin's T : Describable bound, made structural.interface Describable {
fun describe(): String
}
class Celsius(val degrees: Double) : Describable {
override fun describe(): String = "${degrees}°C"
}
fun <T : Describable> announce(value: T) {
println(value.describe())
}
fun main() {
announce(Celsius(21.5))
}Celsius := { degrees : Dec }.{
describe : Celsius -> Str
describe = |celsius| "${celsius.degrees.to_str()}°C"
}
announce! : a => {} where [a.describe : a -> Str]
announce! = |value| {
echo!(value.describe())
}
main! = |_args| {
announce!(Celsius.{ degrees: 21.5 })
Ok({})
}Nothing declares that
Celsius satisfies the clause: it has the method, so it fits. There is no interface to define, no override to write, and no way for a type you do not control to be locked out of a function because its author did not think of your interface.Control Flow
if is an expression in both
Both languages make
if an expression that produces a value, which is why neither has a ternary operator.fun main() {
val score = 85
val grade = if (score >= 90) "A"
else if (score >= 80) "B"
else "C"
println(grade)
}main! = |_args| {
score : I64
score = 85
grade = if score >= 90 {
"A"
} else if score >= 80 {
"B"
} else {
"C"
}
echo!(grade)
Ok({})
}The only differences are punctuation: Roc drops the parentheses around the condition and requires braces around each branch. In both, an
else is mandatory when the result is used.for ... in
The same keyword and the same shape, with the parentheses dropped. Roc's
for is available only in effectful code, because a loop that produces no value has nothing to do in a pure function.fun main() {
for (word in listOf("alpha", "beta", "gamma")) {
println(word)
}
}main! = |_args| {
for word in ["alpha", "beta", "gamma"] {
echo!(word)
}
Ok({})
}There is no range syntax — no
1..10, no downTo, no step — so a counted loop iterates a list or uses while. The same loop can also be written as a method, words.for_each!(|word| echo!(word)), which is the form that chains.while and break
Roc has a real
while loop with break, which surprises people expecting a functional language to insist on recursion. It needs a var, since a loop over an unchanging condition would never end.fun main() {
var count = 0
while (count < 5) {
count += 1
if (count == 3) {
break
}
}
println(count)
}main! = |_args| {
var $count = 0.I64
while $count < 5 {
$count = $count + 1
if $count == 3 {
break
}
}
echo!($count.to_str())
Ok({})
}There are no compound assignment operators, so
count += 1 is written out. There are no labeled breaks and no continue either, so a loop that wants them is usually asking to be a fold or a keep_if.Guard clauses and early return
return exists and does what you expect, so the guard-clause style transfers unchanged. The last expression of a block is its value, so the final line needs no return.fun clampPositive(number: Int): Int {
if (number < 0) {
return 0
}
return number
}
fun main() {
println(clampPositive(-5))
println(clampPositive(9))
}clamp_positive : I64 -> I64
clamp_positive = |number| {
if number < 0 {
return 0
}
number
}
main! = |_args| {
echo!(clamp_positive(-5).to_str())
echo!(clamp_positive(9).to_str())
Ok({})
}There is no labeled return (
return@forEach), which is the mechanism Kotlin needs because a lambda's return would otherwise leave the enclosing function. A Roc closure's return leaves the closure, with no ambiguity to label away.Tail recursion, without the modifier
A call in tail position — the last thing a function does — is compiled to a jump rather than a new stack frame. Roc does this always; Kotlin does it when you ask with
tailrec.tailrec fun sumTo(limit: Int, accumulator: Long): Long =
if (limit <= 0) accumulator
else sumTo(limit - 1, accumulator + limit)
fun main() {
println(sumTo(100000, 0))
}sum_to : I64, I64 -> I64
sum_to = |limit, accumulator| {
if limit <= 0 {
accumulator
} else {
sum_to(limit - 1, accumulator + limit)
}
}
main! = |_args| {
echo!(sum_to(100_000, 0).to_str())
Ok({})
}The
tailrec modifier is a good design: it is opt-in, and the compiler warns when a function marked with it is not actually tail-recursive. Drop the modifier from the Kotlin column and the same function overflows the stack.suspend vs the ! Marker
suspend colors for time; ! colors for everything
You already know this mechanism.
suspend colors a function so that only another colored function may call it, and the color propagates up the call stack. Roc does exactly that with ! — for every side effect rather than only for suspension.import kotlinx.coroutines.runBlocking
suspend fun fetchValue(): Int = 42
fun describe(name: String): String = "hello $name"
fun main() = runBlocking {
// A suspend function can only be called from
// another suspend function — the same
// propagation Roc applies to every effect.
println(fetchValue())
println(describe("Roc"))
}# Pure: Str -> Str (thin arrow)
describe : Str -> Str
describe = |name| "hello ${name}"
# Effectful: {} => I64 (fat arrow, name ends in !)
fetch_value! : {} => I64
fetch_value! = |{}| 42
main! = |_args| {
echo!(fetch_value!({}).to_str())
echo!(describe("Roc"))
Ok({})
}The consequence is the one
suspend has: a pure function that gains a print has to change its signature, and so does every caller. That propagation is the feature — it means a signature without ! is a guarantee rather than a convention.No coroutines, and no concurrency at all
The coloring idea carries over; the concurrency does not. There is no
async, no launch, no Flow, no Channel and no structured concurrency — none of it exists in the language.import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val first = async { 1 * 1 }
val second = async { 2 * 2 }
println(listOf(first.await(), second.await()))
}main! = |_args| {
# Roc has no coroutine, no thread, no async and
# no structured concurrency. Whether work can be
# spread across cores is the PLATFORM's
# decision, and the application cannot say.
results = [1.I64, 2].map(|number| number * number)
echo!(Str.inspect(results))
Ok({})
}The reasoning is the platform model: a platform written in Rust may well run an application's work across every core, and the application stays pure code that says what to compute rather than when. That is coherent, and next to
kotlinx.coroutines it is also the largest single thing this page asks you to give up.expect is part of the language
expect is a keyword rather than a library function, and it can appear at the top level of a file as well as inside one — which is how Roc writes unit tests without a testing framework.fun main() {
val total = 2 + 2
check(total == 4)
println("the assertion held")
}main! = |_args| {
total : I64
total = 2 + 2
expect total == 4
echo!("the assertion held")
Ok({})
}A failing
expect prints every value that fed the expression, not just the expression that was false. That is what a Kotlin assertion library like Kotest's power-assert gives you as a compiler plugin, and it is built in here.Extension Functions vs Methods
A class becomes a type with a method block
The
.{ } after a type definition is a block of functions associated with that type. There is no this — each function takes the value as an ordinary first parameter.class Counter(val value: Int = 0) {
fun increment(): Counter = Counter(value + 1)
fun describe(): String = "count is $value"
}
fun main() {
println(Counter().increment().increment().describe())
}Counter := { value : I64 }.{
new : () -> Counter
new = || { value: 0 }
increment : Counter -> Counter
increment = |{ value }| { value: value + 1 }
describe : Counter -> Str
describe = |counter| "count is ${counter.value.to_str()}"
}
main! = |_args| {
counter = Counter.new().increment().increment()
echo!(counter.describe())
Ok({})
}Method syntax works because the compiler resolves
counter.describe() from the type it already knows, so there is no vtable and no virtual call. There is also no inheritance at all: no open, no override, and no way to subclass a Roc type.No extension functions
Kotlin's extension functions are among its most-used features, and Roc has nothing like them: a type's method block is written where the type is, and nothing can add to it later.
// An extension adds a method to a type you do not
// own, resolved statically at the call site.
fun String.shout(): String = "$this!"
fun main() {
println("hello".shout())
}# A type's method block is fixed where the type is
# defined, and Str is not yours to reopen. Write a
# function instead:
shout : Str -> Str
shout = |text| "${text}!"
main! = |_args| {
echo!(shout("hello"))
Ok({})
}This is a real loss. What softens it is that Kotlin extensions are already static dispatch under a method-call spelling, so the Roc version is the same call with the receiver moved to the front — and that a Roc
where clause can accept any type carrying the right method, which covers a good share of what extensions are used for.A value class becomes :=
Defining a type with
:= rather than : makes it nominal — a genuinely distinct type that a plain number cannot stand in for. It is Kotlin's value class without the annotation.@JvmInline
value class UserId(val value: ULong)
fun greet(userId: UserId): String = "user #${userId.value}"
fun main() {
println(greet(UserId(42u)))
// greet(42u) does not compile.
}UserId := { value : U64 }
greet : UserId -> Str
greet = |user_id| "user #${user_id.value.to_str()}"
main! = |_args| {
user_id = UserId.{ value: 42 }
echo!(greet(user_id))
# greet(42) does not compile.
Ok({})
}Both are zero-cost: Kotlin inlines the wrapper where it can, and Roc monomorphizes so the wrapper never exists at run time. The Kotlin version still leaks on the JVM boundary — a
value class is boxed when used as a generic argument or a nullable — where the Roc one has no boundary to leak across.No JVM, No Collector
Reference counting, with no cycles to collect
The JVM needs a tracing collector precisely because of the fourth line here: two objects holding each other would keep each other alive forever under simple reference counting. Roc needs no collector, and this row is why.
class Peer(val name: String) {
var peer: Peer? = null
}
fun main() {
val first = Peer("first")
val second = Peer("second")
second.peer = first
first.peer = second // closing the cycle
println(second.peer?.name)
println(first.peer?.name)
}main! = |_args| {
first = { name: "first" }
second = { name: "second", peer: first }
# first cannot be made to point back at second:
# it was finished the moment it was defined, so
# this program has no second line to print.
echo!(second.peer.name)
Ok({})
}Nothing in Roc can be made to point back at a value that already exists, which is exactly the condition under which counting alone suffices — so the retain and release calls are inserted by the compiler and there is no collector, no generation and no pause to tune. The Kotlin column needs a
var and a nullable to build the cycle, which is the cost on screen.Functional updates that mutate when it is safe
Semantically
set builds a new list. When the reference count of the old one is one — nobody else is holding it — the compiler mutates in place and copies nothing.fun main() {
val numbers = listOf(1, 2, 3)
val updated = numbers.toMutableList() // a copy,
updated[1] = 99 // then mutate
println(updated)
println(numbers)
}main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3]
updated = numbers.set(1, 99) ?? numbers
echo!(Str.inspect(updated))
echo!(Str.inspect(numbers))
Ok({})
}That is why an immutable style costs less in Roc than
toMutableList() costs in Kotlin. Here both lists are printed, so both must exist and a copy really is made; drop the second echo! and the copy disappears.Top-level values are computed before the program starts
A top-level Roc definition is evaluated by the compiler. By the time the program runs,
squared is the constant 100 baked into the binary.const val limit = 10
val squared = limit * limit // computed at class
// initialization
fun main() {
println(squared)
}limit : I64
limit = 10
squared : I64
squared = limit * limit
main! = |_args| {
echo!(squared.to_str())
Ok({})
}Kotlin has
const val for the cases the compiler can fold, and it is limited to primitives and strings; anything else is a val initialized when its class is loaded. In Roc there is no distinction, and no class loading and no JVM startup behind it.Multiplatform vs Platforms
expect/actual and the platform model
This is the closest structural parallel between the two languages, and it is worth dwelling on. Kotlin Multiplatform splits a program into shared logic and per-target implementations; Roc splits every program into an application and a platform.
// Kotlin Multiplatform: shared code declares what
// it needs, and each target supplies it.
//
// expect fun currentPlatform(): String
// actual fun currentPlatform() = "JVM"
//
// The shared module is the application; the target
// supplies the pieces it cannot write itself.
fun currentPlatform(): String = "JVM"
fun main() {
println("running on ${currentPlatform()}")
}# Roc: the same split, made total. The application
# is pure; the PLATFORM owns the entry point, the
# allocator and every effect — and it is chosen at
# build time rather than per declaration.
main! = |_args| {
echo!("running on whatever platform built this")
Ok({})
}The difference is where the line falls. Kotlin's shared code may still perform effects — it is ordinary Kotlin — and
expect/actual covers only what differs. A Roc application performs no effects of its own, so the line is not a judgment call, and the same application can target a command-line tool, a server or a microcontroller unchanged.A native binary, with no runtime inside it
Roc compiles ahead of time to a native binary with no interpreter and no virtual machine. Startup is measured in microseconds rather than in JVM warm-up.
fun main() {
// The JVM starts before your code does: class
// loading, JIT warm-up, and a heap sized by
// flags. Kotlin/Native exists and is a
// different target with a different ecosystem.
println("hello, after the JVM started")
}main! = |_args| {
# A Roc binary has no virtual machine, no class
# loader and no collector to carry, because the
# platform supplies the runtime and memory
# management is compiled in.
echo!("hello, immediately")
Ok({})
}Kotlin/Native is the same ambition and it is a second target with a second memory model and a smaller library ecosystem — the JVM remains where the libraries are. Roc has one compilation model, and its equivalent question is which platform you build against.
Gotchas for Kotlin Programmers
An untyped integer prints as a decimal
This is the first thing that will confuse you. An unconstrained number literal defaults to
Dec, so a list that looks like integers prints as [1.0, 2.0, 3.0].fun main() {
println(listOf(1, 2, 3))
println(1 + 2)
}main! = |_args| {
# No annotation: these literals become Dec,
# and print with a decimal point.
echo!(Str.inspect([1, 2, 3]))
echo!(Str.inspect(1 + 2))
typed : List(I64)
typed = [1, 2, 3]
echo!(Str.inspect(typed))
Ok({})
}The fix is an annotation or a suffix:
typed : List(I64), or 42.I64 on the literal. Kotlin defaults an integer literal to Int and only widens on demand, which is the friendlier choice — but the habit of asking which numeric type you have transfers directly.A bare True is not a Bool
Bool is an ordinary tag union in Roc, and True and False written bare are just tags — not necessarily that union.fun main() {
val ready = false
println(!ready)
}main! = |_args| {
# Without the annotation, "False" is inferred as
# a one-off structural tag rather than a Bool,
# and ! would have nothing to negate.
ready : Bool
ready = Bool.False
echo!(Str.inspect(!ready))
Ok({})
}Annotating the binding, or writing
Bool.True and Bool.False in full, pins it down. It is the same inference rule that turns untyped numbers into Dec, seen from another angle.There is no working [i] on a list
Subscript syntax exists in the grammar and does not work, which is worse than not existing — the error it produces talks about type variables rather than about indexing.
fun main() {
val numbers = listOf(10, 20, 30)
println(numbers[0])
println(numbers.last())
}main! = |_args| {
numbers : List(I64)
numbers = [10, 20, 30]
# numbers[0] parses in this build but does not
# type-check into a usable value.
echo!((numbers.get(0) ?? 0).to_str())
echo!((numbers.last() ?? 0).to_str())
Ok({})
}Use
get(index), and first() or last() for the ends. Note that Roc's last() returns a Try where Kotlin's throws on an empty list, so the two spellings look alike and behave differently.No it, no trailing lambda, no scope functions
Roc has no implicit
it, no trailing-lambda syntax, and none of the scope functions — let, run, apply, also, with.fun main() {
val numbers = listOf(1, 2, 3)
println(numbers.map { it * 2 })
val label = numbers.first().let { "first is $it" }
println(label)
}main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3]
echo!(Str.inspect(numbers.map(|number| number * 2)))
# No let, run, apply, also or with. A binding
# does the same job with a name you chose:
first = numbers.first() ?? 0
echo!("first is ${first.to_str()}")
Ok({})
}Every lambda parameter is therefore named, which costs a few characters and removes the question of what
it refers to three levels into a chain. The scope functions mostly existed to work around nullability and to build objects with mutable properties, and neither problem exists here.The standard library is still settling
Roc is pre-1.0 and its standard library is visibly incomplete. Methods Kotlin has had since 1.0 are simply absent, and which ones are absent changes between nightly builds.
fun main() {
println("hello world".replace(" ", "_"))
println("shout".uppercase())
}main! = |_args| {
# There is no Str.replace in this build, and no
# case conversion — compose what exists:
parts = "hello world".split_on(" ")
echo!(Str.join_with(parts, "_"))
echo!("shout")
Ok({})
}There is also no regular-expression support, no serialization, no date and time library, and nothing resembling the JVM ecosystem behind it. Kotlin's library is among the best-designed of any mainstream language; this is not a fair comparison and it is a real one.
The honest comparison
Of the eight languages Roc is pitched against, Kotlin is the closest — it already has null safety, sealed hierarchies with exhaustive
when, data classes, immutable-by-default bindings and expression-oriented syntax.fun main() {
// Kotlin: null safety, sealed classes with
// exhaustive when, data classes, coroutines,
// the JVM ecosystem, and a 1.0 in 2016.
println("already most of the way there")
}main! = |_args| {
# Roc: no null at all, tags with no declaration,
# open unions, effects in the type system, no
# collector — and no 1.0, no coroutines, no
# extension functions and an ecosystem of dozens.
echo!("further on the type system, nowhere else")
Ok({})
}So the honest summary is narrow. Roc goes further on exactly one axis — removing
null entirely, dropping the declaration a union needs, allowing unions to stay open, putting all effects in the type system, and replacing the collector — and gives up coroutines, extension functions, default arguments, the JVM and fifteen years of libraries to do it. Read it for that axis.