Roc: Functional Programming Without the Complexity Tax
There is a recurring pattern in functional programming adoption: an engineer hears about Elm or Haskell, gets excited about the guarantees, opens the documentation, and then backs away slowly. Elm is friendly but browser-only and opinionated to the point of frustration. Haskell is powerful but arrives with a 30-year accumulation of concepts — monads, typeclasses, monad transformers, GADTs, rank-N types — that you need to understand before you can read a moderately serious codebase. OCaml and F# split the difference but come with their own runtime baggage and ecosystem assumptions.
Roc is a direct attempt to break this pattern. It was started by Richard Feldman, who was a core contributor to Elm, and the design brief is explicit: take the best parts of Elm — the friendly compiler, the total absence of runtime exceptions, the clean ML-inspired syntax — and build something that isn’t limited to the browser, compiles fast, and doesn’t require a PhD in category theory to use productively.
As of April 2026, Roc is at alpha4 status. That is an honest label: breaking changes still happen, the ecosystem is small, and you should not be running this in critical production infrastructure yet. But it is also a language with real, runnable code, a compelling design that solves real problems, and an unusually thoughtful set of tradeoffs. If you build CLI tools, services, or data pipelines and have been curious about what “type-safe functional programming” actually buys you in practice, Roc is worth several hours of your time.
This post covers the language from end to end: the design philosophy, the platforms model, the type system, tag unions and error handling, performance, practical examples, tooling, and an honest comparison with the alternatives.
What Roc Is and What It Wants to Be
The official tagline is “fast, friendly, functional.” Each word is load-bearing.
Fast means compiled to native code via LLVM, with an automatic reference counting memory model that uses static analysis to make mutation safe without a garbage collector. Roc does not have a GC pause problem. It also does not have Rust’s borrow checker friction — the compiler handles safe mutation transparently when it can prove it’s safe.
Friendly means the compiler error messages are designed to be actionable, not just accurate. It means there is no implicit typeclass resolution that produces error messages pointing to the wrong place. It means the language deliberately omits features — higher-kinded types, arbitrary-rank types, typeclasses — not because the designers didn’t understand them, but because they made a deliberate tradeoff: decidable principal type inference (the compiler can always infer your types, 100% of the time) is worth more than the expressiveness those features provide.
Functional means pure functions, immutable data, algebraic types with exhaustive pattern matching, and errors as values. No null. No exceptions. No implicit side effects. Everything that touches the outside world is explicit.
The design philosophy can be summarized as: make the 90% case easy, and do not expose every user to complexity that only 5% will need. This is a different philosophy from Haskell, which optimizes for expressiveness at the cost of approachability. It is closer to Elm’s philosophy, but without Elm’s browser constraint and with more performance ambition.
Richard Feldman has talked publicly about Roc’s goal of being the language he wished Elm was — more general-purpose, faster, usable on the server, with a more ergonomic error model and a platform architecture that makes effects explicit at a systems level rather than through the Elm Architecture pattern.
The Platforms Model: Roc’s Most Distinctive Feature
The single most interesting design decision in Roc is one that sounds strange at first: Roc has no I/O primitives in the language or standard library. You cannot open a file from Roc. You cannot make an HTTP request. You cannot read from stdin. There is no println in the standard library.
Instead, every Roc application runs on a platform, and the platform provides all I/O operations. The Roc standard library contains only pure data structure operations — List, Dict, Set, Str, Num, and so on. Everything else comes from the platform.
This is not a limitation. It is the architecture.
Here is what a minimal Roc application looks like using the basic-cli platform:
app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }
import pf.Stdout
main! : List Arg => Result {} _
main! = |_args|
Stdout.line!("Hello from Roc!")
The app keyword declares this is an application module. [main!] lists which functions the platform can call (the entrypoints). The pf: part declares a package dependency with a URL and an embedded cryptographic hash — the package is downloaded once and verified on every subsequent use, no package registry required.
The ! suffix on main! and Stdout.line! is significant: it marks these as effectful functions. Pure functions in Roc have no suffix. When you see a !, you know the function interacts with the outside world through the platform. This is not an optional convention; the type system enforces it.
Why This Architecture Matters
The platforms model has several properties that are not immediately obvious.
Your application code is maximally portable. The pure logic in your Roc code — the data transformations, business rules, parsing, validation — has zero platform dependencies. You can run exactly that code on a CLI platform, a webserver platform, a WebAssembly platform, or a custom embedded platform. The same parse_config function works everywhere.
Platforms own all I/O, which means they own all scheduling. A webserver platform can run your Roc request handler in a thread pool. A WebAssembly platform can refuse to expose file I/O entirely. A hypothetical “safe scripting” platform could prompt the user before any filesystem access, because it controls all the operations. There is no escape hatch.
Security by construction. Because there is literally no way to call a filesystem function without the platform providing it, a platform can create hard guarantees about what code running on it can do. No Roc application can exfiltrate data over the network if the platform does not expose networking.
Testing becomes easier. Your pure business logic takes inputs and returns outputs. No mocking, no dependency injection, no test doubles for I/O. You just call the function with test inputs and check the result.
Available Platforms
The two most useful platforms today are:
basic-cli — For command-line applications. Provides file I/O, HTTP client, stdin/stdout, environment variables, command execution, TCP, SQLite, and more. Built in Rust on top of Tokio.
basic-webserver — For HTTP services. Your application exposes an init! function (called once at startup) and a respond! function (called per request). The platform handles all the HTTP machinery — it uses Rust’s hyper and Tokio underneath.
There are also community platforms for WebAssembly, game development (roc-wasm4 for WASM-4), robots, and .NET embedding. Building a new platform is well-documented: you write a host in any language that can produce a C-compatible binary, implement the memory allocation interface, and expose the effects you want as Roc-callable functions.
Core Language and Syntax
Roc’s syntax is clean ML-style with some cosmetic differences from Elm. If you’ve read Elm or OCaml code before, Roc will feel immediately familiar.
Functions and Definitions
Functions are defined with the |args| lambda syntax. There is no def or fn keyword — a function is just a value that happens to be a lambda:
# A simple function
add = |a, b| a + b
# A multi-line function
greet = |name|
"Hello, ${name}! Welcome to Roc."
# Calling functions — standard function call syntax
result = add(3, 4)
message = greet("World")
All definitions are constants. You cannot reassign a name in the same scope. This is not a limitation you work around; it is the foundation of referential transparency.
String Interpolation
Roc uses ${} for string interpolation. You can put any single-line expression inside:
name = "Alice"
count = 42
# Basic interpolation
greeting = "Hello, ${name}!"
# Expression interpolation
summary = "There are ${Num.to_str(count)} items."
# Nested
message = "User ${Str.to_upper(name)} has ${Num.to_str(count * 2)} points."
if/then/else as Expressions
if is an expression, not a statement. Every if must have both then and else branches, because the whole expression must evaluate to a value:
classify_number = |n|
if n > 0 then
"positive"
else if n < 0 then
"negative"
else
"zero"
# Used in a larger expression
label = "The answer is ${if answer == 42 then "correct" else "wrong"}."
Records
Records are the primary product type. They are structural — two records with the same fields and types are the same type, regardless of what you call them:
# Creating records
user = { name: "Alice", age: 30, role: Admin }
# Accessing fields
user_name = user.name
# Record update syntax (creates a new record)
older_user = { user & age: 31 }
# Destructuring
{ name, age } = user
# Shorthand when field name matches variable name
name = "Bob"
age = 25
bob = { name, age }
# Functions that accept records are structurally typed —
# this function works on any record with at least a 'name' field
greet_user = |{ name }| "Hello, ${name}!"
Lists
Lists are homogeneous (all elements must have the same type), persistent, and support a rich set of operations:
numbers = [1, 2, 3, 4, 5]
# Map, filter, fold
doubled = List.map(numbers, |n| n * 2)
evens = List.keep_if(numbers, Num.is_even)
sum = List.walk(numbers, 0, |acc, n| acc + n)
# Appending and building
extended = List.append(numbers, 6)
combined = List.concat(numbers, [6, 7, 8])
# Safe access — returns Result
first_result = List.first(numbers) # Ok(1)
at_result = List.get(numbers, 10) # Err(OutOfBounds)
Pattern Matching with when
when is Roc’s pattern matching construct. It is exhaustive — the compiler will tell you if you miss a case:
describe = |value|
when value is
0 -> "zero"
1 -> "one"
n if n < 0 -> "negative: ${Num.to_str(n)}"
n -> "large: ${Num.to_str(n)}"
# Pattern matching on lists
describe_list = |lst|
when lst is
[] -> "empty"
[only] -> "singleton: ${Num.to_str(only)}"
[first, second, ..] -> "starts with ${Num.to_str(first)} and ${Num.to_str(second)}"
The ! Suffix and Effectful Code
Any function that performs I/O through the platform has a ! suffix. This is enforced by the type system — effectful functions have a type using => instead of ->. This means at a glance you can tell whether a function is pure:
# Pure function — no suffix, uses ->
double : U64 -> U64
double = |n| n * 2
# Effectful function — ! suffix, uses =>
print_doubled! : U64 => Result {} [StdoutErr _]
print_doubled! = |n|
result = double(n)
Stdout.line!("Result: ${Num.to_str(result)}")
The => arrow signals “this function performs effects.” The ! suffix is the visual marker. Together they make the boundary between pure and impure code impossible to miss in a code review.
The Type System
Roc uses Hindley-Milner type inference — the same family of inference used by Haskell, OCaml, Elm, and F#. This means you rarely need to write type annotations; the compiler infers everything. When you do write annotations, they are checked against the inference, so they can never go stale.
Inferred Types
# No annotation needed — compiler infers Str -> Str
shout = |s| Str.concat(s, "!")
# Inferred as Num *, Num * -> Num *
add = |a, b| a + b
# Inferred as List (Num *) -> List (Num *)
double_all = |lst| List.map(lst, |n| n * 2)
Type Annotations
Annotations are optional but useful for documentation and for catching logic errors:
# Type annotations use the colon syntax
full_name : { first : Str, last : Str } -> Str
full_name = |{ first, last }| "${first} ${last}"
# Type aliases
User : { name : Str, age : U64, role : [Admin, Viewer, Editor] }
greet : User -> Str
greet = |user| "Hello, ${user.name}!"
Structural Typing
Records in Roc are structurally typed, not nominally typed. There are no struct declarations or named types you must match exactly. If a function accepts { name : Str }, it will accept any record that has at least a name field of type Str:
# This function accepts any record with a 'name' field
get_name = |{ name }| name
# All of these work — no inheritance, no interfaces
result1 = get_name({ name: "Alice" })
result2 = get_name({ name: "Bob", age: 30 })
result3 = get_name({ name: "Charlie", role: Admin, active: Bool.true })
This is structural subtyping done through type inference, with no explicit declaration. It is one of the most ergonomic aspects of the language for working with data pipelines.
Type Variables and Wildcard Types
Roc supports polymorphic functions through type variables (lowercase in annotations) and the wildcard *:
# 'a' is a type variable — works on List of any type
identity : a -> a
identity = |x| x
# List * means "list of anything"
is_empty : List * -> Bool
is_empty = |lst| List.len(lst) == 0
# Result type — polymorphic over both ok and err values
safe_divide : F64, F64 -> Result F64 [DivisionByZero]
safe_divide = |a, b|
if b == 0.0 then
Err(DivisionByZero)
else
Ok(a / b)
Numeric Types
Roc has a rich numeric hierarchy: U8, U16, U32, U64, U128 for unsigned integers; I8, I16, I32, I64, I128 for signed integers; F32, F64 for floats; and Dec for fixed-point decimal with 18 decimal places (useful for financial calculations). The general Num * type works when you don’t care about the specific representation.
Tag Unions: Roc’s Sum Types
Tag unions are how Roc models “this value can be one of several things.” They are equivalent to sum types in other functional languages, algebraic data types in Haskell, or variants in OCaml — but with Roc’s structural typing applied.
Basic Tags
Tags are values that start with an uppercase letter. They can carry payloads or stand alone:
# Standalone tags
status = Active
direction = North
# Tags as the result of pattern matching
describe_bool = |b|
when b is
Bool.true -> Yes
Bool.false -> No
# Tags with payloads
color =
if something > 100 then
Red
else if something > 0 then
Yellow
else
Custom(40, 60, 80) # RGB payload
# Pattern matching on tags with payloads
color_str =
when color is
Red -> "red"
Yellow -> "yellow"
Custom(r, g, b) -> "rgb(${Num.to_str(r)}, ${Num.to_str(g)}, ${Num.to_str(b)})"
Open vs. Closed Tag Unions
This is one of Roc’s more sophisticated features, and it maps directly to practical error handling patterns.
A closed tag union has a fixed set of tags. When you annotate a type explicitly with a specific set of tags, you get a closed union — exhaustive pattern matching is enforced:
# Closed union — exactly these three tags
Direction : [North, South, East, West]
move : Direction -> Str
move = |dir|
when dir is
North -> "moving north"
South -> "moving south"
East -> "moving east"
West -> "moving west"
An open tag union uses _ or [TagA, TagB]_ syntax to indicate the union may contain additional tags not listed. This is what allows functions to return errors that callers can extend:
# The _ at the end means "and possibly other tags too"
parse_port : Str -> Result U16 [InvalidPort, OutOfRange]_
parse_port = |s|
when Str.to_u64(s) is
Err(_) -> Err(InvalidPort)
Ok(n) if n > 65535 -> Err(OutOfRange)
Ok(n) -> Ok(Num.to_u16_saturate(n))
Open tag unions are what make error propagation ergonomic. When you use ? to propagate errors up the call stack, the error types from different functions are merged into an open union — you don’t need to define a grand unified error enum upfront.
Using Tags as Error Types
The idiomatic Roc approach to errors is to use descriptive tag unions:
ConfigError : [
FileMissing Str,
ParseError Str,
MissingField Str,
InvalidValue { field : Str, value : Str }
]
load_config : Str -> Result Config ConfigError
load_config = |path|
content = File.read_utf8!(path) |> Result.map_err(|_| FileMissing(path))?
parsed = parse_toml(content) |> Result.map_err(|e| ParseError(Inspect.to_str(e)))?
validate_config(parsed)
When you pattern match on these errors, the compiler forces you to handle every case, and the tag names are self-documenting:
when load_config("settings.toml") is
Ok(config) -> run_with_config(config)
Err(FileMissing(path)) -> Stderr.line!("Config file not found: ${path}")
Err(ParseError(msg)) -> Stderr.line!("Failed to parse config: ${msg}")
Err(MissingField(field)) -> Stderr.line!("Required field missing: ${field}")
Err(InvalidValue({ field, value })) ->
Stderr.line!("Invalid value '${value}' for field '${field}'")
No Runtime Exceptions
This is the headline guarantee: a correctly compiled Roc program will not throw an unexpected runtime exception. There is no NullPointerException, no undefined is not a function, no KeyError, no IndexError.
The mechanism is total functions everywhere. Operations that can fail return Result values. The compiler enforces that you handle both branches before moving on.
The Result Type
Result is a built-in type alias for a two-tag union:
Result ok err : [Ok ok, Err err]
Every fallible operation returns a Result. You work with it through pattern matching or through the helper operators:
# Pattern matching
when List.first(my_list) is
Ok(first) -> Stdout.line!("First: ${Num.to_str(first)}")
Err(ListWasEmpty) -> Stdout.line!("List is empty")
# The ? operator — propagates Err upward, unwraps Ok
parse_and_double : Str -> Result U64 [InvalidNumStr]
parse_and_double = |s|
n = Str.to_u64(s)? # If Err, returns Err(InvalidNumStr) immediately
Ok(n * 2)
# The ?? operator — provides a default on Err
value = potentially_fails() ?? default_value
# Result.with_default
safe_result = List.get(my_list, 5) |> Result.with_default(0)
The ? Operator
The ? postfix operator is the primary tool for building error-propagating pipelines without nested pattern matches. It is sugar for Result.try:
process_config! : Str => Result Config [FileMissing Str, ParseError Str, ValidationError Str]
process_config! = |config_path|
# Each `?` either unwraps Ok or returns the Err immediately
raw_content = File.read_utf8!(config_path)
|> Result.map_err(|_| FileMissing(config_path))?
parsed = parse_config(raw_content)
|> Result.map_err(|e| ParseError(Inspect.to_str(e)))?
validated = validate_config(parsed)
|> Result.map_err(|e| ValidationError(e))?
Ok(validated)
This is Roc’s answer to async/await for error handling — you write the happy path linearly, and errors propagate automatically. The difference from exceptions is that the types tell you exactly what can go wrong, and callers know about it at compile time.
crash for Unreachable Code
There is one escape hatch: the crash keyword. It is for branches that you are certain will never be reached. If they somehow are reached, the program terminates immediately — no recovery:
# This is safe because we've validated the bytes externally
to_string_unchecked : List U8 -> Str
to_string_unchecked = |bytes|
when Str.from_utf8(bytes) is
Ok(s) -> s
Err(_) -> crash "Bytes were validated as UTF-8 before calling this"
# Using crash as a TODO placeholder during development
handle_event = |event|
when event is
Click(pos) -> process_click(pos)
KeyPress(key) -> process_key(key)
Resize(_) -> crash "TODO: handle resize events"
crash is a language keyword, not a function — you cannot pass it around or assign it. The string message is passed to the platform as context; what the platform does with it is up to the platform. The basic-cli platform will exit with a nonzero code and print the message. A webserver platform might log it and return an HTTP 500.
Importantly, crash is not a runtime exception mechanism. You cannot catch it. It is a deliberate “this should never happen, and if it does, terminate” marker. It is semantically different from exceptions: there is no try/catch, no recovery, no partial state cleanup. This is a useful property — it forces you to be honest about what is actually a program logic error versus what is a recoverable runtime condition.
Practical Code: A CLI Application
Here is a complete, working CLI application using basic-cli that reads a file, processes it, and writes output:
app [main!] {
pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br",
}
import pf.Stdout
import pf.Stderr
import pf.File
import pf.Arg exposing [Arg]
# Entry point — takes CLI args, returns Result so errors propagate cleanly
main! : List Arg => Result {} _
main! = |args|
when List.get(args, 1) is
Err(OutOfBounds) ->
Err(Exit(1, "Usage: word-count <filename>"))
Ok(path_arg) ->
path = Arg.display(path_arg)
count_words!(path)
count_words! : Str => Result {} _
count_words! = |path|
content = File.read_utf8!(path)
|> Result.map_err(|_| Exit(1, "Could not read file: ${path}"))?
stats = analyze_text(content)
Stdout.line!("File: ${path}")?
Stdout.line!("Lines: ${Num.to_str(stats.lines)}")?
Stdout.line!("Words: ${Num.to_str(stats.words)}")?
Stdout.line!("Characters: ${Num.to_str(stats.chars)}")
# Pure function — no ! suffix, no effects
TextStats : { lines : U64, words : U64, chars : U64 }
analyze_text : Str -> TextStats
analyze_text = |text|
lines = text
|> Str.split("\n")
|> List.len
words = text
|> Str.split_on_each([' ', '\n', '\t'])
|> List.keep_if(|w| Str.count_graphemes(w) > 0)
|> List.len
chars = Str.count_graphemes(text)
{ lines, words, chars }
Notice the clear separation: analyze_text is a pure function you can unit-test with no setup. count_words! and main! are effectful. The ! suffix makes this obvious.
Practical Code: HTTP Requests and JSON
Here is how HTTP and JSON work with basic-cli and the roc-json package:
app [main!] {
pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br",
json: "https://github.com/lukewilliamboswell/roc-json/releases/download/0.13.0/RqendgZw5e1RsQa3kFhgtnMP8efWoqGRsAvubx4-zus.tar.br",
}
import pf.Http
import pf.Stdout
import json.Json
import pf.Arg exposing [Arg]
# Type alias for the JSON structure we expect
GithubUser : {
login : Str,
name : Str,
public_repos : U64,
followers : U64,
}
main! : List Arg => Result {} _
main! = |_args|
# GET request that decodes JSON directly into our type
user : GithubUser
user = Http.get!("https://api.github.com/users/roc-lang", Json.utf8)?
Stdout.line!("Login: ${user.login}")?
Stdout.line!("Name: ${user.name}")?
Stdout.line!("Public repos: ${Num.to_str(user.public_repos)}")?
Stdout.line!("Followers: ${Num.to_str(user.followers)}")
# For more control, use Http.send! with a full request spec
fetch_with_headers! : Str => Result Str _
fetch_with_headers! = |url|
response = Http.send!(
{
method: GET,
headers: [
Http.header(("Accept", "application/json")),
Http.header(("User-Agent", "my-roc-app/1.0")),
],
uri: url,
body: [],
timeout_ms: TimeoutMilliseconds(5000),
},
)?
Str.from_utf8(response.body)
|> Result.map_err(|_| InvalidUtf8Response)
The JSON decoding here is using the Decode typeclass-like capability that Roc builds into the compiler. You do not need to write field-by-field deserialization; the compiler derives it from the type annotation on user.
Practical Code: A Basic Webserver
The basic-webserver platform structures your application around two entry points: init! (once at startup) and respond! (once per request):
app [Model, init!, respond!] {
pf: platform "https://github.com/roc-lang/basic-webserver/releases/latest",
}
import pf.Http exposing [Request, Response]
import pf.Stdout
import pf.Utc
# Model holds any state initialized once at startup
# (database connections, config, etc.)
Model : {
start_time : Str,
version : Str,
}
init! : {} => Result Model []
init! = |{}|
now = Utc.to_iso_8601(Utc.now!({}))
Ok({ start_time: now, version: "1.0.0" })
respond! : Request, Model => Result Response [ServerErr Str]_
respond! = |req, model|
# Log the request
Stdout.line!("${req.method |> Inspect.to_str} ${req.uri}")?
# Route on the URI
when req.uri is
"/" ->
Ok(html_response(200, "<h1>Welcome</h1><p>Server version: ${model.version}</p>"))
"/health" ->
Ok(json_response(200, "{\"status\":\"ok\",\"started\":\"${model.start_time}\"}"))
path ->
Ok(html_response(404, "<h1>Not Found</h1><p>No handler for ${path}</p>"))
# Helper to build HTML responses
html_response : U16, Str -> Response
html_response = |status, body|
{
status,
headers: [("Content-Type", "text/html; charset=utf-8")],
body: Str.to_utf8(body),
}
# Helper to build JSON responses
json_response : U16, Str -> Response
json_response = |status, body|
{
status,
headers: [("Content-Type", "application/json")],
body: Str.to_utf8(body),
}
The key point here is what respond! is: it is a function that takes a Request and a Model and returns a Response. The platform handles all the TCP work, thread pooling, and request parsing. Your code is purely about transforming a request into a response.
Practical Code: String Processing and Data Pipelines
Roc’s pipeline operator (|>) and list operations make data transformation code clean and readable:
# Parse a CSV-like log line into a structured record
LogEntry : {
timestamp : Str,
level : [Debug, Info, Warn, Error],
message : Str,
}
ParseError : [
MalformedLine Str,
UnknownLevel Str,
]
parse_log_line : Str -> Result LogEntry ParseError
parse_log_line = |line|
parts = Str.split(line, "|")
when parts is
[ts, level_str, msg] ->
level = parse_level(Str.trim(level_str))?
Ok({
timestamp: Str.trim(ts),
level,
message: Str.trim(msg),
})
_ ->
Err(MalformedLine(line))
parse_level : Str -> Result [Debug, Info, Warn, Error] ParseError
parse_level = |s|
when s is
"DEBUG" -> Ok(Debug)
"INFO" -> Ok(Info)
"WARN" -> Ok(Warn)
"ERROR" -> Ok(Error)
other -> Err(UnknownLevel(other))
# Process a list of lines, collect results
process_logs : List Str -> { parsed : List LogEntry, errors : List Str }
process_logs = |lines|
List.walk(
lines,
{ parsed: [], errors: [] },
|state, line|
when parse_log_line(line) is
Ok(entry) -> { state & parsed: List.append(state.parsed, entry) }
Err(MalformedLine(l)) ->
{ state & errors: List.append(state.errors, "Malformed: ${l}") }
Err(UnknownLevel(lvl)) ->
{ state & errors: List.append(state.errors, "Unknown level: ${lvl}") }
)
# Filter and count by level using pipeline operators
count_errors : List LogEntry -> U64
count_errors = |entries|
entries
|> List.keep_if(|e| e.level == Error)
|> List.len
# Extract just the messages for a given level
messages_at_level : List LogEntry, [Debug, Info, Warn, Error] -> List Str
messages_at_level = |entries, target_level|
entries
|> List.keep_if(|e| e.level == target_level)
|> List.map(.message)
The .message shorthand in the last line — List.map(.message) — is a point-free accessor. .field evaluates to a function that takes a record and returns that field’s value.
Testing in Roc
Roc has first-class testing via the expect keyword. Tests live inline in source files and are run with roc test:
# Tests are expect statements at the module level
pluralize = |singular, plural, count|
count_str = Num.to_str(count)
if count == 1 then
"${count_str} ${singular}"
else
"${count_str} ${plural}"
expect pluralize("cactus", "cacti", 1) == "1 cactus"
expect pluralize("cactus", "cacti", 2) == "2 cacti"
expect pluralize("cactus", "cacti", 0) == "0 cacti"
# For more context in test failures, use a block form
expect
result = parse_log_line("2026-01-01|INFO|Server started")
result == Ok({ timestamp: "2026-01-01", level: Info, message: "Server started" })
# Test that error cases work correctly
expect
result = parse_log_line("malformed")
when result is
Err(MalformedLine(_)) -> Bool.true
_ -> Bool.false
Running roc test main.roc executes all expect statements and reports failures. The tests can test pure functions directly — no test framework setup, no mocking layer, no test runner configuration.
One current limitation: when a test fails, Roc shows which expectation failed but doesn’t yet show the actual vs. expected values. This is a known gap being worked on.
Modules and Packages
Module Types
Roc has five module types:
- app — an application; the thing you compile and run. Has exactly one platform.
- module — a collection of types and functions. Imported by other modules.
- package — organizes multiple modules to share across applications. Distributed by URL.
- platform — provides I/O primitives and memory management. Usually written in Rust or another systems language.
- hosted — lists the Roc types and functions that a platform’s host provides.
A simple two-file application:
# Utils.roc — a module
module [word_count, char_count]
## Count words in a string
word_count : Str -> U64
word_count = |text|
text
|> Str.split(" ")
|> List.keep_if(|w| w != "")
|> List.len
## Count characters in a string
char_count : Str -> U64
char_count = |text|
Str.count_graphemes(text)
# main.roc — the application
app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }
import pf.Stdout
import Utils exposing [word_count, char_count]
main! = |_args|
text = "the quick brown fox"
Stdout.line!("Words: ${Num.to_str(word_count(text))}")?
Stdout.line!("Chars: ${Num.to_str(char_count(text))}")
Package Management
Roc has no central package registry. Packages are referenced by URL with an embedded BLAKE3 hash for verification. The URL can be a GitHub release artifact, a Gitea instance, or any HTTPS URL that serves a .tar.br (Brotli-compressed tar) file:
app [main!] {
pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br",
json: "https://github.com/lukewilliamboswell/roc-json/releases/download/0.13.0/RqendgZw5e1RsQa3kFhgtnMP8efWoqGRsAvubx4-zus.tar.br",
parser: "https://github.com/lukewilliamboswell/roc-parser/releases/download/0.9.0/w8YR4proqo9oGEhV7HRaC_7HZCJG5E4bSUoXlmIFoKE.tar.br",
}
On first run, the package is downloaded and cached locally. On subsequent runs, the cache is used and the hash is verified. This is reproducible by construction — the hash is in your source file, so you cannot accidentally upgrade to a breaking version.
Tooling
The Roc toolchain is a single binary that provides everything:
|
|
The REPL
The REPL is genuinely useful for learning and experimentation. It supports multi-line expressions, shows types of everything, and is also available online at roc-lang.org/repl:
$ roc repl
The rockin' roc repl
────────────────────────
Enter an expression, or :help, or :q to quit.
» "hello" |> Str.to_upper
"HELLO" : Str
» List.map([1, 2, 3], |n| n * n)
[1, 4, 9] : List (Num *)
» { name: "Alice", age: 30 }
{ age: 30, name: "Alice" } : { age : Num *, name : Str }
Editor Support
The Roc Language Server (LSP) provides completions, type-on-hover, go-to-definition, and inline error display for any editor that supports LSP. VS Code has an official extension. Neovim users can configure it through mason or manually.
The formatter is single-opinionated (no configuration file) and is designed to run in CI. There is no debate about formatting in Roc codebases.
Performance
Roc compiles to native code through an LLVM backend. The current primary compilation path produces optimized native binaries comparable in performance to C or Rust for CPU-bound work, without the complexity of manual memory management.
Seamless Immutability
Roc’s memory model is called seamless immutability. All values are immutable by default, but the compiler uses reference counting plus static analysis to determine when it is safe to mutate a value in place. If you have the only reference to a list and you call List.append, the compiler can make that an in-place append rather than a copy. You write pure functional code; the compiler generates mutation where it is safe.
This is different from Rust’s borrow checker approach (explicit ownership transfer, compile-time lifetime analysis) and different from garbage collection (no GC pauses, no tracing). It is closer to Rust in performance characteristics, but the ergonomics are functional.
The technical mechanism is reference counting with compile-time uniqueness analysis. When the compiler can prove at compile time that a reference count will be exactly 1 at a particular point, it replaces the reference count decrement/increment with a direct mutation. In practice, many list and string operations that look like they produce copies are compiled as mutations.
Memory Management by Platform
Because platforms control the allocator (they provide the equivalent of malloc and free), they can implement domain-specific memory strategies. The nea experimental webserver platform uses per-request arena allocation — each incoming request gets an arena, allocations within the request handler go into that arena, and when the response is sent the entire arena is freed in a single operation. Allocations become almost as cheap as stack allocations, and there is no GC pressure.
This is possible because Roc code does not talk to a fixed allocator. It talks to whatever the platform provides.
Compilation Speed
The Roc compiler has always been designed with compilation speed as a first-class concern. Several design decisions serve this goal directly:
- Parallel name resolution (possible because there is no “import everything” syntax, so each module’s name resolution is independent)
- No higher-kinded types or arbitrary-rank types (these make type inference undecidable and require constraint-solving that can be exponential)
- Module-level parallelism in type checking
The new compiler being written in Zig (replacing the original Rust implementation) is a clean-sheet rewrite specifically focused on compilation speed, with early benchmarks suggesting substantially faster compile times. As of this writing it is not yet feature-complete enough for general use, but it is where active development effort is focused.
Roc vs. the Alternatives
Roc vs. Elm
Roc is the obvious comparison because Richard Feldman came from the Elm core team. The relationship is explicit: Roc is what he wanted Elm to be after learning the lessons of building Elm applications in production.
The key differences:
- Platform: Elm is browser-only. Roc runs anywhere a platform exists: CLI, server, WebAssembly, embedded.
- Effects model: Elm uses The Elm Architecture (TEA) with a
Cmd/Submodel for effects. Roc uses the platforms model with explicit!functions. The Roc approach is simpler to reason about outside the browser context. - Performance: Roc compiles to native code. Elm compiles to JavaScript.
- Packages: Elm has a central registry (package.elm-lang.org) with enforced semantic versioning. Roc uses URL-based packages with hash verification.
- Maturity: Elm is stable and production-ready. Roc is pre-1.0 alpha.
- No Maybe type: Roc deliberately omits
Maybe/Optionin favor ofResultwith descriptive error tags. This is a Roc design choice, not inherited from Elm.
If you are building a browser UI application today and want a mature, production-tested functional language, Elm is the better choice. If you want to build server software or CLI tools with Elm-like guarantees, Roc is the only option in this design family.
Roc vs. Haskell
The contrast is starker. Haskell is one of the most expressive type systems in mainstream use, at the cost of significant complexity: typeclasses, monad transformers, higher-kinded types, lazy evaluation, the IO monad, ST, STM, ReaderT, and decades of evolved idioms that are not explained in introductory material.
Roc deliberately omits all of this:
- No typeclasses: Roc uses structural typing and type-directed dispatch instead. There is no
Functor,Monad,Applicativehierarchy you need to understand. - No higher-kinded types: This is a deliberate tradeoff for decidable type inference.
- No lazy evaluation: Roc is strict. This makes performance more predictable.
- No IO monad: Roc’s effects model is the platforms architecture — effects are explicit through
!functions, but you do not compose effects through monadic bind. - No runtime exceptions: Both Haskell and Roc aim for this, but Haskell still has
error,undefined, and partial functions in the Prelude that can throw at runtime. Roc’scrashis the only equivalent, and it is explicitly marked.
The tradeoff is expressive power. Roc cannot express some abstractions that Haskell can. For example, you cannot write a generic traverse that works across arbitrary applicatives. But for most application code — parsing, HTTP handlers, data pipelines, CLI tools — Roc’s simpler model covers the same territory with less ceremony.
If you need Haskell’s expressiveness for category-theory-level abstractions or advanced type-level programming, Haskell is clearly the better choice. If you want the benefits of functional programming (immutability, total functions, no runtime exceptions) without the ceremony, Roc is more accessible.
Roc vs. F#
F# is a mature, production-ready functional language running on .NET. It is practical, has great tooling, and integrates easily with the C# ecosystem. The tradeoffs versus Roc:
- Runtime: F# runs on the .NET CLR with GC. Roc compiles to native code. For most applications this doesn’t matter, but for latency-sensitive or resource-constrained environments it can.
- Purity: F# allows mutation and has no enforcement of the pure/impure boundary. Roc enforces it via the type system.
- Effects: F# interoperates freely with .NET’s I/O APIs. Roc requires a platform for all I/O.
- Null safety: F# has
Option, not null by default. Roc hasResultwith tag errors, no null. - Ecosystem: F# has the entire .NET ecosystem. Roc has a small but growing set of packages.
- Stability: F# is stable and production-ready. Roc is pre-1.0.
F# is the pragmatic choice today if you need to ship production software with functional style on .NET. Roc is more principled but not yet production-ready.
Roc vs. OCaml
OCaml is fast, has a long history, and is increasingly popular (Jane Street’s use, the Reason/ReScript ecosystem). Comparing:
- Syntax: Roc’s syntax is arguably cleaner and more consistent than OCaml’s. OCaml has some historical quirks.
- Effects: OCaml 5.0+ has native algebraic effects. Roc has the platforms model. Both give you controlled effects with different ergonomics.
- Type system: OCaml has first-class modules and functors. Roc does not. OCaml’s module system is more powerful; Roc’s structural records are more ergonomic for common use cases.
- Performance: Both are fast. OCaml 5’s multicore runtime is ahead of Roc’s current state for parallel workloads.
- Null safety: OCaml has
option, no null. Roc hasResultwith tags. - Ecosystem: OCaml’s ecosystem (opam) is mature. Roc’s is small.
- Maturity: OCaml is stable and production-ready. Roc is pre-1.0.
For systems programming and performance-critical software today, OCaml is the stronger choice. Roc’s advantage is the cleaner effects model via platforms and the friendlier error messages.
An Honest Assessment
Roc is an alpha language. That label is accurate and important.
What “Alpha” Means in Practice
- Breaking changes happen. Syntax and semantics are still being refined. Code you write today against alpha4 may not compile with the next release without modification. The transition from the old Rust-based compiler to the new Zig-based compiler is ongoing and introduces syntax changes.
- Ecosystem is small. Outside of basic-cli, basic-webserver, roc-json, roc-parser, and a handful of other packages, you are largely building things yourself.
- The platforms model is a constraint, not just a feature. You cannot call a C library directly from Roc — a platform author has to wrap it and expose it. If you need SQLite, MQTT, WebSockets, or any other library, you need a platform that provides it, or you need to write the platform glue yourself.
- Documentation gaps. The tutorial and official docs are good but not comprehensive. Building a platform from scratch is possible but requires reading examples more than following a guide.
- Not production-ready for critical systems. Use it for tools, experiments, hobby projects, and non-critical services where you can absorb breakage.
Where Roc Is Compelling Today
Despite the alpha status, there are clear use cases where Roc offers a genuinely better experience than the alternatives:
CLI tools and scripts where you want more structure than Bash but less ceremony than Rust. The basic-cli platform is solid, error handling is clear, and the platform model keeps your business logic clean.
Data processing pipelines where the pure/impure separation and exhaustive pattern matching prevent entire classes of bugs. If you are parsing and transforming structured data, Roc’s type system catches mismatches at compile time.
Services where Elm-like guarantees are desirable but you are not in a browser. If you have appreciated Elm’s “no runtime errors” promise and want something similar for backend code, Roc is the closest thing available.
Learning functional programming in an environment that does not bury you in category theory. Roc teaches the genuinely useful parts of functional programming — immutability, algebraic types, total functions, pipelines — without making you learn monads before you can print “Hello, World.”
Platform experimentation. The platforms model is a genuinely interesting approach to effects that does not exist in quite this form in other languages. Building a platform is a way to explore language-VM-runtime architecture that is both practical and educational.
Future Trajectory
The new Zig-based compiler is the major current focus. The goals are substantially faster compilation, better error messages, and a cleaner internal architecture. Once it reaches feature parity with the existing Rust-based compiler, development velocity should increase significantly.
Longer term, the team is working on: an effect interpreter system that will let packages declare what I/O they need and platforms decide whether to support it (currently packages cannot do I/O at all — only platforms can); improved concurrency support; and a stable 1.0 release that will commit to API stability.
Richard Feldman has stated the goal is for Roc to be genuinely production-ready for the server use case — a functional, fast, safe language for services and tools that does not require the GC overhead of JVM/CLR languages or the complexity tax of Haskell.
Getting Started
Installation is currently source-based — there is no installer yet:
|
|
The community lives primarily on Zulip (roc.zulipchat.com), which is active and friendly. The #beginners channel is well-monitored and questions get answered quickly.
Summary
Roc makes a specific bet: that the most valuable parts of functional programming — total functions, exhaustive pattern matching, immutable data, no runtime exceptions — can be packaged in a language that does not require a background in type theory to use. The platforms model is the most distinctive design choice: by removing I/O from the language entirely and delegating it to platform authors, Roc keeps application code genuinely pure and makes security and testability structural rather than achieved through discipline.
The price is real. Alpha status means breakage. A small ecosystem means writing more yourself. The platforms model means you cannot just reach for any C library on a whim. You are betting on a language that has not shipped 1.0 yet and may never reach the critical mass of OCaml or Haskell.
The return on that bet is a language where the compiler is genuinely your ally, where type errors point at the actual problem rather than a confused inference chain, where the boundary between pure and impure code is legible without reading the function body, and where “no runtime exceptions” is an architectural guarantee rather than a testing aspiration.
For engineers who have wanted to try functional programming but been put off by the complexity — or who tried Elm and wanted more — Roc is worth the time to explore. Keep it out of production systems for now. But for the next CLI tool or data processing script, it is a genuinely interesting alternative.
All code examples in this post target Roc alpha4 with the stable Rust-based compiler. The new Zig-based compiler is under active development and has some syntax differences. Check the official tutorial at roc-lang.org/tutorial for the latest syntax.
Comments