Roc — Reference

Source: https://www.roc-lang.org/

Roc

  • Created: 2018 by Richard Feldman; first public alpha 2024
  • Latest stable: alpha4-rolling (initial alpha4 cut 2024-08-26; rolling tag updated continuously). No 1.0, no numbered semver release yet
  • Status: Alpha. Quote from the homepage: “It doesn’t even have a numbered release yet, just alpha builds!” Breaking changes between alphas are expected. Compiler is undergoing a Rust → Zig rewrite (the “zig compiler” — current alpha4 docs target the Rust compiler; the Zig one has a different syntax for some constructs)
  • Paradigms: Pure functional, ML-family
  • Typing: Static, strong, 100% type inference (Hindley-Milner with extensions for tags, records, abilities). Annotations always optional
  • Memory: Automatic reference counting with compiler-driven uniqueness/borrow optimization (in-place mutation when refcount=1). No GC, no manual free
  • Compilation: AOT to native machine code (LLVM via Rust compiler; direct codegen in the Zig rewrite) and to WebAssembly. Designed for “build fast, run fast”
  • Primary domains: CLI tools, web servers, scripts, embedded as a scripting language in larger apps via the platform/host model
  • Official docs: https://www.roc-lang.org/ · Tutorial: https://www.roc-lang.org/tutorial · Builtins: https://www.roc-lang.org/builtins

At a glance

Roc is Richard Feldman’s “what if Elm targeted servers and CLIs” — a small, friendly ML descendant with no runtime exceptions, total type inference, and a unique platform/application split that pushes effects entirely outside the application code. Apps are pure; platforms (written in Rust/Zig/C) provide the effects. The language is in active alpha; the compiler itself is being rewritten in Zig (replacing the original Rust implementation) for speed and contributor accessibility. Don’t ship Roc to production yet, but it’s mature enough for small CLIs and personal projects.

Getting started

Install: No prebuilt for every platform yet. Two compilers exist:

  • Rust compiler (stable for alpha4): download a build from https://github.com/roc-lang/roc/releases tagged alpha4-rolling. Linux x86_64 and macOS arm64/x86_64 are the best supported. Extract and put roc on PATH.
  • Zig compiler (in development): git clone https://github.com/roc-lang/roc && cd roc && zig build. Syntax is in flux — follow the in-tree docs not the web tutorial.
roc version             # confirm install
roc repl                # interactive

Hello world (hello.roc):

app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.19.0/.../basic-cli.tar.br" }
 
import pf.Stdout
 
main! = |_args|
    Stdout.line!("Hello, World!")

The first line picks the platform, which determines what effects you can do. Run: roc dev hello.roc (dev mode, fast compile) or roc build hello.roc (release).

Project layout: Single-file scripts are common. For multi-file:

myapp/
├── main.roc           # app header at top
└── Util.roc           # module Util exposing [...]

The app header lists the platform URL (content-addressed, downloaded once into ~/.cache/roc).

Build/package tool: roc is the only tool. Sub-commands: roc dev (build+run debug), roc build (optimised), roc test (run expects), roc check (type-check only), roc format (canonical formatter), roc repl, roc docs (gen docs), roc glue (codegen for platform authors).

No central registry. Packages and platforms are content-addressed URLs to .tar.br archives (Brotli-compressed). Hashes pinned in the app/module header guarantee reproducibility.

REPL: roc repl — accepts expressions, prints type + value ("hello" : Str).

Basics

Types & literals:

  • Bool (Bool.true, Bool.false)
  • Numbers: polymorphic by default. Concrete types U8 U16 U32 U64 U128, I8…I128, F32 F64, Dec (fixed-point decimal — money-safe)
  • Str (UTF-8, immutable)
  • List a (homogeneous), Dict k v, Set a
  • Records { name : Str, age : U64 }
  • Tags Red, Green, Some 1, Err NotFound — open or closed unions inferred by use

Variables (definitions): = defines an immutable value. No reassignment ever. Names are snake_case:

greeting = "Hello"
counts = { birds: 5, iguanas: 7 }

|x| x + 1 is a lambda. Type annotations are optional but document intent:

add : I64, I64 -> I64
add = |a, b| a + b

Control flow: Everything is an expression.

  • if cond then a else b (must have else)

  • when value is for pattern matching:

    when stoplight is
        Red -> "stop"
        Yellow -> "slow"
        Green -> "go"
  • Pattern matching is exhaustive — compiler errors on missing cases. Tags + when give you Roc’s sum types.

Functions: Pipe-bar lambdas: |a, b| a + b. First-class. Currying: not automatic — Roc functions take all args at once. Compose with |>:

result = numbers |> List.map(|n| n * 2) |> List.sum

Strings: Str is UTF-8. Interpolation: "Hello, $(name)!". Multiline """...""". No char type — single-character strings.

Collections: List, Dict, Set in the standard Builtin. Lists are persistent immutable values, but the compiler rewrites in place when the refcount allows it. Pattern match with [head, .. as tail], [], [a, b, c].

Intermediate

Type system depth:

  • Type inference is total — annotations are documentation, not requirement
  • Open vs closed records: { name : Str }* accepts records with extra fields; { name : Str } is closed
  • Tags as anonymous unions: [Red, Green, Blue] is a closed tag union; [Red, Green]* is open. Tags can carry payloads: [Ok a, Err e] is the standard Result
  • Type aliases: Color : [Red, Green, Blue]
  • Opaque types: Username := Str — only the defining module can construct/deconstruct. Use @Username "alice" inside, opaque outside
  • Abilities (Roc’s typeclass-equivalent): Encoding, Decoding, Hash, Eq, Inspect — derive automatically with implements clauses on opaque types: Username := Str implements [Eq, Hash]
  • Type variables: lowercase identifiers (a, b) — no 'a ML-style ticks

Modules: module [exposed1, exposed2] header lists exports. Imports: import Util then call Util.foo. Built-ins (Str, List, Dict, Result, Num, Bool, Set, Decode, Encode, Inspect) are auto-imported.

Error handling — no runtime exceptions, ever:

  • Result a e with Ok a / Err e tags
  • ? postfix unwraps a Result, propagating the Err (early-return). Massive build-speed improvement landed in alpha4
  • ?? provides a default: parse(input) ?? 0
  • dbg expr prints to stderr (file:line included), then returns the value — for debugging only
  • crash "msg" is the explicit panic; only used for “this is logically impossible”

Concurrency: Not built into the language. Concurrency is the platform’s job. The basic-webserver platform uses Tokio under the hood and exposes a request-handler model; the basic-cli platform is single-threaded async via Task! (the ! suffix marks effectful functions). The application code stays pure; the platform supplies the runtime.

I/O — the platform model:

  • Pure Roc code cannot do I/O directly. Effectful functions are marked with ! suffix (Stdout.line!, File.read_utf8!)
  • The app header binds a platform that exports an effects API (e.g. pf.Stdout, pf.File, pf.Env, pf.Http)
  • The platform itself is C/Rust/Zig code that implements the effect handlers — the equivalent of an algebraic effect handler

Stdlib highlights: Str (UTF-8 ops + parsing), List (~100 functions), Dict, Set, Result, Num (numeric conversions, math), Decode/Encode (ability-based serialization, used by JSON/CBOR/MsgPack libs), Inspect (debug formatting, like Rust’s Debug), Hash, Bool. Standard library is intentionally tiny — domain functionality lives in packages or the platform.

Advanced

Memory model — reference counting + uniqueness:

  • Every heap value carries a refcount; decremented at scope exit
  • Compiler does opportunistic in-place mutation: when it can prove a refcount is 1 (uniqueness), mutating ops on List/Dict happen in place. So List.set list 0 99 is O(1) when list is unique, O(n) copy otherwise
  • No GC pauses, no global heap walk
  • For numeric/scalar arrays this is competitive with mutable-by-default languages while keeping immutable semantics
  • “Persistent without the cost when you don’t need persistence”

Concurrency deep dive: Pure Roc has no concurrency primitives. Platforms expose what they expose:

  • basic-cli: Task! for sequential async I/O (single-threaded)
  • basic-webserver: requests are independent Roc evaluations (effectively share-nothing parallelism via Tokio worker threads)
  • Custom platforms can expose channels, actors, anything — but it’s a platform-author decision

FFI: Roc apps cannot directly call C. Platforms can — they’re written in Rust/Zig/C and link C libs natively, then expose a Roc API. This is intentional: Roc apps stay pure-functional and portable; platform authors handle the unsafe edge.

Reflection: No runtime reflection. The Inspect, Encoding, Decoding abilities give you derive-based introspection at compile time only. dbg uses Inspect to print values.

Performance tools: roc build --optimize enables Rust/LLVM full opts. roc build --time prints phase timings. Profiling = use the platform’s profiler (e.g. for Rust-host platforms, perf/samply/Instruments works on the final binary). The Zig-rewrite compiler emits direct codegen and is dramatically faster than the Rust LLVM path for dev builds.

God mode

Opaque types + abilities: Roc’s typeclass story. Define:

Username := Str implements [Eq, Hash, Inspect]

Outside the defining module, Username is a black box; inside, @Username s constructs and \@Username s -> s destructures. Abilities (Eq, Hash, Encoding, Decoding, Inspect) are derived automatically. Custom abilities: Encoding implements toEncoder : a -> Encoder where a implements Encoding.

Platform/application split — the BIG idea:

  • An application is pure Roc that targets a chosen platform interface
  • A platform is a project (in Rust/Zig/C) that implements the effect handlers AND defines the API surface the app sees (the types of effects available)
  • This is algebraic effect handlers in production: an app is portable across platforms that implement the same interface; a platform can sandbox effects (e.g. a “just file I/O, no network” platform)
  • Existing platforms: basic-cli, basic-webserver, roc-ray (game), false-interpreter (a sample), more in https://github.com/roc-lang/

Open vs closed unions: [Red, Green] is closed; [Red, Green]a is open and accepts more tags (the a is a row variable). Functions can constrain inputs to a closed set while staying generic over additions to outputs. This is structural ML row-polymorphism done well.

Tags carry payloads, are anonymous: No data Color = Red | Green declaration needed. Red is just a value; Some 5 is a value; Err NotFound is a value. Type inference figures out the union from usage.

Reference-count-based mutation: Profile a Roc program and you’ll find idiomatic immutable code generates mutating loops. The compiler emits if refcount == 1 { in_place_set(...) } else { copy_then_set(...) } automatically.

Roc compiler in Zig (post-Rust rewrite): Original compiler in Rust + LLVM. Late 2024–2026 the team is rewriting in Zig for: dramatically faster dev builds (no LLVM frontend overhead in dev mode), simpler contributor onboarding, smaller compiler binary. Some syntax differs (the Zig-compiler tutorial is in-tree); web tutorial currently targets the Rust compiler.

No-runtime-exception philosophy: The only way a Roc program crashes is crash "msg" (explicit). All errors are values. There is no division-by-zero exception (a / b returns Result), no array-out-of-bounds exception (List.get returns Result), no null. This is enforced by the type system, not by convention.

Format on save, lint on commit: roc format is canonical and not configurable. The Zig rewrite includes a more capable static linter alongside the formatter.

Idioms & style

  • Naming: snake_case for values/functions, PascalCase for types and tags, snake_case! for effectful functions (the ! is part of the name, signaling effects)
  • Formatter: roc format — non-configurable, canonical; run on save
  • Linter: Built into the Zig compiler; in alpha4 (Rust compiler) the formatter handles light lint-style suggestions
  • Idiomatic patterns: Use ? to thread Results; use ?? for fallback; prefer pipelines (|>) over nested calls; keep apps pure and push effects through the platform; use opaque types liberally to enforce module invariants
  • Avoid: crash, except for “logically impossible” branches (e.g. just-checked invariant); deeply nested when; non-exhaustive match patterns (the compiler will reject them anyway)
  • Expert review focus: correct ability constraints on generic functions, opaque type boundaries (don’t leak the @Foo constructor), platform choice (does the app need network? then basic-cli is wrong), effect propagation (every ! function call must be in an ! context)

Ecosystem

Mostly nascent — alpha-stage language.

DomainProject
CLI platformbasic-cli (default for scripts)
Web serverbasic-webserver (Tokio + Hyper underneath)
Gameroc-ray (raylib bindings as a platform)
JSONroc-json (third-party, ability-based)
HTTP clientexposed by basic-webserver and basic-cli platforms
TestBuilt-in expect blocks; roc test runs them
Docsroc docs generates HTML; hosted at https://www.roc-lang.org/packages/

Notable users: Vendr (Richard Feldman’s company, has used Roc in production tooling), various indie/hobbyist CLIs. No mainstream commercial deployment yet — language status precludes it.

Gotchas

  • Alpha = breaking changes — pin a specific alpha4-rolling build, expect to update code between alphas
  • Two compilers exist (Rust and Zig); their syntaxes diverge for some constructs. Web tutorial → Rust compiler; in-tree docs → Zig compiler
  • ! is part of the function nameStdout.line and Stdout.line! are different; the ! signals “this performs effects, can only be called from another ! context”
  • Platform URL is content-addressed — once downloaded the hash is locked. Updating a platform = update the URL and hash in the app header
  • Pure Roc cannot call C — no FFI from app code. If you need it, write a platform
  • Number type defaults can surprise: 1 + 2 is polymorphic; assigning it forces a concrete type. Annotate when LSP-driven inference is unhelpful
  • Refcounted in-place mutation only kicks in when refcount=1 — alias the value and mutating ops silently become O(n) copies. Profile if hot
  • Dec is NOT F64Dec is a fixed-point decimal (money-safe but slower). Use the right type for the domain
  • No null, no exception, no goto, no let mut, no inheritance — by design. Don’t try to port idiomatic Java/Python directly
  • Editor support is limitedroc_ls (LSP) exists but is alpha quality. VS Code extension is the most-tested
  • Documentation can lag the code — alpha-stage. Source is the truth: https://github.com/roc-lang/roc

Citations