Technical Documentation: roc-lang/roc
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
Work in progress!
Roc is not ready for a 0.1 release yet, but we do have:
- installation guide
- tutorial
- docs for the standard library
- examples
- faq: frequently asked questions
- group chat for help, questions and discussions
If you'd like to contribute, get started here. Don't hesitate to ask for help on our group chat, we're friendly!
Sponsors
You can 💜 sponsor 💜 Roc on:
We are very grateful for our corporate sponsors Lambda Class, ohne-makler, and Decem:
If you would like your company to become a corporate sponsor of Roc's development, please DM Richard Feldman on Zulip!
We'd also like to express our gratitude to our generous individual sponsors! A special thanks to those sponsoring $25/month or more:
- Peter Marreck
- Barry Moore
- Eric Andresen
- Jackson Lucky
- Agus Zubiaga
- Angelo Ceccato
- Krzysztof G.
- Sam Mohr
- Steven Chen
- Drew Lazzeri
- Alex Binaei
- Jono Mallanyk
- Chris Packett
- James Birtles
- Ivo Balbaert
- Lucas Rosa
- Jonas Schell
- Christopher Dolan
- Nick Gravgaard
- Zeljko Nesic
- Shritesh Bhattarai
- Richard Feldman
- Ayaz Hafiz
- Anthony Bullard
Thank you all so much for helping Roc progress!
Overview (`CONTRIBUTING/README.md`)
Contributing
Code of Conduct
We are committed to providing a friendly, safe and welcoming environment for all. See our Code of Conduct for details.
How to contribute
All contributions are appreciated! Typo fixes, bug fixes, feature requests,
bug reports...
For ideas, proposals and feature request, click here, for all other contributions, read on.
For a good place to start, check out good first issues or reach out on the #contributing channel on our zulip group chat.
Before making your first pull request, create a new topic in the contributing channel on zulip to discuss what you plan to do! This can avoid duplicated effort or a rejection because the change doesn't fit with the goals of the language.
If you are interested in larger, implementation- or research-heavy projects
related to Roc, check out Roc Project Ideas and reach out to us
on zulip! These projects may be suitable for academic theses, internships,
independent research, or just valuable projects to learn from and improve Roc with.
Building from Source
Check Building from source for instructions.
Glue Code Generation
Roc platforms use generated glue to connect Roc values to host-language code in
C, Zig, and Rust. The roc glue command compiles a platform, reads the checked
type, layout, and ownership metadata emitted by the compiler, and runs a glue
script such as CGlue.roc, ZigGlue.roc, or RustGlue.roc to generate the
host-facing interface.
Glue exists so platform authors can depend on generated, idiomatic helpers
instead of hand-writing Roc ABI layout, ownership, and refcount logic. This also
gives Roc a path to evolve the ABI: consumers regenerate glue rather than
maintaining private bindings to compiler internals.
For the glue architecture, generated-helper contract, and ABI risk register,
see src/glue/README.md.
Build Step Names
Build steps follow a build/run split:
build-*steps compile, install, generate files, or prepare inputs.run-*steps execute tools after their build inputs are ready.run-check-*steps run checks.run-test-*steps run tests.
Do not add duplicate alias steps. When adding CI or MiniCI coverage, wire prep
work into a build-* step and add that step to build-ci; add the executable
test or check as a leaf run-* step.
Running Tests
Most contributors should run MiniCI before pushing their code:
zig build miniciMiniCI is the local orchestrator for the CI-shaped set of checks and tests. It
first builds the inputs behind build-ci, then runs the leaf run-check-* andrun-test-* jobs with separate logs and report entries. It actually runs the
tests; it does not just build them.
Test prerequisites
Only the JavaScript cell tests in run-test-repl-wasm require
Node.js 22.7.0 or newer, available as node on yourPATH. Node.js is not required to use Roc, build the compiler, or develop Roc
in general. MiniCI includes this test, so running MiniCI also requires Node.js
for this test alone.
This version is needed because the test imports a .js ES module without apackage.json declaring its module type; Node.js 22.7.0 enabled
module syntax detection by default.
Check your version with node --version. To run just these JavaScript tests
from the repository root:
node --test test/repl-wasm-test/cells.test.mjsThe run-test-cli job includes the glue test suite, which compiles generated
Rust glue for the wasm32 target. Install a Rust toolchain (via rustup) with
the WebAssembly target so those cases can build:
rustup target add wasm32-unknown-unknownWithout it, the glue cases fail with error[E0463]: can't find crate for std
(the wasm32-unknown-unknown target may not be installed).
After each run, MiniCI writes a report directory:
zig-out/minici/index.htmlis a static dashboard you can open directly in a
browser. It shows the run timeline, failures, job details, slowest harness
cases, and per-case timing for harness-backed tests.zig-out/minici/report.jsonis the machine-readable run summary. It containsschema_version,run_started_unix_ms,build_ci, andjobs. Each result
includesstatus,start_ns,end_ns,duration_ns,log_path,command,
andstats_path.zig-out/minici/logs/*.txtcontains captured stdout and stderr for each
build, check, and test job.zig-out/minici/raw/*.jsoncontains harness runner statistics for jobs with astats_path. These files includerunner,summary, andeventswith case
names, statuses, timings, worker indexes, parent ids, and per-stage details.
Agents and scripts should read zig-out/minici/report.json first, then followlog_path for raw command output and stats_path for per-case statistics. If a
job fails, rerun the failed command from the report or from MiniCI's terminal
hint.
For focused local iteration, run an exact leaf step. To run a specific Zig unit
test:
zig build run-test-zig -- --test-filter "name of test"Contribution Tips
- If you've never made a pull request on github before, this will be a good place to start.
- You can find good first issues here. Once you have gained some experience you can take a look at the intermediate issues.
- It's a good idea to open a draft pull request as you begin working on something. This way, others can see that you're working on it, which avoids duplicate effort, and others can give important feedback sooner rather than later. Click the button "ready for review" when it's ready.
- The Glossary file explains terms commonly used in the compiler.
Bidirectional source controls
MiniCI rejects literal Unicode Bidi_Control characters in tracked text,
filenames, and symlink targets to protect against CVE-2021-42574. Run the gate
locally with zig build run-check-source-bidi. It scans bytes even in malformed
UTF-8 or NUL-containing text and recognizes UTF-16/32 byte order marks. Known
binary extensions are listed explicitly in ci/check_source_bidi.zig; changes
to that list need review. The gate has no inline suppression syntax.
Construct security test inputs with escaped code points, rather than placing
invisible controls in fixtures. Roc strings can use \u(202E); Zig tests can
use \u{202E}. Ordinary Arabic and Hebrew text is allowed. Enable your editor's
invisible-character warnings when reviewing Unicode changes. Remove a reported
control from comments, or explicitly escape it in a literal when it is intended
as runtime data; roc fmt refuses to rewrite affected source.
Debugging tips
If you need to do some debugging, check out our tips.
Code coverage
To measure eval interpreter code coverage and find untested code paths, see the eval coverage guide.
Commit signing
All your commits need to be signed to prevent impersonation.
Check out our guide for commit signing.
⚠️ Tip for NixOS users
On NixOS pinentry can cause problems, the following setup works well for those with a KDE desktop. From /etc/nixos/configuration.nix:
programs.gnupg.agent = {
enable = true;
pinentryFlavor = "qt";
enableSSHSupport = true;
};Forgot to sign commits?
:exclamation: Make sure to set up signing on your device first, then continue below.
You can view your commits on github, those without the "Verified" badge still need to be signed.
If any of those is a merge commit, follow these steps instead of the ones below.
If you have only one commit, running git commit --amend --no-edit -S would sign the latest commit 🚀.
In case you have multiple commits, you can sign them in two ways:
- Switching to interactive rebase mode and editing the file:
- Enter into interactive mode, by running
git rebase -i HEAD~nwherenis the number of commits up to the most current commit you would like to see. - This would display a set of commits in a text file like below:text
pick hash2 commit message 2 pick hash1 commit message 1 - On a new line below a commit you want to sign, add
exec git commit --amend --no-edit -S. Do this for all your unsigned commits.
- Enter into interactive mode, by running
- Or run git rebase recursively:
- Find the oldest commit you want to sign, using the
git log --show-signaturecommand. - Run the command
git rebase --exec 'git commit --amend --no-edit -n -S' -i HASHwhich would sign all commits up to commitHASH.
- Find the oldest commit you want to sign, using the
If you already pushed unsigned commits, you may have to do a force push with git push origin -f .
Can we do better?
Feel free to open an issue if you think this document can be improved!
Overview (`examples/README.md`)
Examples
See the examples site.
Overview (`projects/README.md`)
Compiler Improvement Projects
This folder contains self-contained project specifications for structural
improvements to the compiler. Each .md file is written so that someone brand
new to the codebase (human or agent) can read that one file and understand the
problem, the solution approach, what success looks like, how to evaluate the
result for long-term correctness and performance, and what tests to add. Each
doc's "What success looks like" section is a completion contract: the project
is not done until every criterion listed there holds.
small/—localized, mostly additive checks or deletions, low design risk;
hours to days each.- Projects on the order of weeks each (cross-cutting, and several
requiring a design decision before implementation starts) are tracked
as GitHub issues and linked from this README by issue number.
The projects come from a root-cause analysis of eight weeks of bug fixes
(May–June 2026), a July 2026 duplication audit, and a July 2026 differential
re-analysis of the fixes that landed since. The recurring disease across
independent bug clusters was: facts proven during checking get re-derived
downstream from type, name, or structure content instead of traveling as
explicit data, keyed by fragile identity (name strings, positional order,
mutable keys) and enforced only by panics at the consumption site. The
re-analysis added a second-order lesson: generators whose mechanism was
deleted stayed dead, while generators that were centralized but left a
parallel old path kept firing on uncovered axes—so each project's finishing
move is deleting the re-derivation path, not just adding the carried fact
beside it. design.md at the repo root is the authoritative post-check
design; these projects implement its stated principles more completely.
A second batch of projects came out of the 2026-07 comparative review of
the post-check pipeline against the cor lss prototype it was
productionized from (stage-by-stage divergence review; no shipped
miscompile found, but several unstated load-bearing invariants, one
termination hazard, and one verification coverage gap):
- small/spec-constr-specialization-limits.md—
termination budgets for call-pattern specialization (compile-time
divergence is reachable today). - small/empty-tag-union-yield-provenance.md—
key Lambda Solved's one unification escape hatch on carried provenance
instead of shape. - small/pin-lambda-solved-invariants.md—
state, assert, and test the four invariants that make monomorphic
lambda-set solving sound. - small/lambda-mono-oracle-fidelity.md—
agreement asserts and contract pins for the Debug Lambda Mono oracle;
delete its dead Queue. - small/monotype-machinery-hardening.md—
release-gate verification-only type checks; measure-first fixes for
digest depth fallback, unify memo, spec duplication, cross-store reuse. - small/lift-capture-single-sourcing.md—
one capture-fixpoint driver, theif_initialized_payloadbinder
question, and the capture-id override path.
Within this batch the projects are independent.
(spec-constr-specialization-limits used to be paired here withspec-constr-static-match-soundness, which has since landed and had its
doc removed.)
A third batch came out of a whole-codebase competing-sources-of-truth
audit (2026-07-18): a sweep of every subsystem for the same fact
encoded independently in N places with no cross-check. The sweep also
confirmed the prevailing single-source pattern holds in most places
(escape alphabet, LowLevel vocabulary, layout/abi classification,RocTarget, precedence table, snapshot file format, serde audits)—
these projects close the holdouts. All are independent of the earlier
batches and of each other:
- #12165—
backends stop re-encoding RocStr/RocList offsets, flag bits, the
refcount contract, and C-ABI thresholds as magic numbers. - small/llvm-conversion-op-explicit-dispatch.md—
end the LLVM backend's@tagName-substring dispatch for numeric
conversion ops (the one backend exempt from switch exhaustiveness). - small/build-and-ci-single-lists.md—
the nightly and Nix CI legs name MiniCI jobs by hand instead of
invoking MiniCI's own gate list.
A fourth batch (2026-07-20) targets operational robustness and
build-throughput gaps rather than sources of truth. The projects are
independent of the earlier batches and of each other:
- #12162—
dev-backend RC helpers and wasm backend code generation move to the
per-proc worker generation and single deterministic writer the
dev backend already uses for procedures. - #12164—
everyunreachableundersrc/carries a same-line rationale (or
is converted to a checked invariant), enforced forever by a CI lint
with a shrinking allowlist. - small/ice-crash-context.md—
a panic handler that prints what the compiler was doing (phase,
module, def) plus a repro command before the stack trace, built from
thread-local context frames pushed at existing phase boundaries.
A fifth batch (2026-08-24) came out of a duplication audit of the
stages between type checking and code generation—Monotype, Monotype
Lifted, SpecConstr, Lambda Solved, the two LIR lowerers, the LIR
passes, and ARC. It looked specifically for multiple implementations
of one semantic rule rather than for repeated text. The recurring
shape: .lss and .boxy were scoped by the phases they skip, but
value semantics (equality, hashing, Inspect, match compilation)
are not phase artifacts and got duplicated along with the phases; and
inside each strategy, producer/consumer boundaries that design.md
states as "record the decision, consume it later" are implemented as
"re-derive it from the emitted shape". Several of these pairs have
already diverged in behavior, not just in text.
- #12166—
Inspect(three implementations, three copies of the format
strings), structural equality and hashing (three to four), andmatch
compilation (.boxynever adopted the shared decision-tree
compiler, contrary to design.md) collapse
onto one shape-parameterized layer, plus a standing.lss/.boxy
differential harness. - #12163—
the god-structs (BodyContextat 44.5k lines,ProcBodyBuilderat
28.3k) that force helpers to be copied rather than shared, with
divergence already present in both. - small/arc-shared-predicates.md—
four ARC predicates defined twice, including the refcounted-local
predicate where the RC inserter and its own certifier already use
different code. - small/erased-ownership-as-lir-data.md—
the lowerer records erased ownership and the certifier re-derives it
with a copy of the rule;.boxyhas no producer at all.
Single-source primitive tables have landed (the batch's first
project): CheckedPrimitive's four post-check mappings—storage layout,
inspect low-level op, hasher write op, and builtin owner—each have one
definition. The first three live in src/postcheck/common.zig; the
owner table lives beside CheckedPrimitive itself insrc/check/checked_artifact.zig. A structural_test.zig lint fails the
build if a second definition of any of them appears. None of the copies
had been forced by a type barrier: MonoType.Primitive is a plain alias
of checked.CheckedPrimitive.
Boxy representation queries on the plan have landed: the ~45 queries
Boxy lowering re-derived are now Plan.RepQuery / Plan.NamedRepQuery,
one definition each, and a structural_test.zig lint keeps every
consumer calling them. This closed a live plan/lower disagreement—the
planner's repSubtreeHasDescriptorInOtherChildren skips siblings the
selected child's subtree already covers, and both lowering copies
lacked that carve-out, so lowering could refuse an unwrap the planner
had planned. It also separated two predicates a shared name had blurred:
exact-role equality and sameChildRoleKind, which answers false for
every role carrying a payload.
One call-result fusion machinery has landed: return_slot andstr_append were the same pass twice, and the walk, the single-use
liveness guard, and the variant clone now live once in body_clone.zig,
whose charter already named them. Each pass keeps only what is genuinely
its rule—which consumer statement it matches, and how it rewrites the
variant's returns. The merge fixed a silent drop: str_append's variant
clone did not carry erased_reuse_arg through, so a fused
erased-callable proc lost it. box_reuse adopted the shared
eligibility predicate; loop_append_promote deliberately did not,
because its gate is a genuinely different one (it admits non-Roc ABI
procs), which is recorded here rather than papered over.
One correction to the batch, from implementing it. The audit filed aspec-constr-single-cloner project on the claim that Monotype Lifted's
two cloners cover 29 and 44 expression variants and that the narrower
one declines the difference silently, so a new variant would be
half-supported without a build error. That claim was wrong.Pass.cloneExprFresh ends in an explicit 19-variant arm returning null,
and grep -c 'else =>' src/postcheck/monotype_lifted/spec_constr.zig is
zero—the repo's ban on non-exhaustive switch prongs already forces both
cloners to make a decision about every variant. The coverage gap is a
deliberate, compiler-enforced decline set, not a silent one, so the
project's premise did not survive contact and the risky part of it (one
traversal through a compile-time-hot pass, for a drift the compiler
already catches) was not worth doing.
The real duplication that project found was there, one layer down:
the pattern walk that collects a pattern's bound locals existed three
times—the lift pass's bound-set scan, its capture graph builder, and
SpecConstr's body-local scope—identical in every position, differing
only in what each does at a binding site. That is nowAst.forEachBoundLocal, taking the binding action as a comptime
parameter, with a structural_test.zig lint keeping it single. Three
copies could have come to disagree about whether a given position
binds—a list rest pattern, say—and only one would have been right.
A second correction, also from implementing it. The audit filed apostcheck-ir-store-boilerplate project proposing a comptimeFlatStore(Spec) mixin that generates each store's add*, get*,set*, *Count, *View, and *Span surface. Zig 0.16 removedusingnamespace, so a mixin cannot inject named declarations into a
struct at all; every accessor has to stay a hand-written name whatever
sits behind it. The project's mechanism does not exist in this compiler,
so the doc is deleted rather than left proposing it.
The duplication it counted is real—57 byte-identical bodies betweenmonotype/ast.zig and monotype_lifted/ast.zig—but most of those
bodies are already one-line delegations whose only content is the field
they name, which is the part that legitimately differs per store. The
part with real duplicated logic was the span append: three lines
(len(), appendSlice, construct the span) written out 33 times across
the three stores. That is now Common.appendSpan /Common.appendNonemptySpan, with Span itself single-sourced, and astructural_test.zig lint keeps the hand-written form from coming back.addTypedLocalSpan deliberately kept its own body: it preallocates
through ensureUnusedCapacity, which is a different append, not the
same one spelled differently.
Two doc claims were also corrected in the code. match_tree.zig andpostcheck/mod.zig both described the decision-tree match compiler as
"shared by both LIR lowerers"; it is used by .lss only, and a doc
comment asserting a sharing invariant that does not hold is worse than
no comment, because the next reader budgets for one match semantics when
there are two. Both now say what is true, and a lint pins the claim to
the imports so it moves when the gap closes.
Housekeeping in the same pass. float-range-flat-representation.md was
carrying its own "Status: Resolved" section rather than a project: range
syntax now constructs a reusable Num.Range(num) whose singleBuiltin.Num.Range.iter is the monomorphization-recognized iterator
source, so integer, Dec, and float ranges all keep flat by-value
iterator state and the element-type performance cliff is gone. Its
verification lives in src/eval/test/eval_iter_alloc_tests.zig andsrc/eval/test/lir_inline_test.zig, both present and covering it, so
the doc is deleted. Twelve cross-references to four project docs that
landed and were removed (silent-drift-guards,store-generation-counters, spec-constr-static-match-soundness,audit-solver-mutating-rewrites) were dangling links; they now read as
plain names marked landed, and the two recommended-order lists dropped
their landed entry and renumbered.
Within the rest of this batch, #12166 and #12163 compose in either
order. The rest are independent.
Recommended order
Start here—enforcement layers, cheap and load-bearing
- small/cross-phase-coverage-parity-tests.md—
the divergence-classification parity suite; a regression net the big
lowering projects inherit. - small/rceffect-conformance.md—
comptime validity plus a per-op refcount conformance harness for the
central ownership table (the PR 10023 bug class). - small/cache-and-identity-residuals.md—
closes the four small seams left after the identity/cache cures
(name-text fallback, hand-enrolled serde contracts, split version
hashes,type_namein nominal keys).
Chain A—dispatch evidence, consumed everywhere
- small/hoist-consumes-dispatch-evidence.md—
hoist selection reads the dispatch resolution instead of re-deriving
evidence-dependence from type-var content (the PR 10073 seam), and
recovers the hoisting its conservative gate gives up.
Chain B—the host/platform boundary
- small/hosted-extern-declared-abi.md—
the invariant that a hosted extern is only specialized at its declared
type, enforced at the producer instead of by a checker rewrite.
The solver-rewrite audit that followed this chain has landed: design.md's
"Solver-Mutating Rewrites" section holds the mechanism/policy inventory,Store.dangerousSetVarRedirect requires a declared RedirectRule at every
call site, and the 9966 widening is the declared Hosted Try Question
Widening rule, scoped to direct hosted calls with both sides pinned by
tests.
The platform-relation migration that concluded this chain has landed:
checking records each platform requirement's solution in the app's checked
artifact, finalization consumes it, and the platform root is published
once—the name-keyed re-resolution and double publication are deleted.
Chain C—specialization sealing
- small/pin-deferred-spec-requests.md—
seal-time instrumentation, the snapshot-regime pin, and theunifyThroughBackingdecision; end state:row_defaultunreachable
for checker-constrained rows.
Independent—start any time, in any order
Small:
- small/frame-partitioned-checker-state.md—
inventory and convert frame-scoped checker/canonicalizer state to
dedicated frame storage (the 9929→10010 and 10001 shape). - small/compact-constant-aggregates.md—
static-data and builtin-call materialization for constant/repeated
lists, ending the one-local-per-element explosion behind issue 9898.
The decision-tree match compiler has landed for .lss: the direct
solved-to-LIR lowering compiles match through one shared Maranget-style
module (src/postcheck/match_tree.zig)—one multiway switch per tested
position, one discriminant read, strings and list-length buckets as
ordinary arms—with the sharing invariant documented in design.md and
enforced by a debug statement-count lint. The 2026-08-24 audit found.boxy never adopted it (grep -rn match_tree src/postcheck/boxy/ is
empty; boxy/lower.zig folds branches into a sequential chain), so the
"shared by both LIR lowerers" claim in src/postcheck/mod.zig is not yet
true. Closing that is part of
#12166.
Single-source builtin registration has landed: the seven hand-typedroc_builtins_* symbol/ABI tables now derive from one comptime registry
(src/builtins/builtin_registry.zig), and the LowLevel-to-builtin member
choice is shared across backends via base.LowLevelBuiltins.
Suggested overall sequence
If one person or agent works through everything serially, this order
front-loads leverage and keeps prerequisites satisfied:
small/cross-phase-coverage-parity-tests.mdsmall/rceffect-conformance.mdsmall/cache-and-identity-residuals.mdsmall/pin-deferred-spec-requests.mdsmall/hoist-consumes-dispatch-evidence.mdsmall/hosted-extern-declared-abi.mdsmall/frame-partitioned-checker-state.mdsmall/compact-constant-aggregates.md
Overview (`src/README.md`)
Roc Compiler (Zig Implementation)
Navigation
Core compiler directories:
- base/ - Core data structures and utilities shared across the compiler
- check/ - Main compilation pipeline (parsing, canonicalization, type checking)
- types/ - Type system definitions and utilities
- builtins/ - Built-in types, functions, and operations
- reporting/ - Error reporting and diagnostic formatting
- collections/ - Efficient data structures used throughout the compiler
- cache/ - Compilation caching system for faster incremental builds
- coordinate/ - Cross-module compilation coordination and dependency management
- eval/ - Runtime evaluation and interpretation system
- fs/ - Filesystem operations and file I/O utilities
- layout/ - Memory layout computation and management for code generation
- serialization/ - Serialization utilities for compiler data structures
- snapshots/ - Snapshot tests validating compiler behavior across all stages
Status
This table provides a summary of progress for the zig compiler re-write and should be updated with the PR that includes new features.
| Str & Num | Functions | Modules | Collections | Records & Tuples | Recursive Types | Static Dispatch | |
|---|---|---|---|---|---|---|---|
| Parse | 🔋 | 🔋 | 🪫 | 🪫 | 🔋 | 🪫 | 🚧 |
| Canonicalize | 🪫 | 🪫 | 🪫 | 🪫 | 🪫 | 🪫 | 🚧 |
| Resolve Imports | 🚧 | 🚧 | 🪫 | 🚧 | 🚧 | 🚧 | 🚧 |
| Check Types | 🪫 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | |
| Interpreter | |||||||
| Specialize Types | |||||||
| Lift Functions | |||||||
| Solve Functions | |||||||
| Specialize Functions | |||||||
| Reference Counting | |||||||
| Lower IR | |||||||
| Gen LLVM |
- N/A Not applicable
- 🚧 Work Started
- 🪫 Tests Passing
- 🔋 Polished
Fast Feedback Loop
The roc zig compiler can have a very fast feedback loop. We support zigs incremental compilation and watch mode.
By avoiding generating final executables, we can build and typecheck much much faster.
Try it with zig build -Dno-bin -fincremental --watch
Running Language Server (LSP) Tests
The LSP tests are split into fast unit coverage and compiler-backed integration
coverage.
zig build run-test-zig-module-lsp_unit
zig build run-test-zig-module-lsp_integrationFor faster iteration with incremental compilation and watch mode:
zig build run-test-zig-module-lsp_unit -fincremental --watchUse lsp_unit for protocol, transport, document store, token-only coloring,
and handler routing work that does not need compiler checking. Uselsp_integration when changing syntax checking, completions, hover,
definition, document symbols, diagnostics, or behavior that depends on real Roc
source, compiled builtins, and platform/app checking. The integration command
is a parallel harness; pass -- --threads N to cap workers or-- --test-filter "case name" to run matching integration cases.
Expanding to ZLS
This fast config can also be used with zls. Simply follow these steps:
- run
zls --versionand make sure it supports Zig0.17.0. - run
zls envand grab theconfig_filepath. - Edit the config file to include
{
"enable_build_on_save": true,
"build_on_save_args": ["-Dno-bin", "-fincremental"]
}- Advised, also changing the cache dir, I use
--cache-dir .zig-cache/zls.
Otherwise, zig commands run manually can lead to the lsp breaking and requiring a restart. - Optionally, add
-Dfuzzabove as well to get type checking of fuzz scripts as well. - Note, I had to fully delete my
.zig-cacheto getzlsto start.
Make sure to check the logs if you aren't getting type failures. - Enjoy better lsp results.
Simply testing feedback loop
Sadly, this is not nearly as fast due to building binaries.
One day, we will get dev zig backends, and it should be fast.
Try it with zig build run-test-zig -fincremental --watch
Overview
Overview (`CONTRIBUTING/profiling/README.md`)
Profiling Roc
Profiling is a very big topic with many facets and pitfalls.
This is just a preliminary doc to recommend some nice tools and basic flows.
If you have any question, please reach out on zullip.
Feel free to at Brendan Hansknecht in the discussion thread.
General Tips
- Build with optimizations and retain debug symbols (
-Doptimize=ReleaseFast -Dstrip=false). - Try to benchmark real world rather than synthetic inputs.
- Prefer end to end benchmarks when possible.
Microbenchmarks can be misleading and even potentially lead to end to end slowdowns. - Probably use linux (linux just gives a lot more info and tools in general).
That said, mac and windows can also be ok. - Make sure you have a long enough execution horizon to get good results (sometimes requires looping over the same work multiple times).
Some Nice Tools
These are tools I recommend for profiling that help get a solid picture.
[Flamegraph](https://github.com/flamegraph-rs/flamegraph)
Flamegraphs are the core of basic profiling.
They enable quickly finding hotspots in execution.
By regularly sampling callstacks, flamegraphs are able to identify where time is being spent.
This tool simply generates an svg to view.
This is a great place to start if new to profiling, but has a number of limitations.
For examples, flamegraphs simply aggregate information with sampling and can miss a lot of data.
They also lack interactivity and many low level details.
Flamegraphs are essentially a still frame view into an apps performance.
The tool is simple to run.
Using it with roc can be as simple as flamegraph --root -- ./zig-out/bin/roc fmt /tmp/app.roc.
Note:
--rootis not always needed, but it generally gives better results.
If you open the flamegraph in a browser, you can explore where time is spent.
There is a search button in the top right corner (almost not visible) that enables better discovery of percentage of time used in various functions.
For example, here is a flamegraph from parsing and formatting a roc file.
It was search for memmove and shows that 25% of time is spent moving bytes.
[Samply](https://github.com/mstange/samply)
Samply is a sampling profiler that that uses the Firefox profiler to give richer info and interactivity on top of a flamegraph.
I find it to be a richer and nicer default than simple flamegraphs, but it is also more complex.
Not only do you get a flamegraph, but you get callstacks, can collapse recursive nodes, and are able to view source lines and assembly hit by the sampler.
Using samply is also super simply, just samply record -- ./zig-out/bin/roc fmt /tmp/new.roc.
Here is an example output after searching for memmove.
We can see in the top graph, all the specific times during execution that memmove was using the cpu.
On top of that, we can see that 42% of memmoves happened during tokenization.
[Tracy](https://github.com/wolfpld/tracy)
Unlike the above, tracy is not primarily a sampling profiler.
Instead, tracy focuses on tracing through explicit code annotations.
Tracy is significantly more complex than the other solutions, but is always way more powerful.
Tracy has an ~100 page detailed manual and an online interactive demo: https://tracy.nereid.pl/.
The most important part of the manual to read is probably Analyzing captured data (which is about the UI).
Simply using tracy is more work than the other tools.
Beyond having tracy install, you also need to download the source of tracy checked out to the same version.
For example, I installed tracy with homebrew. It is at version 0.11.0.
As such, I have checked out the tracy github repro to the v0.11.0 tag in my ~/vendor/tracy directory.
Then, on my linux machine (which tracy works better on), I run zig build -Doptimize=ReleaseFast -Dstrip=false -Dtracy=$HOME/vendor/tracy roc.
This builds tracy with all of the bells and whistles.
One of those cool features is tracy callstacks. This feature gives much more precision of information, but adds significant overhead.
Often times, it is worth profiling once with -Dtracy-callstack=false to have much less overhead.
Now that I have the compiler built with tracy, I can launch the tracy server on my mac machine to record the result (you can also run it on the same machine if wanted).
Run tracy-profiler, input the correct ip address, and press connect.
Then run the instrumented version of zig: ./zig-out/bin/roc fmt /tmp/new.roc.
Also, run with the root user to capture more information.
In this case, I ran:
sudo ./zig-out/bin/roc fmt /tmp/new.rocAfter that, welcome to a wealth of information:
- Everything running on the cpu over the course of the execution
- Zone info and statistics
- Source information with disassemblies
- Memory allocations and frees.
- Locks and multithread info (once the zig roc compiler uses them)
At some point, hopefully we will have videos on using tracy with roc.
Until then, here is a video on tracy from cppcon and here is an example output.
Overview (`docs/langref/README.md`)
NOTE: This is heavily WIP! This line will be removed when the langref is considered complete, and at that point, missing sections and outdated information will be considered bugs. If you'd like to contribute to this (other than minor corrections of obvious mistakes), definitely mention it in the langref Zulip channel before getting started on anything!
Language Reference
This is Roc's language reference (or langref for short).
If you're looking for a beginner tutorial, check out roc-lang.org/tutorial instead; the langref is a detailed reference intended for experienced Roc programmers.
Outline
- Expressions
- Statements
- Error Handling
- Conditionals
- Pattern Matching
- Functions
- Naming
- Modules
- Platforms
- Types
- Static Dispatch
- Operators
- Records
- Tuples
- Tag Unions
- Lists
- Boxes
- Loops
- Iterators
- Numbers
- Strings
- Dictionaries and Sets
- Parsers
- Compile-Time Evaluation
- Comments
- Packages
Overview (`src/backend/README.md`)
Backend Development Guide
This directory contains the code generation backends for Roc.
Native procedure artifacts
The dev backend shares one procedure-emission pipeline between native compile-time
evaluation and object generation. Workers borrow frozen LIR and return owned,
relocatable artifacts; deterministic placement and linking belong to the coordinator.
Executable images are execution resources, not reusable compiler output.
Reuse requires an explicit same-program capability, producer revisions, and
compatible emission policies. Compile-time hooks and mutable slot addresses cannot
escape through cached code. Immutable runtime data travels with its referenced
artifact closure; mutable CTFE slots remain external bindings. See design.md for
the lifetime and placement invariants.
Running Tests with Filters
To run a specific eval test by name filter:
# Run a single test
zig build run-test-zig -- --test-filter "list refcount function"Debugging Generated Code
When debugging code generation issues (calling conventions, register allocation, etc.),
you can enable hex dumping of the generated machine code.
Enable Hex Dump
Use a targeted debug print in the backend code path you are investigating and
re-run the relevant eval test. The inspect-only eval harness no longer keeps a
test-helper-specific generated-code dump switch.
Disassembling the Output
On Windows, save the hex bytes to a file and use:
# Using dumpbin (from Visual Studio)
dumpbin /disasm code.bin
# Or using objdump (from MinGW/MSYS2)
objdump -D -b binary -m i386:x86-64 code.binOn Linux/macOS:
objdump -D -b binary -m i386:x86-64 code.binUsing Python to convert hex dump to binary:
import re
hex_dump = """
55 48 89 E5 53 41 54 48 81 EC 00 04 00 00 48 89
CB 49 89 D4 ...
"""
hex_values = re.findall(r'[0-9A-Fa-f]{2}', hex_dump)
code_bytes = bytes(int(h, 16) for h in hex_values)
with open('code.bin', 'wb') as f:
f.write(code_bytes)Overview (`src/base/README.md`)
Base
Foundation utilities and common functionality used throughout the Roc compiler.
Overview
The base module provides essential utilities, data structures, and abstractions that are used across multiple stages of the Roc compiler. It serves as a shared foundation that other modules depend on.
Purpose
This module provides:
- Common Data Structures: Shared data structures and collections used throughout the compiler
- Memory Management: Safe memory allocation and management utilities
- Parallel Processing: Utilities for parallel execution and work distribution
- Error Handling: Common error types and error handling patterns
- Debugging Support: Debugging and logging utilities
- Platform Abstractions: Cross-platform abstractions for common operations
Overview (`src/builtins/README.md`)
Builtins
Built-in functions, types, and runtime support for the Roc language.
Overview
The builtins module provides the core runtime functionality that every Roc program depends on. It includes fundamental operations, data types, and the interface between Roc code and the host platform.
Purpose
This module provides:
- Core Data Types: Built-in types like strings, numbers, and basic collections
- Runtime Operations: Fundamental operations like memory allocation, string manipulation, and arithmetic
- Host Platform Interface: The bridge between Roc code and the underlying platform (ABI, system calls, etc.)
- Standard Library: Essential functions that are always available in Roc programs
The builtins module is essential for both the compiler (during type checking and code generation) and the runtime (during program execution).
Numeric Oracle Tests
Numeric builtins should have tests whose expected values come from an
independent, known-good computation rather than from the implementation under
test. Prefer generating those expected values at Zig comptime, then storing them
in ordinary test fixture arrays.
The usual pattern is:
- Write an explicit input corpus that covers ordinary values, sign boundaries,
representation boundaries, domain boundaries, overflow/underflow edges,
branch cutoffs, and special values such as zero, infinity, and NaN where the
type supports them. - Build fixture arrays at comptime from that corpus.
- Compute expected values with Zig language builtins or
std.mathfunctions
that are independent of the Roc builtin implementation being tested. - Run the Roc builtin implementation at test runtime and compare it to the
comptime-generated expectation.
For exact operations, compare exact representations. Examples include integer
overflow flags, bit conversions, parsing/formatting cases with exact expected
text, and Dec operations whose semantics define an exact fixed-point result.
For Dec this usually means comparing the raw scaled i128 payload.
For approximate operations, keep the approximation explicit in the test name and
fixture. Transcendental functions such as sin, cos, tan, asin, atan,log, and fractional pow generally do not have exactly representable Dec
results. Their tests should use comptime oracle values plus a stated error
envelope, not an ambiguous "close enough" check hidden inside the assertion.
For floating-point functions, prefer ULP-aware comparisons that preserve signed
zero and NaN behavior.
Do not use the implementation under test as its own oracle. Also avoid relying
on a backend host import as evidence for a custom builtin implementation unless
that import is exactly the code under test. For example, a wasm host math import
can validate wasm linking and call plumbing, but direct Zig tests are still
needed for a self-contained builtin math port.
When adding a new numeric builtin, add tests at the lowest layer that owns the
algorithm and add integration coverage for the public Roc API or low-level
operation that reaches it. The comptime oracle fixtures establish algorithmic
expectations; eval, CLI, wasm, and backend tests establish that the compiler and
runtime plumbing call the intended builtin.
Test Maturity TODOs
- TODO: Add property-style List tests for refcounted elements, seamless slices,
aliasing, concat/drop/replace/reverse chains, and allocation-count invariants.
The current List tests cover many targeted cases; the follow-up should add a
systematic corpus that exercises ownership-sensitive operation sequences.
Overview (`src/canonicalize/README.md`)
Canonicalize
Transforms Abstract Syntax Tree (AST) into Canonical Intermediate Representation (CIR) through semantic analysis of the program.
The canonicalize module is the second stage of the Roc compiler pipeline. It performs semantic analysis, resolves names, and transforms the AST into a normalized intermediate representation that's easier for the type checker to process.
Overview (`src/check/README.md`)
Check Types
Performs Hindley-Milner type inference with constraint solving and unification on the Canonical Intermediate Representation (CIR).
The check module is the third stage of the Roc compiler pipeline. It performs type inference using the Hindley-Milner algorithm, ensuring type safety and generating the necessary type information for code generation. This stage catches type errors and ensures the program is well-typed before proceeding to evaluation or compilation.
Exhaustiveness uses read-only nominal views so declaration substitutions cannot
mutate solver types or escape as solver blockers. Exact template dependencies
allow irrelevant substitutions to share identity while private unknowns retain
their application scope.
Inhabitedness uses a greatest fixed point to preserve conservative recursive
payload semantics without enumerating shared paths. Row-only cycles provide no
constructor witness. General, constructor-payload, and known-absent queries retain
distinct leaf and open-row policies; only settled answers are memoized.
Overview (`src/cli/README.md`)
CLI
This directory contains the source code for the roc command-line interface (CLI) tool.
The CLI is the main entry point for developers using Roc. Its responsibilities include:
- Command Parsing: Parsing commands (e.g.,
build,run,fmt,test) and their options throughcli_args.zig - Compilation Orchestration: Managing the compilation pipeline and integrating with the Roc compiler
- Linker Integration: Using an abstraction for the LLD linker as a library through
linker.zigfor code generation - Performance Profiling: Integration with Tracy profiler for performance analysis
- Testing: Built-in execution of top-level
expects through thetestcommand - Shared Memory Management: Testing utilities for the shared memory system used in IPC
- Default-App Staging: Turning a file that runs on the built-in Echo platform—a headerless file with
main!, or anappheader that names no platform—into an ordinary app rooted beside a copy of that platform, throughdefault_app.zig
The CLI coordinates between the compiler frontend (parsing, type checking) and backend (code generation, linking) to provide a seamless development experience.
Multiline strings in the REPL
The terminal REPL keeps consecutive multiline string lines in one statement.
A blank line, a following non-string statement, or EOF submits the pending string.
String payloads retain their trailing spaces.
Overview (`src/collections/README.md`)
Collections
The collections module provides efficient data structures that are tailored for use throughout the Roc compiler. These collections are designed to handle the specific needs of compiler operations like symbol tables, dependency graphs, and intermediate representations.
Purpose
This module provides:
- Specialized Data Structures: Collections optimized for compiler-specific use cases
- Memory Efficiency: Data structures designed to minimize memory overhead during compilation
- Performance: Fast access patterns for common compiler operations
- Type Safety: Safe, generic collections that work with Roc's type system
RekeyingHashMap keeps lookup cost independent of deletion history for indexes
whose keys are repeatedly removed and reinserted as compiler identities evolve.
Overview (`src/compile/README.md`)
Compile
This directory holds the core logic for compiling Roc modules. It takes Roc source code and processes it through various stages to produce an executable or library.
Key responsibilities include:
- Compilation Pipeline: Managing the flow of a module through the compiler stages:
- Tokenizing
- Parsing
- Canonicalizing
- Type Checking
- Code Generation (Not yet implemented)
- Module Caching: Managing the cache of compiled modules to speed up subsequent builds
- Package Building: Building complete packages
- Build Environment: Managing build contexts and environments
The compile module serves as the orchestrator that coordinates between the different compiler stages and manages the build process efficiently.
Embedding
Roc can be embedded in another Zig program to compile and run .roc files in-process. The driver is Coordinator; the in-process execution path uses lir.LirImage and eval.LirInterpreter.
The embedding API is stable in shape; implementation details may change between releases.
Using Roc as a Zig dependency
Pin a Roc commit in your package manifest withzig fetch --save=roc 'git+https://github.com/roc-lang/roc.git#<full-commit-sha>'.
In your build, resolve b.dependency("roc", .{ .target = target, .optimize = optimize })
and import its named modules with exe.root_module.addImport("compile", roc.module("compile")).
The same interface exports lir, eval, check, base, ctx, builtins,roc_target, and build_options, including their transitive dependencies and
generated builtins. The consumer's executable must link libc.
test/downstream-compiler is a complete
standalone driver exercised against a fetched package byzig build run-test-downstream-package. Updating a downstream dependency means
updating the pinned commit and package hash, without copying compiler sources.
Canonical sequence
/* Detailed source-code truncated for AI context efficiency. */postCheckExecutor() reuses the coordinator's persistent compilation workers
for graph-free post-check tasks. It is intentionally a synchronous borrowed
capability: obtain and use it only after coordinatorLoop() has drained the
frontend and before coordinator shutdown. Frontend and post-check work share
the same task/result channels, so overlapping those phases is an invariant
violation.
Type-check-only flow
For LSP, fuzzing, or any consumer that only needs diagnostics, stop after step 3:
try coord.start();
try coord.discoverAppFromPath(arena, .{ .entry_path = app_path });
try coord.coordinatorLoop();
var it = coord.iterReports();
while (it.next()) |entry| {
// render entry.report
}
const had_errors = coord.hasUserErrors();Skip finalizeExecutableArtifacts, LIR lowering, and execution entirely.
Runtime arena
fillHeaderInBuffer and viewMappedImage compute offsets via ptr - base_ptr arithmetic, so the image allocator must own a single contiguous virtual region. std.heap.ArenaAllocator grows by allocating new pages and will not work—the offsets it computes will be wrong, either silently producing a broken image or tripping error.InvalidLirImage.
Use a std.heap.FixedBufferAllocator over a heap-allocated contiguous buffer:
const RUNTIME_ARENA_SIZE = 128 * 1024 * 1024; // 128 MiB
const runtime_buffer = try gpa.alignedAlloc(u8, 16, RUNTIME_ARENA_SIZE);
defer gpa.free(runtime_buffer);
var runtime_fba = std.heap.FixedBufferAllocator.init(runtime_buffer);
const runtime_alloc = runtime_fba.allocator();Sizing: budget at least 128 MiB for a typical app—even a "hello world" needs more than 16 MiB after lowering all reachable modules. Anonymous virtual memory overcommits on Linux/macOS, so a generously-sized buffer doesn't cost real RAM until actually touched. Pick a number that comfortably exceeds your largest expected program.
Memory ownership
Refcounted values that Roc returns to the host (a RocStr, RocList, or RocBox written through ret_ptr; or any of those passed as arguments to your hosted functions) are heap-owned by the host. The interpreter does not auto-tear-down on eval return—that's intentional, since embedders frequently want to inspect the return value before freeing it.
The host must explicitly decref returned refcounted values when done. Without this, host allocator leak-checking will report leaks tracing back to rocAllocFn in the interpreter:
// After interp.runEntrypoint(...) returns, if the entrypoint's return type
// is a refcounted value (e.g. RocStr returned via ret_ptr), decref before
// tearing down. `RocStr.decref` takes the value by copy and a *RocOps; it's
// a no-op for small (stack) strings.
const result_str: RocStr = @as(*const RocStr, @ptrCast(@alignCast(&result_buf))).*;
defer result_str.decref(&my_roc_ops);The same rule applies to hosted-fn arguments: the host owns each *RocStr (or other refcounted pointer) it receives for the call's duration. Read freely, but if you stow the value to use later, call the appropriate incref helper first.
Custom filesystem
Implement the Io vtable and pass it via coord.setIo(...). The default Io reads from std.fs.cwd().
For embedders that don't have a filesystem at all (e.g. wasm), Coordinator doesn't require one as long as every path passed in is served through the configured Io. The cache manager (cache_manager parameter to Coordinator.init) can be left null.
Host functions
Set coord.enable_hosted_transform = true before coord.start(). Hosted lambda bodies in platform modules are auto-converted during canonicalization.
At execution time, RocOps.hosted_fns.fns is a positional array—the interpreter calls fns[dispatch_index]. The dispatch index is the hosted function's position in the platform header's hosted section, so the array follows the order the section is written in:
hosted { "roc_stdout_line": Stdout.line!, "roc_stderr_line": Stderr.line! }gives Stdout.line! index 0 and Stderr.line! index 1, whatever modules they are declared in. roc glue emits the same numbering, so generated glue and a hand-written array agree. A declaration in the platform module itself is named without a module, since a platform cannot import itself: hosted { "roc_helper": helper! }.
Wrong order is silent (wrong function called), not loud—be deliberate, especially when reordering the hosted section.
A platform without a hosted section has no bound declarations, and post-check lowering orders whatever remains by the procedure's deterministic order key.
URL-resolved packages
Coordinator.discoverAppFromPath does not download URL-specified packages—it only supports relative paths. For URL support:
- Call
compile.app_header.parseAppHeader(io, gpa, arena, path)to get the raw header. - Resolve URLs to local paths using your own caching policy.
- Register packages with
coord.ensurePackage/coord.registerInlinePackageinstead ofdiscoverAppFromPath.
The Roc CLI follows exactly this pattern—see buildLirImageWithCoordinator in src/cli/main.zig for a worked example.
Overview (`src/eval/README.md`)
Interpreter Overview
This directory contains Roc's interpreter. It powers the REPL, the interpreter
shim (for the default roc command and roc build in interpreter mode), and
the evaluation tests. This document introduces the core pieces so a new contributor can
navigate the code without prior context.
High-Level Architecture
The interpreter works by consuming ARC-inserted LIR and interpreting that
program directly. In the public post-check pipeline, CIR is never given to the
interpreter or interpreter shim; the parent compiler lowers through checked
modules, post-check IRs, LIR, and ARC first.
checked modules → post-check IRs → LIR → TRMC/TCE → ARC → InterpretCore Modules
interpreter.zigexportsLirInterpreter. Each Roc call gets a frame
whose local slots are looked up through the proc's sortedframe_locals
span. Within a frame,execStmtChainwalks the statement chain in a flat
loop—join/jumpexecute as loops without growing any stack—whileassign_callrecurses natively into the callee (bounded by the call-depth
cap below).value.zigdefinesValue—a raw pointer to bytes in memory. Values
carry no runtime type information; the layout is always tracked separately
vialayout.Idx.
Evaluation Flow
- Published inputs—Consumers (REPL, tests, CLI) type check source and
publish checked modules plus explicit roots. - Lowering—The checked-module pipeline lowers through post-check IRs
and LIR, rewrites tail recursion via TRMC/TCE (src/lir/trmc.zig), and
inserts ARC, producing aLirStore, committed layouts, and explicit root
procedures. - Execution—On native compiler hosts, compile-time roots execute as
dev-backend machine code. When the compiler itself targets wasm32 or another
freestanding host, compile-time roots execute throughLirInterpreter.
This host-platform choice is not configurable. Both compile-time paths
unconditionally use.normalize, so stored f32/f64 NaNs have canonical,
host-independent bits; runtime consumers unconditionally use.preserve. - Interpreter statement walk—When the interpreter is the selected
execution engine,execStmtChainexecutes each frame's statement chain
iteratively, dispatching perCFStmtvariant; low-level ops go throughevalLowLevel. - Crash handling—A Roc crash records its message in the interpreter and
unwinds aserror.Crash(getCrashMessagereturns the message). The
interpreter never calls the host'sroc_crashed, which does not return;
the embedder delivers the crash to its host. Expect expressions delegate to
the host viaRocOps.
All RocOps interactions (alloc, dealloc, crash, expect, dbg) happen through theRocOps pointer. This keeps host integrations consistent.
Evaluation Limits
- Call-depth cap—
LirInterpretercrashes ("stack overflow") after 1024
nested Roc calls (max_call_depth). Tail-recursive and
constructor-tail-recursive functions don't hit this: the TRMC/TCE pass
(src/lir/trmc.zig) rewrites them into join-point loops before the
interpreter ever sees them. - Debug value validation—In Debug builds,
setLocalwalks values to
check they match their layouts. The walk is best-effort: it stops atmax_debug_value_depth(64) nested values and aftermax_debug_value_visits(16) heap cells (so deep lists can't overflow the
native stack and wide trees don't make every assignment O(structure size)),
and inside TRMC-transformed procs a null box pointer is accepted as a legal
not-yet-filled hole.
Host Integrations
- Inspected evaluation (
inspected.zig,inspected_run.zig)—Compiles an
explicit checked root and executes it through a selected backend while
returning the inspected value, crash outcome, allocation count, and ordered
host events. - Runtime host (
runtime_host.zig)—Implements compiler-ownedRocOps
callbacks with allocation tracking and ordereddbg, failed-expect, and
crash capture. Production REPLs and evaluator tests use the same host. - Interpreter shim (
src/interpreter_shim/main.zig)—Provides a
C-callable entry point (roc_entrypoint) that maps/views an ARC-inserted LIR
image and evaluates it via the interpreter.
Tests
Evaluation coverage lives in src/eval/test/:
parallel_runner.zig—Runs every data-drivenTestCaseon all enabled
backends (interpreter, dev, wasm; llvm with--llvm) in forked
subprocesses, requiring byte-identicalStr.inspectoutput.eval_tests.zig—Aggregates theTestCasetables from theeval_*_tests.zigfiles (recursive data, closures, low-level ops,
polymorphism, issue repros, ...).eval_trmc_tests.zigholds the TRMC/TCE
stack-safety gates and the CFold/NQueens/RBTreeCk benchmark ports.trmc_lir_test.zig,lir_inline_test.zig—Standalone binaries asserting
on LIR structure (TRMC pointer ops, detection/transform outcomes, inlining).host_effects_runner.zig/host_effects_tests.zig—Runtime host-effect
coverage.runtime_host.zig—SharedRocOpshost with allocation leak checks and
event capture.
Run tests with:
zig build run-test-eval # interpreter + dev + wasm
zig build run-test-eval -- --llvm # also the LLVM backend
zig build run-test-zig-trmc-lir # LIR-level TRMC tests
zig build run-test-zig-lir-inline # LIR-level inlining testsDebugging
The interpreter supports a compile-time tracing flag that enables detailed
evaluation output. To build with tracing enabled:
zig build -Dtrace-eval=trueWhen enabled, the interpreter outputs detailed information about evaluation
steps. Both tracing flags default to false.
Allocation-Failure Injection
Bugs on the out-of-memory path (double frees, cleanup of never-initialized
state, leaks) never run while allocation succeeds. The eval test runner can
make them run on demand; each test executes in a forked child, and these
options act inside that child:
# Fail a random allocation in every child; each child logs the index it drew.
zig build run-test-eval -- --fail-alloc random --child-log-dir /tmp/child-logs --debug-allocator
# Reproduce one finding exactly.
zig build run-test-eval -- --fail-alloc 78 --filter "the test's name" --debug-allocator--debug-allocator runs the child on a DebugAllocator (arenas pass every
allocation through it), so double and invalid frees are reported; build with-Ddebug-gpa-traces=true to get allocation traces. Leaks are not reported
here, because the child exits without tearing its allocator down; thecheckAllAllocationFailures unit tests cover those. The allocation
count a child reaches depends on this choice, so reproduce with the same flags
that found the bug. --freeze-on-crash keeps a segfaulting child alive and
ptrace-able so gdb -p can inspect the crashed state (Linux only).
Refcount Tracing
For debugging memory management issues, use the -Dtrace-refcount flag:
zig build -Dtrace-refcount=trueWhen enabled, this outputs detailed refcount operations to stderr:
[REFCOUNT] DECREF str ptr=0x1234 len=5 cap=32
[REFCOUNT] DECREF list ptr=0x5678 len=3 elems_rc=1 unique=1
[REFCOUNT] INCREF str ptr=0x1234 len=5 cap=32Expect a large volume of output: every incref/decref is logged.
Overview (`src/fmt/README.md`)
Fmt
This directory contains the implementation of the roc fmt command, the code formatter for the Roc language.
Its primary purpose is to format .roc source files with a consistent and deterministic style.
While this module is currently only used by the roc CLI, it is designed to be reusable so that it can be integrated into other tools in the future.
Overview (`src/glue/README.md`)
Roc Glue
roc glue generates platform-facing bindings from checked Roc platform
metadata. A glue script such as src/glue/src/ZigGlue.roc,src/glue/src/RustGlue.roc, or src/glue/src/CGlue.roc receives the
compiler-emitted type table and writes host-language source files for platform
authors.
Why Glue Code Generates the Interface
The Roc platform ABI is not just a collection of structs that can be copied by
hand into C, Zig, or Rust. Host-visible values carry target-specific layout,
ownership, and refcounting contracts:
- records and tag unions have compiler-committed field order, padding, size,
alignment, discriminants, payload offsets, and pointer-width differences Str,List,Box, and erased callables have runtime allocation headers
and ownership rules that are not visible from the Roc source type alone- lists of refcounted elements need different allocation metadata and teardown
than lists of plain bytes - seamless slices do not necessarily point at the allocation base that must be
retained or released - hosted functions receive owned refcounted arguments, and Roc-provided
functions return owned refcounted values - recursive refcounted data requires compiler-derived retain/release plans
- erased callables require a specific call ABI, capture teardown callback, and
host/runtime context - 128-bit integer vectors use the target C ABI's native vector class, including
different direct-argument, direct-result, and aggregate rules, and
memory-class multi-vector aggregates need the union spelling in C
For those reasons, platform code should consume generated glue instead of
hand-rolling Roc ABI bindings. The generated interface is the compatibility
layer between platform authors and Roc compiler internals. If Roc needs to
change its ABI in the future, platform consumers should be able to regenerate
glue and keep using the generated helper API rather than rewriting manual
layout and ownership code.
Generated Helper Contract
CGlue, ZigGlue, and RustGlue should expose idiomatic helpers for their host
languages while enforcing the same underlying ABI contract. Generated helpers
should cover:
- layout assertions for ABI-visible types
- typed views and constructors for strings, lists, boxes, records, tag unions,
integer vectors, and erased callables - retain, release, and recursive teardown for refcounted public types
- explicit ownership transfer for hosted arguments, provided returns, stored
values, and values returned back to Roc - allocation helpers for Roc headers, alignment, overflow checks, static data,
list element metadata, and pointer-width differences - erased-callable invocation helpers for args buffers, return buffers, captures,
and owned results - unsafe or ownership-typed APIs where the host language cannot make invalid
states impossible
Glue generators must consume compiler-emitted metadata such as RecordRepr,RecordField, TagUnionRepr, TagVariant, TypeRepr, and explicit ownership
plans. They must not reconstruct runtime layout or ownership behavior from
display strings, semantic type names, source field order, or incidental data
structure shape.
For concrete Roc-owned values, Zig and Rust glue compose zero-runtime-storage
release policies. RocList(T).deinit in Zig selects the compiler-generated
policy for T at comptime. Rust provided functions whose results have complete
release policies also get an _owned wrapper returning RocOwned; its
zero-sized policy implements recursive Drop without adding a descriptor or
context pointer to the value. List release atomically claims the final spine
reference before reading elements, then releases the allocation's complete
element range and finally frees the spine. Opaque payloads are not assigned a
structural policy: recursive teardown across that boundary requires an explicit
compiler-owned release root or descriptor rather than host-side guessing.
Pointer-Width Contract
The glue script input is targetless. It carries no concrete Roc build target,
OS, or architecture, and roc glue has no --target flag: running it on any
machine produces the same generated source for a given platform and glue
script. Compiler-emitted layout facts vary only by host pointer width, modeled
as AbiWidth := [Pointer32, Pointer64]. Generated source may additionally use
the host compiler's target conditionals to select the natural C spelling and
calling convention for a native vector leaf; that selection does not alter any
compiler-emitted record, union, or ownership fact.
What the compiler guarantees to glue scripts:
- Every layout fact arrives for both widths (
size32/size64,offset32/offset64,alignment32/alignment64,discriminant_offset32/discriminant_offset64, ...), read from the layout
store with explicit dual-width queries. Use the width-parameterized
accessors (AbiLayout.size(layout, width),AbiFieldLayout.offset(field, width), ...). - Everything else is width-independent: field order, field names, tag names,
discriminant values, and refcount plans. AbiRecordLayout.fieldsandAbiTagLayout.payload_fieldsare emitted in
committed layout order, which is identical at both widths with
non-decreasing offsets at both widths (asserted insrc/glue/glue.zig).
Emitters must iterate fields in the given order and must not re-sort them.- Padding fields (
is_padding) have nonzero size at both widths; zero-sized
padding markers such as_ : {}are dropped before emission.
What generators must do with that data:
- Render pointer-sized Roc values (
Str,List,Box, erased callables)
with the host language's pointer-sized types (size_t/uintptr_t,usize,
raw pointers), not hardcoded 4- or 8-byte shapes. - Render every
Vector128leaf with the target's native vector type rather
than a byte array orU128substitute. Keep all eight Roc vector names as
distinct generated binding types. - Emit one source file per language containing both width shapes, resolved by
the host compiler:#if UINTPTR_MAX == UINT64_MAXin C,if (@sizeOf(usize) == 4)in Zig,#[cfg(target_pointer_width = "32")]in
Rust. A future language without a compile-time width idiom may instead emit
one file per width; the dual-width schema supports both. - Emit size/alignment/offset assertions from the emitted facts for both
widths, so the host compiler proves the generated shapes match the
committed ABI. - Never compute sizes, offsets, or padding from reflected type shape; the
compiler owns the ABI facts.
Concrete build targets still exist, but only in the test harness and roc build: the runtime matrix compiles the same generated source for native and
wasm32 hosts and runs it in both.
Regenerating the C Header Snapshot
glue command with CGlue generates expected C header byte-compares CGlue
output for test/fx/platform/main.roc againsttest/glue/fx_platform_cglue_expected.h. After an intentional CGlue change,
regenerate the snapshot with:
roc glue src/glue/src/CGlue.roc /tmp/cglue-out test/fx/platform/main.roc
cp /tmp/cglue-out/roc_platform_abi.h test/glue/fx_platform_cglue_expected.hTreat an unexpected diff as an emitter bug, not a regen trigger.
Host ABI Lock
The templates restate the host ABI—the RocStr/RocList/RocDec
layouts, the RocHost callback vtable, the extern runtime symbols, and the
erased-callable payload—as text in their output languages, so the compiler
cannot import those restatements. zig build run-check-glue-abi is the
enforcement. It generates Zig glue and compiles test/glue/zig_abi_lock.zig
against both the generated file and the canonical builtins definitions for
x86-64 and AArch64 on Linux, macOS, and Windows, plus wasm32. It also generates
the vector-heavy layout-probe C and Rust bindings: C is compiled over that
same seven-target matrix, while Rust is compiled for the configured native
target and wasm32 (rustc requires an installed core library for every
additional cross target).
Template drift from src/builtins/host_abi.zig, str.RocStr,list.RocList, dec.RocDec, or erased_callable fails that step at compile
time. Generated C and Rust files carry matching offsetof/offset_of! static
assertions, which their compile controls and the native/wasm runtime matrix
verify per target.
test/glue/generate_abi_lock.zig reflects the canonical builtins declarations
into target-independent C and Rust reference declarations. C includes the
resulting canonical_host_abi.h; Rust compiles a copy of its generated bindings
with canonical reference structs, layout assertions, and type assignments
appended. The foreign compilers determine each target's physical layout. C
locks strings, lists, and erased-callable callbacks; Zig and Rust also lock the
runtime symbol signatures and the helper-only RocHost callback prefix.RocHost deliberately excludes interpreter-only hosted dispatch tables and is
never passed across compiled Roc entrypoints.
Glue platform marshalling
glue.zig materializes glue input and reads its result through the checked
platform's runtime schemas and committed LIR layouts. The oldplatform/host.zig extern records have been removed; alphabetical field order
is no longer a handwritten ABI assumption. GlueRocValueWriter.recordField
uses the schema's logical field index and the layout store's physical offset.
The glue platform marshalling schema matches checked Roc records test compilesDebugGlue.roc and locks the complete marshalling protocol against the actual
checked platform, including nested list elements, records, tag payloads, and
scalar leaf types.
A second test copies the platform sources into isolated temporary directories,
mutates field names, field sets, scalar types, nested record types, and tag
payloads. Every mutated checked schema must fail the same lock. Run both with:
zig build run-test-zig-module-glue -- --test-filter 'glue platform'ABI Risk Register and Roc Glue Runtime Controls
Every risk in this section must be covered by at least one Roc glue test
platform. In this context, "runtime control" means a check executed by the Roc
glue test harness, not a production assertion inserted into ordinary generated
glue.
The glue runtime harness works by:
- selecting realistic platform shapes,
- generating C, Zig, and Rust glue for those platforms,
- compiling each generated interface with a matching host implementation for
native and wasm targets, - building a Roc app that imports the platform, and
- executing the resulting native program or wasm module.
The host implementation in those runs is deliberately a contract test runner.
It implements the platform API while collecting allocator statistics,
telemetry, ownership events, layout checks, refcount checks, and scenario
results.
The control column names concrete glue runtime scenarios, platform shapes,
language matrix cells, compile checks, or source-shape assertions that enforce
the contract. glue runtime: means the CGlue, ZigGlue, and RustGlue
runtime matrix for both native and wasm32 targets unless the entry names a
specific language, target, or custom case.
Those controls should run through realistic glue platforms and should cover
CGlue, ZigGlue, and RustGlue. A bug found in one generated language should be
captured as a shared platform shape unless the behavior is genuinely
language-specific.
| Risk | Required generated support | Runtime control |
|---|---|---|
| Owned-vs-borrowed boundary confusion for hosted args, provided returns, and stored values | Explicit retain/release/move helpers and generated hosted-function comments/signatures that make ownership transfer visible | glue runtime: cli-main owns/decrefs RocStr hosted args and roc_main results; glue runtime: app-model owns/decrefs boxed models and rendered View; all CGlue/ZigGlue/RustGlue native+wasm32 cells. |
Recursive refcounting for nested records, tag payloads, lists, boxes, closures, and recursive types through Box |
Data-driven recursive retain/release helpers emitted from compiler RC plans, not from type names | glue runtime: type-catalog exercises Tree, boxed payloads, recursive tags, and catalog unions; glue runtime: layout-probe exercises many boxed fields; glue command generated Zig compiles with zig build-obj checks emitted retain/release helpers. |
| Lists with refcounted elements, including backing element-count headers | List helpers that know element width, alignment, and whether element teardown is required | glue runtime: cli-main constructs List(Str) args and validates the refcounted element-count header; glue runtime: app-model renders and decrefs View.messages : List(Msg); issue 10451's Zig/Rust glue cases compile recursive release for a directly returned List(Str). |
Seamless slices for Str and List |
Allocation-base recovery helpers that never free the visible slice pointer directly | glue runtime: cli-main uses generated Zig/Rust RocStr.fromSlice and RocList.fromSlice helpers and decrefs through allocation-base recovery; CGlue helper source is covered by glue command generated C header compiles with zig cc and CGlue.roc expect tests pass. |
| Roc allocation headers, static refcount zero, and deallocation alignment | Allocator wrappers and assertions for header size, alignment, canaries, and static-data no-op release | glue runtime: cli-main and glue runtime: app-model use adversarial host allocators with alignment/live-allocation checks; glue regression: ZigGlue decrefs non-refcounted boxed payloads with payload alignment and the Rust equivalent cover boxed payload alignment/static-release helpers. |
RocStr small, big, static, and sliced representations |
Safe byte/as-str views, constructors with UTF-8 policy, retain/release helpers, and no C-string assumptions | glue runtime: cli-main covers roc_cli_read, roc_cli_log, and app args; glue runtime: type-catalog covers provided/result strings in records and tag payloads; glue runtime: app-model covers View.title. |
RocList representation, including different field order from RocStr |
Typed list helpers for allocation, initialization, slicing, element access, retain, and release | glue runtime: cli-main validates List(Str) construction and teardown; glue runtime: type-catalog covers List(Bool) fields; glue runtime: app-model checks List(Msg) layout in View. |
| Erased callable invocation from the host | Generated callable wrapper APIs for args buffer, return buffer, capture pointer, host context, and owned result handling | glue command with CGlue generates expected C header, glue regression: RustGlue succeeds on fx platform, glue command generated Zig compiles with zig build-obj, and glue regression: ZigGlue decrefs non-refcounted boxed payloads with payload alignment cover generated callable payload, capture, call, and drop APIs. |
| Stored Roc closures and dev hot-reload image lifetime | Retain/release helpers for callable boxes and documented per-thread host-state requirements for later invocation | glue command with CGlue generates expected C header covers boxed callable store/call signatures; glue regression: ZigGlue decrefs non-refcounted boxed payloads with payload alignment checks retained callable final decref; roc --watch hot reloads app-provided Model through Box covers image lifetime outside the glue suite. |
| Natural C ABI target differences, including sret, aggregate returns, wasm32, and pointer width | Generated extern signatures and compile-time layout assertions per target language | Full glue runtime: matrix compiles/links/runs native and wasm32 hosts for CGlue/ZigGlue/RustGlue; glue runtime: type-catalog covers Dec, I128, U128, records, unions, and aggregate returns; C exact-header and compile checks catch stale C ABI signatures. |
Native 128-bit vector ABI, including SysV SSE/SSEUP, Win64 indirect arguments, AAPCS64 Q-register placement, memory-class multi-vector aggregates, wasm v128, mixed aggregates, and register exhaustion |
Distinct generated vector leaf types, C-layout aggregates, exact assertions, and extern declarations that use the target C ABI directly | glue runtime: layout-probe calls every vector and aggregate shape in both directions from generated C/Zig/Rust hosts; run-check-glue-abi compile-locks generated C and Zig across x86-64/AArch64 Linux/macOS/Windows plus wasm and Rust across native+wasm. |
| Compiler-emitted layout facts: field order, offsets, discriminants, size, alignment, zero-sized values, and Bool representation | Glue consumers must use emitted AbiLayout metadata and generated assertions rather than reconstructing layout |
glue runtime: layout-probe, glue runtime: duplicate-tags, and glue runtime: type-catalog cover emitted layout facts across all languages/targets; glue command generated Zig compiles with zig build-obj checks field offsets and compile-time layout assertions. |
| Nominal vs structural records and explicit padding fields | Generated records with concrete committed fields plus explicit padding and per-target size, alignment, field-offset, and padding assertions | glue runtime: type-catalog covers nominal and structural records; glue runtime: layout-probe covers padded/boxed/vector layouts; glue regression: ZigGlue quotes bang record fields covers field names that require escaping. |
| Tag union active-payload handling, discriminant layout, single-variant unwrapping, duplicate names, recursive tags, and payload ownership transfer | Generated tag constructors/destructors, unsafe borrowed references, and unsafe consuming payload projections that only touch the validated active payload | glue runtime: duplicate-tags covers duplicate module-local tag/result names; glue runtime: type-catalog covers single-payload, no-payload, recursive, boxed, and result tag unions and consumes Rust owning payloads through take_payload_*_unchecked. |
Opaque or library containers such as Dict whose internals are not platform ABI |
Opaque-safe helpers only; no generator or host code may infer container internals from semantic names | glue regression: ZigGlue uses RocBox for opaque boxed app types enforces RocBox for opaque boxed app values; C/Rust source-shape and compile controls cover opaque helper signatures without exposing container internals. |
| Accidental shallow copies of ownership-bearing values | Rust non-Copy or unsafe ownership APIs; Zig/C retain/release naming and runtime double-free/leak checks |
glue runtime: cli-main, app-model, and type-catalog all check balanced live allocations after owned values cross the boundary; glue regression: RustGlue succeeds on fx platform covers Rust ownership-shaped generated APIs. |
| Error paths after ownership transfer into hosted functions | Scenario helpers that deliberately fail after receiving owned values and verify cleanup policy | glue runtime: duplicate-tags exercises Try-returning hosted functions with owned payloads; glue runtime: cli-main checks failure reporting paths and requires owned hosted args to be decrefed before a host-side failure is recorded. |
| Thread sharing and atomic vs thread-confined refcounting | Thread-aware contracts where supported, plus explicit documentation of host-visible value sharing requirements | Generated Zig/Rust helper compile controls cover atomic refcount operations; glue regression: ZigGlue decrefs non-refcounted boxed payloads with payload alignment and the Rust equivalent check final decrement behavior; runtime matrix documents that current glue hosts are single-threaded. |
| Stale generated bindings after ABI changes | Version/header assertions or harness checks that regenerated glue is used for each platform and target | runGlueRuntimeCase runs roc glue into an isolated work directory before each host compile/link/run; full glue runtime: matrix plus glue command with CGlue generates expected C header catch stale generated signatures. |
| C representation of records and tag unions | Concrete C fields, named discriminants, concrete payload storage, typed constructors/accessors, and helpers for ownership operations that field access alone cannot safely express | glue command with CGlue generates expected C header, glue command generated C header compiles with zig cc, CGlue.roc expect tests pass, and CGlue runtime cells for all five platform shapes cover concrete records, tags, payload access, macros, and helper declarations. |
Release Files
The checked-in glue specs under src/glue/src/ use platform glue, a
compiler-owned platform embedded in the Roc compiler. Keep that form for local
compiler development and releases.
Nightly releases should use generated copies instead:
zig build build-glue-release -Dglue-release-tag=nightly-YYYY-Month-DD-SHORTSHAThis writes zig-out/glue-release/:
glue/RustGlue.roc
glue/ZigGlue.roc
glue/CGlue.roc
glue/README.md
glue/envInclude the generated glue/ directory in every platform compiler archive for
that same release. The generated specs rely on the compiler-owned glue platform
embedded in that compiler build, so downstream repos do not need a Roc source
checkout or a separate glue platform package.
When roc-lang/setup-roc installs a nightly that contains glue/, it exports:
ROC_GLUE_DIR
ROC_RUST_GLUE
ROC_ZIG_GLUE
ROC_C_GLUEDownstream CI can then run:
roc glue "$ROC_RUST_GLUE" ./platform/main.roc --output-dir ./platformOverview (`src/interpreter_shim/README.md`)
Interpreter Shim
The interpreter shim runs already-lowered Roc programs. The compiler parent
process owns checking and post-check compilation and publishes an ARC-inserted
LIR image into shared memory. The child process maps that same memory, creates
zero-copy views over the LIR/runtime-layout arrays, and evaluates them with the
LIR interpreter.
Runtime Boundary
┌─────────────────┐ LIR image ┌──────────────────┐
│ roc CLI │ ──────────────────────> │ Interpreter │
│ (parent proc) │ │ Host (child) │
└─────────────────┘ └──────────────────┘Parent responsibilities:
- Parse and canonicalize source.
- Type check and publish checked modules.
- Resolve roots, platform entrypoints, hosted procedures, static dispatch, and
compile-time constants before post-check lowering. - Lower through the post-check IRs to LIR and insert ARC statements.
- Publish the target-specific LIR image into shared memory.
Child responsibilities:
- Map shared memory.
- Validate the LIR image header and bounds.
- Create zero-copy views of the LIR store, committed layouts, literal pool,
root procedures, and hosted-function dispatch metadata. - Initialize
LirInterpreter. - Invoke the explicit LIR root procedure requested by the host.
The child must never receive ModuleEnv, CIR, checked modules, or post-check
IRs. It must not perform compiler lowering, root discovery, static dispatch
resolution, platform lookup, compile-time evaluation, or recovery of missing
compiler data.
Operating Modes
IPC Mode (`roc path/to/app.roc`)
The parent publishes the LIR image into shared memory using the existing
shared-memory coordination path. The child maps that memory and views the image
in place.
Embedded Mode (`roc build --opt=interpreter path/to/app.roc`)
The parent embeds a viewable LIR image into the output binary. At runtime,
the shim views that image without running semantic compiler stages.
Key Symbols
roc__main: Entry point called by the platform host.roc_alloc,roc_dealloc,roc_realloc: Memory allocation functions.
Overview (`src/ipc/README.md`)
IPC (Inter-Process Communication)
This directory contains helpers for communication between the roc CLI and the Roc interpreter, which is spawned as a child process.
The communication is implemented using shared memory to ensure high performance.
The roc CLI owns parse, canonicalization, type checking, checked module publication, post-check lowering, LIR lowering, and ARC insertion. After ARC insertion, the parent publishes a target-specific LIR image into the existing shared-memory region. The interpreter child maps that same shared memory, validates the LIR image header, creates zero-copy LIR views, and evaluates explicit roots.
The interpreter child must not receive or inspect ModuleEnv, CIR, checked modules, or post-check IRs, and it must not run compiler lowering stages. Parent-child IPC for the LIR image is shared-memory mapping only, not serialization/deserialization.
This allows for a very responsive CLI and a fast development loop.
Overview (`src/layout/README.md`)
Layout
Memory layout and data structure representation for the Roc runtime.
Overview
The layout module is responsible for determining how Roc data types are represented in memory during execution. It handles memory alignment, field ordering, and efficient storage of Roc's algebraic data types.
Purpose
This module provides:
- Memory Layout: Determining the optimal memory layout for Roc data structures
- Field Ordering: Performing one shared stable alignment sort at the
IR -> LIR/layoutboundary while preserving earlier semantic field order among equal-alignment fields - Alignment: Ensuring proper memory alignment for different data types
- Size Calculation: Computing the memory requirements for Roc types
- Runtime Support: Layout information needed by the interpreter and code generator
The layout module is crucial for the eval stage (interpreter) and any future code generation stages, as it determines how data is stored and accessed in memory.
Overview (`src/lir/README.md`)
Low-level intermediate representation
LIR is the common, layout-complete boundary between checked-program lowering
and execution. Ownership-neutral transformations preserve explicit value,
control-flow, and frame inventories; ARC then makes every ownership operation
explicit so backends and the interpreter need no reference-counting policy.
Parallel procedure rewrites and ARC emission share frozen inputs and publish
private body output in deterministic order. Global analyses and identity
reservation remain coordinator-owned. LirImage preserves the resulting
ARC-complete program without compiler scratch or worker state.
use_order.zig owns the neutral control-flow queries shared by loop promotion
and ARC. Procedure rewrites use compact statement/local domains; ARC retains
its store-indexed topology and reuses scratch across procedure queries, resetting
only touched entries. Neither client reconstructs ownership decisions from the
other.
See design.md for the transformation, ownership, and serialization contracts.
Overview (`src/llvm_compile/README.md`)
LLVM Compile
Compiles LLVM bitcode into native shared libraries for JIT evaluation.
This module sits between the LLVM IR codegen backend (src/backend/llvm/) and
the evaluators that execute compiled Roc code. It handles the LLVM-to-native
compilation pipeline and platform-specific linking so that callers can simplydlopen the result and call roc_eval.
Overview (`src/lsp/README.md`)
Roc LSP
The Roc compiler can now expose an experimental Language Server Protocol (LSP) endpoint
written in Zig as part of the Rust to Zig rewrite.
Current state
The following requests are handled:
initialize,shutdowntextDocument/hovertextDocument/definitiontextDocument/completiontextDocument/documentSymboltextDocument/documentHighlighttextDocument/referencestextDocument/inlayHinttextDocument/codeActiontextDocument/formattingtextDocument/foldingRangetextDocument/selectionRangetextDocument/semanticTokens/fulltextDocument/rename,textDocument/prepareRename
The following notifications are handled:
initialized,exitdidOpen(stores the buffer into aStringHashMap)didChange(same asdidOpen, and supports incremental changes)
Diagnostics are pushed as textDocument/publishDiagnostics when a document is opened or changed.
Inlay hints
Roc infers most types, and an annotation can still leave parts to inference with _, so the
type a binding actually has is often written nowhere in the file. textDocument/inlayHint
renders it next to the name.
Only plain bindings get a hint—one name, one type. Skipped are bindings that already carry a
written annotation (their type is on screen), bindings marked unused with a leading _, and
any pattern whose source text is not exactly the name: a compiler-synthesized binding has no
name in the source, and import "x" as name : Str already spells its type.
Labels are cut at 56 bytes with a trailing …, because a hint is drawn inside the line it
annotates and an inferred Roc type can be far longer than the code it describes—a generic
function carries its whole where clause. Hovering the same name gives the type in full.
Each hint carries the edit that writes its type into the source, so a client that lets the
reader accept a hint inserts the annotation instead of leaving them to retype it. That matters
here more than elsewhere: a hint reads like an inline annotation, which is how several
languages spell one, and Roc has no name : Type = value form - inside a block that spelling
is already taken by record fields, so retyping what a hint shows changes what the braces mean.
The edit writes the two-line form, and carries the type in full even where the label is cut.
A hint on a lambda parameter carries no edit. The type is still worth seeing, but a parameter
takes no annotation, and one written above it would land in the parameter list. The same
applies to any binding that does not open its line.
A document that does not build produces no hints, which leaves whatever the editor last drew
in place rather than blanking every hint on each keystroke.
Code actions
Two actions are offered, both written from the checked types rather than from the text on
screen. Neither is offered unless it can be written in full: a document that does not build
has no types to read, and handing the author source that does not compile is worse than
offering nothing.
Annotate a binding with its inferred type writes the type on its own line above the
binding, carrying the indentation of the line it opens. Roc has no inline foo : U64 = 100
form, so this two-line one is the only one there is. It is offered for any binding a
definition or a statement declares that does not already write its type - top level, or
several blocks deep inside a function.
Lambda parameters and destructured fields are left out. They are plain bindings like any
other, and a Roc lambda may spread its parameters over several lines, so a parameter can open
a line of its own: what declares a binding tells the two apart, the source around it does not.
The binding must still open its line, which rules out a block written on a single line, since
anything in front of the binding would be split across the inserted line break. A selection
covering several bindings is answered with one action per binding, each title naming its own:
the range does not say which was meant, and no comparison of names or positions recovers it.
A cursor, which is how the request usually arrives, touches one name and so produces one
action. A selection covering several functions is answered the same way.
Generate an expect test for a function writes an expect that calls the function,
directly after the definition it tests. Each argument and the expected result are placeholder
literals of the right type - "", 0, [], Bool.True, and records and tuples of those -
so the generated test compiles and fails loudly until the author replaces the values.
The action is left out rather than guessed at when a type has no such literal: a nominal type
the author declared, a tag union, a function argument, or a type variable. It is also left out
for effectful functions, because expect checks a value rather than running effects, for
functions taking no arguments, and for anything that is not a top-level definition - a
generated expect sits at the top level, where a name bound inside a block cannot be reached.
Both actions write their line breaks the way the open document writes its own, so applying one
to a CRLF file does not leave a lone LF behind. The compiler rewrites CRLF as it loads a file,
so this is read from the document the client holds rather than from the module's source.
The inserted annotation is the checker's own rendering of the type, which is worth a read
before it is kept: an inferred type can be wider than the one the author would write.
References
textDocument/references reports every place a symbol is written in the requested document.
Occurrences come from the CIR, so a reference resolves to the binding it actually names rather
than to matching text, and a same-named binding in another scope is not reported. Unlike rename
it only reports, so it is not limited to plain bindings—a destructured field can have its uses
listed even though renaming one is refused.
includeDeclaration controls whether the binding site and the name on its type annotation are
included; both count as the declaration. A client that omits the field gets everything.
Only the requested document is searched, so a short result is not proof that a symbol is unused
across a project—see the cross-module note below.
Positions
Positions are exchanged in UTF-16 code units, as the protocol specifies and as the server
advertises with positionEncoding. src/lsp/position.zig owns the conversion in both
directions, with an ASCII fast path, and the same helper backs the incremental edits indocument_store.zig in a strict mode: a query rounds a column that lands inside a character
to the nearest boundary, an edit refuses it.
Rename
Rename edits only the document it was asked about. It rewrites the binding, the name on
its type annotation, and every reference, taking the occurrences from the CIR so shadowing
is respected. Type errors do not stop it: checking replaces rejected code with runtime errors,
and every query here reads through those replacements to the code as written, so occurrences
inside rejected code are renamed, listed, and highlighted like any other.
It refuses rather than producing a partial rewrite, because editing every occurrence but one
silently breaks the program. It refuses when:
- the document could not be built, so there is no CIR to read occurrences from
- the position names something other than a plain local binding—a type, a tag, a record
field, or a destructuring pattern - the new name is not a single Roc identifier
- the new name would change what the name means: case separates types from values, and the
!,_and$markers carry meaning, sofoocannot be renamed tofoo! - another binding of the new name is live where the renamed one is, which would capture a
reference or shadow a binding
Cross-module limits
Neither references nor rename looks past the requested document. References will miss uses in
other modules, and rename edits only the one file, so renaming an exported name breaks its
importers. The editor cannot warn about either, so it is on the author to check.
How to implement new LSP capabilities
The core functionalities of the LSP have been implemented in a way so that transport.zig andprotocol.zig shouldn't have to be modified as more capabilities are added. When handling a new
LSP method, like textDocument/completion for example, the handler should be added in the handlers
directory and its call should be added either in request (if it expects a response) or notification
(if it doesn't expect a response). textDocument/completion for example would go here :
const request_handlers = std.StaticStringMap(HandlerPtr).initComptime(.{
.{ "initialize", &InitializeHandler.call },
.{ "shutdown", &ShutdownHandler.call },
.{ "textDocument/completion", &CompletionHandler.call },
});When adding a new capability, if the server is ready to support it, you need to add the capabilities to
the capabilities.zig file for the initialize response to tell the client the capabilities is available :
pub fn buildCapabilities() ServerCapabilities {
return .{
.textDocumentSync = .{
.openClose = true,
.change = @backingInt(ServerCapabilities.TextDocumentSyncKind.incremental),
},
};
}Here we tell the client that textDocumentSync is available in accordance to the LSP specifications data
structure. The Server struct holds the state, meaning in has the knowledge of the project files, the
documentation, the type inference, the syntax, etc. Every handler has access to it. These points of knowledge
are ideally separated in different fields of the server. For example, the opened buffer and other desired files
are stored in a DocumentStore which is a struct containing a StringHashMap, accessible through the Server.
Starting the server
Build the Roc toolchain and run:
roc experimental-lspNormally you would just let your editor handle this.
Debugging
Because the LSP takes control of the standard input and output, an optional flag was added.
roc experimental-lsp --debug-transportPassing the --debug-transport flag will create a fresh lsp-debug-<random>.log in your Roc cache directory
(~/.cache/roc on Linux, ~/Library/Caches/roc on macOS, %APPDATA%\Roc on Windows; the server
prints the exact path to stderr on startup). A mirror of the raw JSON-RPC traffic will be appended
to the log file. Watching the file will allow a user to see incoming and outgoing message between
the server and the editor
tail -f /path/printed/by/the/server
---
[1763992681773] OUT (128 bytes)
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"positionEncoding":"utf-16"},"serverInfo":{"name":"roc-lsp","version":"0.1"}}}
---
[1763992681828] IN (52 bytes)
{"jsonrpc":"2.0","method":"initialized","params":{}}
---Additional debug channels can be enabled with --debug-build, --debug-syntax, and --debug-server
which log build environment activity, syntax/type checking, and server lifecycle details respectively
to the same session log file. Logs are created exclusively with owner-only permissions
on Unix; existing files and symlinks are never reopened. Transport logs contain
full document contents.
Workspace trust
By default, opening or changing a document does not compile it. Formatting,
folding ranges, and selection ranges remain available; diagnostics and semantic
features require the client to send this in its initialize params:
"initializationOptions": { "trustedWorkspace": true }Enable this only after the user trusts the workspace: checking can download URL
packages and execute compile-time Roc code inside the server. Clients must derive
trust from their own user-controlled trust mechanism, never repository settings.
Restart the server when trust changes. Trust applies to the entire server session,
including every opened document and transitive dependency; use separate sessions
for untrusted folders. Clients without a trust integration remain in syntax-only
mode. Semantic requests return a trust-required error in that mode.
Editor examples
Neovim (lua + nvim-lspconfig)
The plan is to eventually just add the lsp to the Mason package manager, but for the meantime a manual
setup is possible. Your config might vary, but it'll look something like this:
local lspconfig = require("lspconfig")
local configs = require("lspconfig.configs")
if not configs.roc_lsp then
configs.roc_lsp = {
default_config = {
name = 'roc_lsp',
cmd = { '/path/to/roc', 'experimental-lsp', '--debug-transport' },
filetypes = { 'roc' },
root_markers = { 'main.roc', 'app.roc' },
single_file_support = true,
},
}
end
lspconfig.roc_lsp.setup({
capabilities = require("cmp_nvim_lsp").default_capabilities(),
})
-- If using Mason, you might have to remove the ensure_installed check
ensure_installed = vim.tbl_filter(function(name)
return name ~= 'roc_lsp'
end, ensure_installed)VS Code
TODO
Zed
TODO
Overview (`src/machine_code_shim/README.md`)
Machine-code run shim
This archive loads the compiler's explicit run-image contract inside a platform
process. Its build-time verifier seals the host boundary: only declared Roc and
C/OS ABI imports and the three shim entrypoints may participate in platform
symbol resolution.
Compiler-generated 32-bit arithmetic and ARM AAPCS helpers use the active Zig
toolchain's implementations, compiled into the shim with local binding. They
must neither depend on the host's compiler-rt nor interpose its definitions.
Source references retain the implementations, while assembler-local aliases
preserve libcall names through LLVM's late libcall generation. Whole public
compiler-rt archives are not shim inputs.
Linux ARM instruction publication uses the kernel cache-flush ABI. The Linux
ARM shim emits no exception-unwind tables: its host crash contract aborts and
its std options disable stack tracing, so it must not depend on EHABI
personalities or pretend to implement them with empty routines. This policy
does not change Windows unwind metadata or other architectures' stack walking.
zig build check-selected-machine-code-shim -Dtarget=x86-linux-musl (orarm-linux-musleabihf) checks the actual archive and links it with deliberate
strong host/compiler-runtime collisions. run-check-machine-code-shim-archive
covers all supported object formats. Run optimized checks too: libcall creation
and dead-code elimination differ from Debug.
Overview (`src/parse/README.md`)
Parse
Converts Roc source code into an Abstract Syntax Tree (AST) through tokenization and parsing.
Purpose
The parse module is the first stage of the Roc compiler pipeline, transforming raw source code into a structured representation that can be processed by subsequent stages (canonicalization, type checking, etc.).
Overview (`src/playground_wasm/README.md`)
Roc WASM Playground Interface Control Document
Overview
The Roc Playground provides a WebAssembly interface to the compiler, providing analysis of .roc code in web browsers and JavaScript environments. It implements a state machine architecture to support both single-file compilation analysis and interactive REPL (Read-Eval-Print Loop) sessions.
This playground protocol remains available for compiler exploration. Embedders
that only need a REPL should use the smaller-scope interface documented insrc/repl_wasm/README.md; that module has no compiler-introspection modes.
The playground supports:
- Single-file compilation: Load and analyze complete Roc source files
- Interactive REPL: Evaluate expressions and definitions incrementally
- Compiler introspection: Query tokens, AST, CIR, types, and hover information
- Diagnostic reporting: Comprehensive error and warning information
- State management: Clean separation between compilation and REPL modes
State Machine
The playground operates as a finite state machine with four states:
START --(INIT)-> READY --(LOAD_SOURCE)-> LOADED
| |
| |
+--(INIT_REPL)--> REPL_ACTIVE
^ |
| |
+--------(RESET)--------+State Descriptions
- START: Initial state, awaiting initialization
- READY: Initialized and ready to compile source code or start REPL
- LOADED: Source compiled, ready for queries about compilation stages
- REPL_ACTIVE: REPL session active, ready for interactive evaluation
WASM Exports
Core Functions
// Initialize the WASM module
init(): void
// Process a message and get response
processMessage(
messagePtr: number, // Pointer to JSON message
messageLen: number, // Length of message
responsePtr: number, // Pointer to response buffer
responseBufferSize: number // Size of response buffer
): number // Returns actual response length
// Memory management
allocate(size: number): number | null // Returns pointer or null
deallocate(ptr: number, size: number): void
// Utilities
getCurrentState(): number // Returns current state as integer
cleanup(): void // Clean up all resourcesMessage Protocol
All communication uses JSON messages. Each message must have a type field.
Message Types
1. INIT
State: START → READY
Purpose: Initialize the compiler and get version information
Request:
{"type": "INIT"}Response:
{
"status": "SUCCESS",
"message": "debug-813709ba"
}2. LOAD_SOURCE
State: READY → LOADED
Purpose: Compile Roc source code through all compiler stages
Request:
{
"type": "LOAD_SOURCE",
"filename": "Demo.roc",
"source": "Demo := [].{\n foo = 42\n bar = \"baz\"\n}\n"
}Response:
{
"status": "SUCCESS",
"message": "LOADED",
"diagnostics": {
"summary": {"errors": 0, "warnings": 0},
"debug_counts": { ... },
"list": [],
"html": "<div class=\"reports\"></div>"
}
}3. QUERY_TOKENS
State: LOADED
Purpose: Get tokenization results as formatted HTML
Request:
{"type": "QUERY_TOKENS"}Response:
{
"status": "SUCCESS",
"data": "<div class=\"token-list\">...</div>"
}4. QUERY_AST
State: LOADED
Purpose: Get Abstract Syntax Tree as formatted HTML
Request:
{"type": "QUERY_AST"}Response:
{
"status": "SUCCESS",
"data": "<div class=\"ast-tree\">...</div>"
}5. QUERY_CIR
State: LOADED
Purpose: Get Canonical Intermediate Representation as formatted HTML
Request:
{"type": "QUERY_CIR"}Response:
{
"status": "SUCCESS",
"data": "<div class=\"cir-display\">...</div>"
}6. QUERY_TYPES
State: LOADED
Purpose: Get type information as formatted HTML
Request:
{"type": "QUERY_TYPES"}Response:
{
"status": "SUCCESS",
"data": "<div class=\"types-display\">...</div>"
}7. QUERY_FORMATTED
State: LOADED
Purpose: Get formatted Roc code
Request:
{"type": "QUERY_FORMATTED"}Response:
{
"status": "SUCCESS",
"data": "Demo := [].{\n\tfoo = 42\n\tbar = \"baz\"\n}\n"
}8. GET_HOVER_INFO
State: LOADED or REPL_ACTIVE
Purpose: Get hover information for an identifier at a specific source position
Request:
{
"type": "GET_HOVER_INFO",
"identifier": "x",
"line": 1,
"ch": 5
}Response:
{
"status": "SUCCESS",
"hover_info": {
"name": "x",
"type_str": "Num *",
"definition_region": {
"start_line": 1,
"start_column": 1,
"end_line": 1,
"end_column": 7
},
"docs": null
}
}Note: In REPL_ACTIVE state, this works with the last REPL evaluation's ModuleEnv.
9. INIT_REPL
State: READY → REPL_ACTIVE
Purpose: Initialize a REPL session for interactive evaluation
Request:
{"type": "INIT_REPL"}Response:
{
"status": "SUCCESS",
"message": "REPL initialized",
"repl_info": {
"compiler_version": "debug-813709ba",
"state": "REPL_ACTIVE"
}
}10. REPL_STEP
State: REPL_ACTIVE
Purpose: Submit a line of input to the REPL for evaluation
Request:
{
"type": "REPL_STEP",
"input": "x = 42"
}Response (Definition):
{
"status": "SUCCESS",
"result": {
"output": "assigned `x`",
"type": "definition",
"compiler_available": true
}
}Response (Expression):
{
"status": "SUCCESS",
"result": {
"output": "43",
"type": "expression",
"compiler_available": true
}
}Response (Error):
{
"status": "SUCCESS",
"result": {
"output": "Evaluation error: error.ZeroSizedType",
"type": "error",
"error_stage": "evaluation",
"error_details": "error.ZeroSizedType",
"compiler_available": false
}
}Result Types:
"definition": Input was a variable assignment (e.g.,x = 42)"expression": Input was an expression that was evaluated"error": Input caused a compilation or evaluation error
Error Information (when type is "error"):
error_stage: Indicates which compiler stage produced the error"parse": Syntax/parsing error"canonicalize": Canonicalization error"typecheck": Type checking error"layout": Layout computation error"evaluation": Runtime evaluation error"interpreter": Interpreter initialization error"runtime": General runtime error"unknown": Error stage could not be determined
error_details: Additional error details, typically the specific error name or message
Compiler Availability:
compiler_available: true: Compiler queries (QUERY_CIR, QUERY_TYPES, QUERY_FORMATTED, GET_HOVER_INFO) are availablecompiler_available: false: Compiler queries are not available (usually due to errors)
11. CLEAR_REPL
State: REPL_ACTIVE
Purpose: Clear all REPL definitions while keeping the REPL session active
Request:
{"type": "CLEAR_REPL"}Response:
{
"status": "SUCCESS",
"message": "REPL cleared",
"repl_info": {
"compiler_version": "debug-813709ba",
"state": "REPL_ACTIVE"
}
}12. RESET
State: LOADED or REPL_ACTIVE → READY
Purpose: Clean up compilation state and return to READY
Request:
{"type": "RESET"}Response:
{
"status": "SUCCESS",
"message": "debug-813709ba"
}Response Status Codes
SUCCESS: Operation completed successfullyERROR: Operation failed due to internal errorINVALID_STATE: Message not valid for current stateINVALID_MESSAGE: Malformed or unrecognized message
Diagnostic Information
The LOAD_SOURCE response includes comprehensive diagnostic information:
Summary
errors: Total number of compilation errorswarnings: Total number of warnings
Debug Counts
Per-stage diagnostic counts:
tokenize: Tokenization errors/warningsparse: Parse errors/warningscan: Canonicalization errors/warningstype: Type checking errors/warnings
Diagnostic List
Array of diagnostic objects with:
severity: "error", "warning", or "info"message: Human-readable diagnostic messageregion: Source location with start/end line/column
HTML Output
Pre-formatted HTML for displaying diagnostics in web interfaces.
REPL Functionality
The playground now supports interactive REPL (Read-Eval-Print Loop) sessions that allow users to:
- Define variables:
x = 42→ "assignedx" - Evaluate expressions:
x + 1→ "43" - Redefine variables: Automatically updates dependent expressions
- Error recovery: Invalid input doesn't corrupt REPL state
- Compiler integration: Access compiler diagnostics and introspection
REPL Features
- State Persistence: Definitions remain available across multiple evaluations
- Dependency Tracking: When a variable is redefined, dependent expressions automatically use the new value
- Error Recovery: Invalid input doesn't corrupt the REPL state
- Compiler Integration: Full access to compiler diagnostics and introspection
- Clean State Management: Clear separation between REPL and single-file compilation modes
Compiler Queries in REPL Mode
When in REPL_ACTIVE state, the following compiler query messages work with the last REPL evaluation:
QUERY_CIR: Returns CIR for the last REPL evaluationQUERY_TYPES: Returns types for the last REPL evaluationGET_HOVER_INFO: Returns hover info for the last REPL evaluationThese queries are only available when
compiler_available: truein the REPL response.
Integration Notes
- Initialization: Always call
init()before processing messages - State Awareness: Track the current state to send appropriate messages
- Memory Management: Always pair
allocatewithdeallocatecalls - Error Recovery: Handle both WASM errors and compilation errors gracefully
- Buffer Sizing: Ensure response buffers are large enough for expected output
- REPL State Management: Use
RESETto switch between REPL and single-file modes
Example Usage
Basic REPL Session
// Initialize REPL
const initResponse = await sendMessage({type: "INIT_REPL"});
// Add definition
const defResponse = await sendMessage({
type: "REPL_STEP",
input: "x = 42"
});
// Response: {result: {output: "assigned `x`", type: "definition"}}
// Evaluate expression
const exprResponse = await sendMessage({
type: "REPL_STEP",
input: "x + 1"
});
// Response: {result: {output: "43", type: "expression"}}
// Query CIR of last evaluation
const cirResponse = await sendMessage({type: "QUERY_CIR"});
// Response: CIR representation of the last evaluationError Handling and Recovery
// Submit invalid syntax - Parse error
const parseError = await sendMessage({
type: "REPL_STEP",
input: "x = = 42" // Invalid syntax
});
// Response: {
// result: {
// output: "Parse error: Expected expression",
// type: "error",
// error_stage: "parse",
// error_details: "Expected expression",
// compiler_available: false
// }
// }
// Submit type error
const typeError = await sendMessage({
type: "REPL_STEP",
input: "\"hello\" + 42" // Type mismatch
});
// Response: {
// result: {
// output: "Type check expr error: TypeMismatch",
// type: "error",
// error_stage: "typecheck",
// error_details: "TypeMismatch",
// compiler_available: false
// }
// }
// Submit evaluation error
const evalError = await sendMessage({
type: "REPL_STEP",
input: "x +" // Incomplete expression
});
// Response: {
// result: {
// output: "Evaluation error: error.ZeroSizedType",
// type: "error",
// error_stage: "evaluation",
// error_details: "error.ZeroSizedType",
// compiler_available: false
// }
// }
// REPL state remains intact after errors, continue with valid input
const validResponse = await sendMessage({
type: "REPL_STEP",
input: "x = 42" // Should still work
});
// Response: {result: {output: "assigned `x`", type: "definition"}}Using Error Information for Better UX
// Process REPL response with enhanced error handling
function handleReplResponse(response) {
const result = response.result;
if (result.type === "error") {
// Use error_stage to provide stage-specific help
switch (result.error_stage) {
case "parse":
console.error("Syntax error:", result.output);
showSyntaxHighlight(result.error_details);
break;
case "typecheck":
console.error("Type error:", result.output);
showTypeHints(result.error_details);
break;
case "evaluation":
console.error("Runtime error:", result.output);
showDebugInfo(result.error_details);
break;
default:
console.error("Error:", result.output);
}
// Check if compiler is still available for queries
if (result.compiler_available) {
// Can still query CIR, types, etc.
enableCompilerQueries();
} else {
disableCompilerQueries();
}
} else if (result.type === "definition") {
console.log("Defined:", result.output);
} else if (result.type === "expression") {
console.log("Result:", result.output);
}
}Dependency Updates
// Define variables
await sendMessage({type: "REPL_STEP", input: "x = 10"});
await sendMessage({type: "REPL_STEP", input: "y = x + 5"});
const result1 = await sendMessage({type: "REPL_STEP", input: "y"});
// Response: {result: {output: "15", type: "expression"}}
// Redefine x
await sendMessage({type: "REPL_STEP", input: "x = 20"});
const result2 = await sendMessage({type: "REPL_STEP", input: "y"});
// Response: {result: {output: "25", type: "expression"}}
// Shows that y automatically uses the new value of xBuild
zig build build-playgroundOutput: zig-out/bin/playground.wasm
This module bundles the whole compiler, so a plain zig build builds it at
ReleaseSmall rather than Debug: a Debug build is ~85 MB and needs roughly
12 GiB of RAM to link. Any explicitly requested mode is still honored, so-Doptimize=Debug does give you a Debug module. The playground integration
tests (zig build run-test-playground) run against this same artifact.
Overview (`src/repl_wasm/README.md`)
Roc REPL WebAssembly module
repl.wasm is a self-contained, pure REPL. It does not expose the playground's
compiler-inspection state machine or the echo platform. One WebAssembly instance
owns one in-memory session.
Host ABI
roc_repl_alloc(len) -> ptrroc_repl_free(ptr, len)roc_repl_process(ptr, len) -> response_ptrroc_repl_free_response(response_ptr)
roc_repl_process accepts UTF-8 JSON. Its result begins with a four-byte
little-endian payload length followed by the UTF-8 JSON response. The module
exports its linear memory as memory.
Every request is an envelope with protocol, id, op, and optional params:
{"protocol":1,"id":1,"op":"eval","params":{"source":"x = 41\nx + 1"}}Every response echoes protocol and id and contains either ok: true with aresult, or ok: false with a stable structured error. Unknown request
fields are rejected so misspelled parameters cannot silently change behavior.
The installed protocol.d.ts describes the complete version 1 request and
response shapes for JavaScript and TypeScript embedders.
The protocol contains no frontend presentation strings. In particular,
definition results do not return text such as assigned foo; they returnkind, definition_kind, name, type, committed, and revision fields.
For example:
{
"source": "foo = \"bar\"",
"kind": "definition",
"definition_kind": "value",
"name": "foo",
"status": "ok",
"committed": true,
"revision": 1,
"type": "Str",
"diagnostics": [],
"events": []
}Operations and scope
| Operation | Contract |
|---|---|
capabilities |
Reports protocol operations, ownership boundaries, and supported features. |
eval |
Parser-backed multiline/batch input; evaluates left to right and stops at the first failed snippet. Earlier successful definitions remain committed. |
analyze |
Reports whether input is complete, incomplete, or invalid without changing the session. |
complete |
Filters session definitions by the identifier prefix ending at params.cursor and returns insertion text plus an explicit replacement range. |
inspect |
Returns an expression's checked type without evaluating it. |
get_state |
Returns ordered structured definitions, exact replay source, virtual module sources, pending-annotation state, and revision. |
clear |
Clears definitions while retaining virtual modules. |
set_modules |
Atomically replaces named in-memory modules and clears definitions. |
Evaluation is left-to-right. Each snippet reports whether it changed session
state through committed; expressions always report committed: false.
The batch result reports completed, stop_reason, committed_count, and the
final session revision. Earlier successful definitions remain available when
a later snippet fails.
Revisions are unsigned 32-bit counters scoped to one WASM instance, so they are
exact JavaScript numbers. Hosts must not compare revisions across instances.
Expression values, checked types, definition metadata, diagnostics, and ordereddbg events are separate fields. Diagnostics have a stable code, severity,
human-readable message, and nullable region. A failed compile-time expect
or user-written crash is a checking diagnostic and does not poison the next
request.
Version 1 enumerates blocking diagnostics only; non-blocking compiler warnings
are not part of the current contract. This is reported by the capabilities
response rather than being left implicit.
Compile-time evaluation
REPL expressions are explicit compile-time roots. Pure expressions produce an
inspected value, and dbg observations become ordered structured events.
Effectful expressions are rejected with an Effectful Compile Time Expression
diagnostic; the WebAssembly module does not expose a platform-effect boundary.
Text offsets and completion
Source strings are UTF-8. All protocol offsets are zero-based UTF-8 byte
offsets; capabilities.offset_unit and every completion response state this
explicitly. A cursor beyond the source or inside a multi-byte code point is aninvalid_cursor request error.
Completion is deliberately scoped to names stored in the current REPL session.
It does not claim contextual field, tag, keyword, or builtin completion. The
scope is reported by capabilities.completion_scope. A completion response
contains prefix, cursor, replacement, items, and whether checked type
details are currently available. A standalone annotation is a valid
intermediate state; names remain completable while details_available is
false.
Frontend boundary
ReplSession.stepLanguageWithConfig accepts Roc syntax and returns a typed
language result. Terminal commands are parsed by the CLI frontend and passed as
typed commands to executeCommandWithConfig; they are not recognized by the
WASM protocol. The browser demo renders only structured fields and never parses
compiler display text.
The module has no filesystem, network, package resolver, stdin, history store,
or platform effects. Hosts own persistence and cancellation. The demo uses a
Web Worker; Stop terminates the worker, creates a new module instance, and
replays the exact structured definition_source returned by get_state.
The demo keeps the complete editable notebook source in one textarea. A line
containing only #%% separates cells; Ctrl/Command-Enter evaluates the active
cell and advances to the next one. Completion requests contain only the active
cell source and translate browser UTF-16 cursor positions to protocol UTF-8
byte offsets. Syntax highlighting is intentionally deferred: a plain textarea
keeps selection, composition, accessibility, and cell-boundary behavior
correct without introducing a second synchronized text layer.
Overview (`src/reporting/README.md`)
Reporting
Error reporting, diagnostics, and user feedback for the Roc compiler.
Overview
The reporting module provides comprehensive error reporting and diagnostic information throughout the compilation process. It ensures that users receive clear, actionable feedback when things go wrong.
Purpose
This module provides:
- Error Reporting: Structured error messages with source locations and context
- Diagnostics: Detailed information about compilation issues and warnings
- Source Location: Precise tracking of where errors occur in source code
- User-Friendly Messages: Clear explanations that help developers fix issues
- Consistent Formatting: Uniform error presentation across all compiler stages
The reporting module is used by parse, canonicalize, check, and other stages to provide consistent, helpful feedback to users when compilation fails or produces warnings.
Rendering architecture
Compiler stages build a Report containing a Document; they do not format
individual output targets. renderer.zig traverses DocumentElement through
one walker, renderElementAs, for terminal, Markdown, HTML, and language-server
output. Report summaries and primary-source selection use projections of that same
walker. A source-layout hook supplies report framing for top-level secondary
blocks while nested blocks retain their document style.
Each target supplies a style with annotation delimiters, text escaping,
indentation, and hooks for source and structural formatting. Annotation style
tables are exhaustive: terminal styles select fields from ColorPalette instyle.zig, while Markdown, HTML, and language-server styles declare their
markup in renderer.zig. Adding an annotation requires its semanticName and
an entry in each target's style table. HTML classes come from semanticName;../snapshot_tool/snapshot.css owns their colors, and the snapshot tool tests
check that every annotation has a CSS selector.
source_region.zig owns shared source layout. Layout iterates numbered source
rows and computes their underline spans without allocating; target hooks add
escaping, gutters, and markup around those computed rows. Report snippets useSnippet to expand leading tabs, dedent source, and transform caret coordinates
together. This keeps the primary and secondary report snippets aligned using
the same calculations. Title uppercasing is shared with the snapshot tool
through the exported writeShouted helper.
report_sexpr.zig serializes the semantic report structure independently of
presentation layout. Ordinary compiler snapshots store that canonical form inPROBLEMS. The type=reporting fixtures in test/snapshots/reporting/ pin the
canonical report alongside all four rendered forms; they complement the
exhaustive model-variant unit corpus.
Rendering checks
parity_test.zig enumerates the document and annotation variants at compile
time, so extending the model requires extending its fixtures. The suite checks
visible text, source rows and line counts, underline spans, nested annotations,
and exact output pins across all four targets. It also exercises the report
entrypoints and HTML escaping.
Run the reporting suite with zig build run-test-zig-module-reporting. Run the
CSS coverage test with zig build run-test-zig-snapshot-tool -- --test-filter "snapshot CSS covers". Changes to shared rendering should also preserve the
tracked snapshot corpus and terminal diagnostic bytes unless an output change
is intentional.
Overview (`src/snapshot_tool/README.md`)
Snapshot Tool
This directory contains a tool for snapshot testing the Roc compiler.
Snapshot testing is a method used to verify the behavior of the compiler's different stages. The tool generates "golden snapshot" files, which are baseline outputs that are known to be correct.
During testing, the tool runs the compiler and compares its output against these golden files. If there are any differences, the test fails. This is an effective way to detect regressions and unintended changes in the compiler's behavior across a large number of test cases.
The golden snapshots are committed to the repository and are therefore tracked by Git and checked along with any changes to the codebase.
Diagnostics
Diagnostic coverage is split between semantic and presentation snapshots:
- The
PROBLEMSsection of ordinary snapshots contains the canonical,
presentation-independent S-expression form of eachreporting.Report
(serialized bysrc/reporting/report_sexpr.zig), so those snapshots change
only when diagnostic semantics change. type=reportingsnapshots (intest/snapshots/reporting/) render the same
semantic reports through every user-facing renderer:REPORT(canonical
S-expression),CLI,MARKDOWN,HTML, andLSP; they are the only place
where renderer-specific layout and markup are pinned.
See test/snapshots/README.md for details.
Overview (`src/target/README.md`)
Target Architecture Documentation
This document defines the different machines and targets involved in building and running the Roc compiler, and compiling Roc programs. Understanding these distinctions is critical for correct cross-compilation support.
Machines and Targets
There are six distinct machine contexts to consider:
1. Build Host
The machine that compiles the Roc CLI itself (runs zig build).
- When it matters: Build scripts, compile-time constants in build.zig
- In code: Available via
@import("builtin")during Roc CLI compilation - Example: Your development MacBook running
zig build
2. CLI Target
The architecture the Roc CLI binary is compiled FOR. This determines what machine can run the resulting roc binary.
- When it matters: Cross-compiling the Roc CLI itself (rare)
- Usually: Same as Build Host (native compilation)
- In code: The
-Dtarget=flag tozig build, or native by default - Example: Building the CLI on x64 Linux to run on arm64 Linux
3. Shim Target
The architecture each embedded interpreter shim is compiled FOR. The Roc CLI embeds multiple pre-compiled interpreter shims for different targets.
- When it matters: The interpreter shim's own memory layout and operations
- In code:
@import("builtin").cpu.archinside interpreter shim code - Key insight: When a shim runs,
builtin.cpu.archreflects what it was compiled FOR - Examples:
- Native shim: compiled for CLI Target (runs directly)
- wasm32-freestanding shim: compiled for wasm32 (runs in wasm runtime)
4. CLI Runtime
The machine actually executing the Roc CLI.
- When it matters: Runtime behavior, file I/O, process spawning
- Usually: Same as CLI Target
- In code: Runtime detection via
std.Target.currentor OS APIs - Example: The CI server running
roc build
5. Compilation Target
The target architecture for the Roc APPLICATION being compiled. This is what the user specifies when they want to cross-compile their Roc program.
- When it matters: Generated code layout, struct sizes, pointer widths
- In code:
--target=flag toroc build, represented asRocTarget - Examples:
roc build --target=wasm32 app.roc→ Compilation Target is wasm32roc build app.roc→ Compilation Target defaults to first compatible as specified by the platform header, usually a native executable (CLI Runtime) but could build a static or shared library e.g.app.wasm.
6. App Runtime
The machine/environment where the compiled Roc application will eventually execute.
- When it matters: The generated binary's behavior
- Relationship: Must be compatible with Compilation Target
- Examples:
- wasm32 target → runs in browser or wasm runtime
- x64linux target → runs on x64 Linux machines
CPU Level
Each of the machines above is a CPU as well as an OS and architecture, and Roc
names two floors per architecture: .default and the v1 baseline (seeCpuLevel). The two are separate concerns from the six contexts above, and they
interact in one place that is easy to get wrong.
The Roc CLI is built for the architecture baseline so that one released binary
runs on every CPU of its architecture. That makes builtin.cpu a statement
about the CLI Target, not about the CLI Runtime: it cannot tell you
whether the machine executing roc has AVX2. Only a runtime query can, which is
what host_cpu.zig does.
So anything generated to run on the CLI Runtime—compile-time execution,
in-memory dev runs, and the Compilation Target chosen when the user passes no--target—takes its CPU level from host_cpu.level(). Anything generated for
a Compilation Target the user named takes its level from that target, because
the App Runtime is then someone else's machine.
Key Relationships
Build Host ──compiles──▶ CLI Target (Roc CLI binary)
│
│ embeds multiple
▼
┌─────────────────────┐
│ Interpreter Shims │
│ - native shim │◀── Shim Target (each has its own)
│ - wasm32 shim │
│ - ... │
└─────────────────────┘
│
│ runs on
▼
CLI Runtime (user's machine)
│
│ compiles Roc app for
▼
Compilation Target (--target flag)
│
│ produces binary for
▼
App Runtime (where app runs)Common Scenarios
Native Development (Most Common)
All targets are the same architecture:
- Build Host = CLI Target = CLI Runtime = Compilation Target = App Runtime
- Example: Developing on arm64 Mac, building apps to run on arm64 Mac
Cross-Compiling Roc Apps
CLI runs natively, but compiles for a different target:
- Build Host = CLI Target = CLI Runtime (e.g., x64 Linux)
- Compilation Target = App Runtime (e.g., wasm32)
- Example:
roc build --target=wasm32 app.rocon a Linux server
Cross-Compiling the CLI Itself
Building the Roc CLI for a different machine:
- Build Host (e.g., x64 Linux CI) ≠ CLI Target (e.g., arm64 Mac)
- Example: CI building release binaries for multiple platforms
Overview (`src/types/README.md`)
Types
Core type system implementation for the Roc language.
Overview
The types module provides the foundational type system that underpins the entire Roc compiler. It defines the representation of all Roc types, including primitive types, algebraic data types, functions, and type variables used during type inference.
Purpose
This module serves as the backbone for:
- Type Representation: Defining how all Roc types are stored and manipulated in memory
- Type Operations: Providing utilities for type comparison, substitution, and manipulation
- Type Variables: Managing type variables used during Hindley-Milner type inference
- Built-in Types: Implementing the core Roc type system (numbers, strings, lists, etc.)
The types module is used extensively by the canonicalize, check, and eval stages of the compiler to ensure type safety and provide the necessary type information for compilation and execution.
Overview (`test/downstream-compiler/README.md`)
Downstream compiler consumer
This separate Zig executable uses only dependency.module(...) to import Roc's
compiler. It checks a small application, lowers its platform entrypoint to LIR,
builds and reads a LIR image, and executes the entrypoint with the interpreter,
asserting that the returned U64 is 42.
Run from the repository root:
zig build build-test-downstream-package
zig build run-test-downstream-packageThe build helper snapshots the compiler sources, runs zig fetch (which applies
Roc's build.zig.zon .paths), and generates a consumer manifest with a URL and
hash for the resulting package archive. It builds in a separate directory;
there are no relative imports of the compiler checkout. The run step only
executes the built consumer. MiniCI includes both phases.
The manifest is generated because its dependency hash must describe the current
compiler sources. In a real downstream project, pin a published commit instead:
zig fetch --save=roc 'git+https://github.com/roc-lang/roc.git#<full-commit-sha>'Use this fixture's build.zig as an example of importing the named modules.
See src/compile/README.md for the embedding contract. The platform needs no
native host library because this test executes its entrypoint in-process.
Overview (`test/fuzzing/README.md`)
Fuzzing
The typed source generators must not hardcode userspace Roc names.
All generated type names, tag names, field names, function names, local names,
module names, and filenames must come from the fuzzer's symbol generator. Do not
add fixture modules, canned domain models, semantic identifiers, or raw Roc
snippets that bake in userspace names.
Do not add catalog-style fuzzers made from handwritten scenario emitters. Fuzzers
should generate small typed language constructs and compose them randomly, so
new features multiply the reachable program space instead of adding one more
fixed example.
Builtin names are the only exception. Hardcoding Bool, Str, List, Try,Ok, Err, and public builtin associated function names is allowed when the
generated program is deliberately exercising builtin behavior.
Generated platform/app fuzzers must also generate app-provided names, platform
aliases, and platform wrapper names. External ABI symbol strings and target names
are platform data, not userspace Roc identifiers.
On macOS, AFL++ may fail with shmget() failed under the default System V
shared-memory limits. Prefix AFL commands with AFL_MAP_SIZE=2097152 unless the
machine has already been configured with afl-system-config.
Overview (`test/int/README.md`)
Test Platform
This directory contains a primitive test platform for Roc and demonstrates how to pass multiple arguments from the host.
- Function signature:
I64, I64 -> I64 - Description: Takes two random integers from the host and returns their product
zig build
# Run (ignore cached files)
./zig-out/bin/roc --no-cache test/int/app.rocOverview (`test/snapshots/README.md`)
Snapshots
Snapshot tests that validate compiler behavior by capturing the output of each compilation stage for specific Roc code examples.
Snapshot tests provide comprehensive validation of the compilation pipeline by showing how source code is transformed through each stage: tokenization, parsing, canonicalization, and type checking etc.
Each snapshot file contains the expected output and helps us to detect regressions when compiler behavior changes unexpectedly.
Semantic diagnostics vs renderer output
Diagnostics are covered by two different kinds of snapshot, split so that
semantic changes and presentation changes never show up in the same files:
Ordinary snapshots (
type=file,snippet,expr, ...) capture diagnostic
semantics. TheirPROBLEMSsection contains the canonical S-expression
serialization of eachreporting.Report(seesrc/reporting/report_sexpr.zig): severity, title, source regions, and the
full document structure (text, annotations, source excerpts, underlines).
It contains no renderer-specific details: no box-drawing characters, ANSI
escapes, wrapping, or markup.NILmeans the compile produced no reports.
These snapshots answer: did the compiler produce the correct diagnostic?Reporting snapshots (
type=reporting, inreporting/) pin renderer
output. Each one compiles itsSOURCEnormally, then renders the same
semantic reports through every user-facing format, one section per renderer:REPORT(the canonical S-expression),CLI(plain terminal layout),MARKDOWN,HTML, andLSP. Layout, wrapping, punctuation, and markup are
pinned here and only here.
A renderer-only change must only affect files in reporting/; a change to
diagnostic semantics shows up in ordinary snapshots (and possibly inreporting/ too). Note that snapshot post-processing globally rewrites the
removed header keyword to mod, which also applies inside the S-expression
output.
Usage
- Generate all snapshots:
zig build run-snapshot-tool - Update specific snapshot:
zig build run-snapshot-tool -- <file_path> - Update expected from problems:
zig build run-snapshot-tool -- <file_path> --update-expected - Embed carriage-return source bytes: add
source_escapes=truetoMETAand write each carriage return as\rinSOURCE - Debug REPL evaluation with trace:
zig build run-snapshot-tool -- <repl_snapshot.md> --trace-eval
Trace Debugging
The --trace-eval flag enables detailed interpreter tracing for debugging REPL snapshots:
# Debug build (trace support enabled by default)
zig build run-snapshot-tool -- src/snapshots/repl/repl_record_field_access.md --trace-evalRequirements:
- Only works with REPL snapshots (
type=repl) - Can only be used with a single snapshot file
- Trace output is automatically enabled in debug builds
- For release builds, use
-Dtrace-eval=trueto enable tracing
Overview (`test/str/README.md`)
Test Platform
This directory contains a primitive test platform for Roc and demonstrates passing a string to and from the host.
- Function signature:
Str -> Str - Description: Takes a string from the host and returns a processed string
zig build
# Run (ignore cached files)
./zig-out/bin/roc --no-cache test/str/app.rocOverview (`.github/actions/flaky-retry/README.md`)
Flaky Retry Action
A GitHub Action that retries a command if it fails with a specific error string.
Inputs
command(required): The command to runerror_string_contains(required): Retry only if error output contains this stringretry_count(optional): Number of times to retry on failure (default: 3)
Usage
- name: Run flaky test
uses: ./.github/actions/flaky-retry
with:
command: 'zig build my_flaky_cmd'
error_string_contains: 'connection timeout'
retry_count: 5Behavior
- Runs the specified command
- If the command succeeds (exit code 0), the action succeeds immediately
- If the command fails (non-zero exit code):
- Checks if the error output contains the specified string
- If it does, retries up to
retry_counttimes - If it doesn't, fails immediately without retrying
- After all retries are exhausted, the action fails with the last exit code
Overview (`src/backend/llvm/README.md`)
Roc LLVM Backend
This directory contains the LLVM code generation backend for the Roc compiler. It takes monomorphized IR and compiles it to native object code using LLVM.
Overview
The LLVM backend follows this pipeline:
Monomorphized IR → LLVM IR Builder → LLVM Bitcode → Object Code (.o) → Executable- Monomorphized IR: The input from the Roc compiler's earlier stages (type checking, monomorphization)
- LLVM IR Builder (
Builder.zig): Constructs LLVM IR in memory - Bitcode Writer (
bitcode_writer.zig): Serializes the IR to LLVM bitcode format - LLVM Backend (
vendor/llvm_compile_bindings.zig): Parses bitcode and compiles to native object code - Linker: Links object code with platform host to produce final executable
Files
| File | Description |
|---|---|
Builder.zig |
LLVM IR builder - constructs LLVM modules, functions, basic blocks, and instructions in memory |
ir.zig |
LLVM IR data structures, opcodes, and type definitions |
bitcode_writer.zig |
Serializes Builder's in-memory IR to LLVM bitcode binary format |
Building with LLVM
LLVM is always enabled. The build system will automatically download pre-built LLVM libraries from roc-bootstrap for supported platforms.
# Standard build (downloads LLVM automatically)
zig build
# Use a custom LLVM installation
zig build -Dllvm-path=/path/to/llvmSupported Platforms
Pre-built LLVM is available for:
x86_64-linux-musl/x86_64-linux-gnuaarch64-linux-musl/aarch64-linux-gnux86_64-macosaarch64-macosx86_64-windowsaarch64-windows
For other platforms, use -Dllvm-path to specify a custom LLVM installation.
Usage: Compiling Monomorphized IR to Executable
Step 1: Generate LLVM IR from Monomorphized IR
The Builder.zig module constructs LLVM IR. Here's the general pattern:
const llvm = @import("backend/llvm/Builder.zig");
const ir = @import("backend/llvm/ir.zig");
// Create a new LLVM module builder
var builder = llvm.Builder.init(allocator);
defer builder.deinit();
// Set target triple (e.g., "x86_64-linux-gnu", "aarch64-macos-none")
builder.setTargetTriple(target_triple);
// Define types
const i64_type = builder.intType(64);
const void_type = builder.voidType();
// Create a function
const main_fn = builder.addFunction(
"main", // function name
builder.functionType(i64_type, &.{}), // return i64, no params
);
// Create entry basic block
const entry_block = builder.appendBasicBlock(main_fn, "entry");
builder.positionAtEnd(entry_block);
// Add instructions
const result = builder.buildAdd(lhs, rhs, "sum");
builder.buildRet(result);Step 2: Serialize to Bitcode
Once the LLVM module is built, serialize it to bitcode:
const bitcode_writer = @import("backend/llvm/bitcode_writer.zig");
// Get bitcode as bytes
const bitcode = builder.toBitcode(allocator);
defer allocator.free(bitcode);
// Or write directly to a file
try builder.writeBitcodeToFile("output.bc");Step 3: Compile Bitcode to Object Code
Use the bindings.zig module to compile bitcode to a native object file:
const bindings = @import("backend/llvm/bindings.zig");
// High-level convenience function
const error_msg = bindings.compileBitcodeToObject(
bitcode, // []const u8 - LLVM bitcode bytes
"x86_64-linux-gnu", // target triple
"generic", // CPU name (or null for default)
null, // CPU features (or null)
"output.o", // output object file path
false, // is_debug (false = optimized)
);
if (error_msg) |msg| {
std.debug.print("LLVM error: {s}\n", .{std.mem.span(msg)});
return error.LlvmCompilationFailed;
}Step 4: Link to Executable
Link the object file with the platform host library to create the final executable:
const embedded_lld = @import("embedded_lld");
// Using LLD (LLVM's linker) for ELF targets
const args = &.{
"ld.lld",
"output.o",
"libhost.a", // platform host library
"-o", "my_program",
};
try embedded_lld.link(allocator, .elf, args, .{});For different platforms, pass the appropriate linker format:
- Linux:
.elf - macOS:
.macho - Windows:
.coff - WebAssembly:
.wasm
Low-Level API
For more control, use the lower-level bindings directly:
/* Detailed source-code truncated for AI context efficiency. */Target Triples
Common target triples for Roc:
| Platform | Triple |
|---|---|
| Linux x86_64 | x86_64-linux-gnu or x86_64-linux-musl |
| Linux ARM64 | aarch64-linux-gnu or aarch64-linux-musl |
| macOS x86_64 | x86_64-macos-none |
| macOS ARM64 | aarch64-macos-none |
| Windows x86_64 | x86_64-windows-msvc |
| WebAssembly | wasm32-freestanding-none |
Optimization Levels
The CodeGenOptLevel enum controls LLVM's optimization:
| Level | Description |
|---|---|
.None |
No optimization (fastest compile, -O0) |
.Less |
Basic optimizations (-O1) |
.Default |
Standard optimizations (-O2) |
.Aggressive |
Maximum optimizations (-O3) |
For size optimization, set emit_options.is_small = true to get -Oz behavior.
Debugging
To debug LLVM IR generation:
- Emit LLVM IR text: Set
emit_options.llvm_ir_filename = "output.ll"to get human-readable IR - Emit bitcode: Set
emit_options.bitcode_filename = "output.bc"to inspect withllvm-dis - Use debug builds: Set
is_debug = trueto preserve debug info and disable optimizations
Architecture
/* Detailed source-code truncated for AI context efficiency. */Attribution
The LLVM bitcode generation code has been adapted from the Zig compiler at https://codeberg.org/ziglang/zig and licensed under the MIT license. Thanks, Zig team!
Overview (`src/lsp/completion/README.md`)
LSP Completion System
This document describes how code completion works in the Roc LSP, covering the
request flow, context detection, completion types, key data structures, and
testing.
File Map
All paths are relative to src/lsp/.
| File | Role |
|---|---|
handlers/completion.zig |
LSP protocol handler. Parses the JSON-RPC textDocument/completion request, extracts URI/line/character, calls into SyntaxChecker, and serialises the response. Defines CompletionItem, CompletionItemKind, and CompletionResult. |
syntax.zig |
Orchestrator. SyntaxChecker.getCompletionsAtPosition() is the main entry point. Builds the file, obtains a ModuleEnv, detects the completion context, creates a CompletionBuilder, and dispatches to the context-specific branch. Also contains helper resolution functions (resolveModuleAlias, resolveLocalBindingTypeVar, resolveAccessChainTypeVar, extractReturnType). |
completion/context.zig |
Pure text analysis. detectCompletionContext() scans backwards from the cursor to decide which CompletionContext variant applies. Also provides computeOffset() for converting LSP line/character to a byte offset. |
completion/builder.zig |
CompletionBuilder struct. Accumulates CompletionItems with deduplication (via seen_labels hash map). All the add* methods live here. |
completion/builtins.zig |
BUILTIN_TYPES list (21 entries: Str, List, Dict, …, Num) and isBuiltinType() helper. |
completion/mod.zig |
Re-exports CompletionContext, detectCompletionContext, computeOffset, and CompletionBuilder. |
scope_map.zig |
Reconstructs lexical scopes from CIR so the builder can answer "which local variables are visible at byte offset X?" |
cir_queries.zig |
Offset-based CIR queries. findFieldAccessReceiverTypeVar() finds the receiver type of an existing field-access node. findTypeAtOffset() returns the type at an arbitrary offset. Used as a first-pass type resolver before falling back to name-based lookup. |
module_lookup.zig |
findDefinitionByName()—searches a ModuleEnv's defs and statements for a name and returns its pattern index. Used when resolving access chains. |
build_env_handle.zig |
Reference-counted wrapper around BuildEnv. Ensures a BuildEnv stays alive while snapshot or previous-build references exist. |
build_session.zig |
BuildSession—manages a single build invocation (used at the top of getCompletionsAtPosition). |
Supporting compiler modules (outside `src/lsp/`)
| Module | Purpose in completions |
|---|---|
canonicalize/ModuleEnv.zig |
Per-module compilation state: CIR node store, type store, method idents, ident table, etc. |
compile/BuildEnv |
Root compilation environment. Its Coordinator owns per-package ModuleState lists. Also holds builtin_modules. |
types/ |
Type inference results: Var, Content, TypeStore, records, aliases, nominals, functions, static dispatch constraints. |
can/ (CIR) |
Canonical Intermediate Representation—Statement, Def, Pattern, Expr, TypeAnno. |
Request Flow
Editor sends textDocument/completion
│
▼
handlers/completion.zig handler().call()
├─ parse JSON params → (uri, line, character)
├─ look up document text from doc_store
└─ call syntax_checker.getCompletionsAtPosition(uri, text, line, character)
│
▼
syntax.zig SyntaxChecker.getCompletionsAtPosition()
1. Lock mutex (completions are single-threaded)
2. createFreshBuildEnv() → BuildEnv
3. BuildSession.init() → builds the file
4. Obtain a ModuleEnv (see "Module Env Priority" below)
5. detectCompletionContext(source, line, character) → CompletionContext
6. computeOffset(source, line, character) → cursor_offset
7. Create CompletionBuilder
8. switch (context) → call the appropriate builder methods
9. Return CompletionResult { items, is_incomplete }
│
▼
handlers/completion.zig
└─ serialise CompletionResult as JSON-RPC responseModule Env Priority
The system tries three sources for a ModuleEnv, in order:
- Snapshot env—cached from the last successful build of this file.
Best for completions because the user is usually mid-edit and the current
build may fail. - Previous build env—the last globally-successful build. Fallback when
no snapshot exists for this file. - Current build—used only when the build just succeeded with no reports.
When a snapshot or previous env is used, the flag used_snapshot is set totrue. This matters because CIR byte offsets from a snapshot do not match the
current source text, so CIR-based type lookups (findFieldAccessReceiverTypeVar) are
skipped in favour of name-based lookups.
The module_lookup_env variable tracks which BuildEnv backs the chosenModuleEnv, keeping module member lookups consistent.
Completion Context Detection
completion/context.zig :: detectCompletionContext(source, line, character)
The function converts line/character to a byte offset, then scans backward:
- Skip any partial identifier the user is currently typing.
- Skip whitespace.
- Inspect the character immediately before the cursor:
| Character | Result | Example |
|---|---|---|
. preceded by an uppercase ident (no dots in chain) |
after_module_dot |
Str. → module name "Str" |
. preceded by a lowercase ident (or any chain with dots) |
after_value_dot |
myRecord., myrec.subrec. |
. preceded by ) (function call result) |
after_receiver_dot |
getValue(). |
: |
after_colon |
x : |
| anything else | expression |
x = , beginning of line |
CompletionContext union
pub const CompletionContext = union(enum) {
after_module_dot: []const u8, // the module name
after_value_dot: struct {
access_chain: []const u8, // e.g. "myrec.subrec"
chain_start: u32, // byte offset of chain start
receiver_segment_start: u32, // byte offset of segment before dot
dot_offset: u32, // byte offset of the '.'
},
after_receiver_dot: struct {
dot_offset: u32,
call_chain: ?[]const u8, // e.g. "Record2.SubVal" or "testFunc"
chain_start: u32,
},
after_colon,
expression,
};Completion Types
Each completion context triggers a different set of builder methods. Here is
every kind of completion the system can produce, grouped by context.
1. Module Member Completions—`after_module_dot`
Trigger: Str., List., Json., or MyNominalType.
What happens:
resolveModuleAlias(module_env, name)—if the module was imported withimport Json as J, this mapsJback toJson.builder.addModuleMemberCompletions(env, resolved_name, module_env_opt)—
walks allexposed_itemsfrom the target module'sModuleEnv. For builtin
type names (recognised viaisBuiltinType), it uses the specialbuiltin_module.env. Items are classified asfunction(lowercase) orclass(uppercase).builder.addTagCompletionsForNominalType(module_env, name, null)—if the
name is also a nominal type in the current module, adds its tag constructors
(e.g.,Color.Red,Color.Green).
CompletionItemKind: function, class, enum_member
2. Incomplete Value-Dot Completions—`after_value_dot`
Trigger: myRecord., myrec.subrec.
What happens (simplified):
- Try
resolveAccessChainTypeVar()to resolve dotted chains likeModule.value.subfieldby walking segment-by-segment through the type
system. - If that fails (common with snapshots), fall back:
- CIR-based:
cir_queries.findFieldAccessReceiverTypeVar()orfindTypeAtOffset() - Name-based:
addRecordFieldCompletions(name, offset)andaddMethodCompletions(name, offset)
- CIR-based:
- Once a
types.Varis in hand:addFieldsFromTypeVar()—unwraps aliases/nominals up to 8 levels, then
iterates record fields. Each field gets adetailstring showing its type.addMethodsFromTypeVar()—independently looks up the type identity inmethod_idents. It does not inspect record fields.
At value. the source is incomplete: selecting a field and stopping producesvalue.field, while adding an argument list produces the distinct method syntaxvalue.method(args). Offering both completion kinds at this unfinished prefix
does not merge their lookup or resolution.
CompletionItemKind: field, method
3. Incomplete Receiver-Dot Completions—`after_receiver_dot`
Trigger: getValue()., Str.join(items)., testFunc("hi").
What happens:
- Try CIR-based lookup:
findFieldAccessReceiverTypeVar()atcursor_offset, with
fallback tofindTypeAtOffset(dot_offset - 1). - If using a snapshot or CIR failed, parse the
call_chaintextually and
resolve viaresolveAccessChainTypeVar(), then callextractReturnType()to unwrap the function return type. - Run field completion and method completion independently for the resolved
receiver type.
CompletionItemKind: field, method
4. Type Annotation Completions—`after_colon`
Trigger: x : , foo : , a function signature parameter annotation
What happens:
addTypeCompletions(module_env)—iteratesall_statementsfors_alias_declands_nominal_decl, adding each type name.addModuleNameCompletions(module_env)—iteratesall_statementsfors_importand adds imported module names/aliases.addTypeCompletionsFromEnv(env)—same as (1) but across all modules
in theBuildEnv.addModuleNameCompletionsFromEnv(env)—adds all module names fromBuildEnvcoordinator packages and callsaddBuiltinModuleNameCompletions()
which adds every entry inBUILTIN_TYPES.
This means typing x : suggests: user-defined type aliases and nominals,
imported module names, all loaded module names, and all 21 builtin types
(Str, List, Bool, U8–U128, I8–I128, F32, F64, Dec, Num,Dict, Set, Box, Try).
CompletionItemKind: class (types), module (module names)
5. Expression Completions—`expression`
Trigger: beginning of a line, after =, after if, any general expression
position.
What happens:
addLocalCompletions(module_env, cursor_offset)—builds aScopeMapfrom
CIR, then adds every binding visible at the cursor. Also adds top-level
defs (fromall_defs) and statement-level defs (fromall_statements).
Closures/lambdas get kindfunction; everything else getsvariable.addModuleNameCompletions(module_env)—imported module names.addAmbientTagCompletions(module_env)—structural tags from the type
store (e.g.Ok,Errif they appear in the module).addTypeCompletions(module_env)—alias and nominal type names.addModuleNameCompletionsFromEnv(env)—all module names + builtins.
CompletionItemKind: variable, function, module, enum_member, class
6. Tag Completions (nominal)
Added in the after_module_dot context when the dot-prefix is a nominal type.addTagCompletionsForNominalType() finds the s_nominal_decl matching the
name, checks opaqueness, then extracts tags from the backing tag_union type
annotation. Each tag gets a formatted signature detail like SubVal(Str).
CompletionItemKind: enum_member
7. Ambient/Structural Tag Completions
Added in the expression context. addAmbientTagCompletions() iteratesmodule_env.types.tags and adds every tag name. These are structural tags that
appear without a module prefix.
CompletionItemKind: enum_member
Key Types
CompletionItem
Defined in handlers/completion.zig:175:
pub const CompletionItem = struct {
label: []const u8, // Display text (e.g. "concat", "Str", "name")
kind: ?u32 = null, // CompletionItemKind as u32
detail: ?[]const u8 = null, // Type signature shown beside label
documentation: ?[]const u8 = null,
sortText: ?[]const u8 = null, // Custom sort key
insertText: ?[]const u8 = null, // Text inserted on accept (if different from label)
};CompletionItemKind
Matches the LSP spec (subset actually used by the builder):
| Name | Value | Used for |
|---|---|---|
method |
2 | Static dispatch methods |
function |
3 | Top-level functions, closures/lambdas |
field |
5 | Record fields |
variable |
6 | Local bindings, non-function top-level defs |
class |
7 | Type names (aliases, nominals), uppercase module members |
module |
9 | Module names (imported and builtin) |
enum_member |
20 | Tag constructors (nominal and structural) |
CompletionBuilder
Defined in completion/builder.zig:33. Key fields:
pub const CompletionBuilder = struct {
allocator: Allocator,
items: *std.ArrayList(CompletionItem),
seen_labels: std.StringHashMap(void), // deduplication
builtin_module_env: ?*ModuleEnv, // for Str.concat, List.map, etc.
};ModuleEnv (abbreviated)
From src/canonicalize/ModuleEnv.zig. Fields used by completions:
| Field | Purpose |
|---|---|
store: NodeStore |
Access CIR nodes (defs, statements, patterns, exprs, type annos) |
types: TypeStore |
Resolve type variables, unwrap aliases/nominals, iterate record fields |
common: CommonEnv |
Ident table (idents), exposed items, node index lookup |
all_defs: CIR.Def.Span |
All top-level definitions |
all_statements: CIR.Statement.Span |
All top-level statements (including imports, type decls) |
module_name: []const u8 |
This module's name |
method_idents: MethodIdents |
Maps (type_ident, method_ident) → qualified_ident |
BuildEnv (abbreviated)
| Field | Purpose |
|---|---|
coordinator.packages |
Package map whose entries each have a .modules list of ModuleState |
builtin_modules.builtin_module.env |
The ModuleEnv for the Builtin module (Str, List, etc.) |
ScopeMap and Binding
scope_map.zig. Reconstructs lexical scopes from CIR:
pub const Binding = struct {
ident: Ident.Idx,
pattern_idx: CIR.Pattern.Idx,
visible_from: u32,
visible_to: u32,
is_parameter: bool,
};ScopeMap.build(module_env) traverses all defs, statements, and nested
expressions to populate a list of Bindings sorted by visible_from.isVisibleAt(binding, offset) checks if a binding is in scope at a cursor
position.
Builder Method Reference
Every public add* method on CompletionBuilder:
| Method | What it adds |
|---|---|
addItem(item) |
Core: appends one item with deduplication. Returns true if added. |
addModuleNameCompletionsFromEnv(env) |
All module names from BuildEnv coordinator state + addBuiltinModuleNameCompletions(). |
addModuleNameCompletions(module_env) |
Imported module aliases from s_import statements. |
addModuleMemberCompletions(env, name, opt) |
Exposed items from a named module (handles builtins and imports). |
addModuleMemberCompletionsFromModuleEnv(env, name) |
Exposed items from a specific ModuleEnv. |
addTypeCompletions(module_env) |
Alias and nominal type names from one module. |
addTypeCompletionsFromEnv(env) |
Type names from all modules in BuildEnv. |
addLocalCompletions(module_env, cursor_offset) |
Local variables in scope + top-level defs + statement-level defs. |
addRecordFieldCompletions(module_env, name, offset) |
Record fields by variable name lookup. |
addFieldsFromTypeVar(module_env, type_var) |
Record fields from a resolved type variable (unwraps aliases/nominals). |
addRecordFieldsForModuleMember(module_env, name) |
Record fields for Module.value. access patterns. |
addMethodCompletions(module_env, name, offset) |
Methods by variable name lookup. |
addMethodsFromTypeVar(module_env, type_var) |
Methods from a resolved type variable (alias ident → method_idents lookup). |
addTagCompletionsForNominalType(env, name, req_mod) |
Tag constructors for a nominal type, with opaqueness checks. |
addAmbientTagCompletions(module_env) |
Structural tags from the type store. |
BUILTIN_TYPES
Defined in completion/builtins.zig. These 21 names are added as module-kind
completions by addBuiltinModuleNameCompletions():
Str List Dict Set Box
Bool Try
U8 U16 U32 U64 U128
I8 I16 I32 I64 I128
F32 F64
Dec NumisBuiltinType(name) does a case-sensitive linear scan of this list. It is
used to:
- Route method lookups through the builtin
ModuleEnvinstead of the user's. - Add builtin module names to completion lists.
- Recognise when
addModuleMemberCompletionsshould usebuiltin_module.env.
Testing
Test files
| File | Contents |
|---|---|
test/handler_integration_tests.zig |
Full end-to-end integration tests. Simulates an LSP session (initialize → didOpen → completion request → shutdown → exit) and parses the JSON-RPC response. |
test/handler_unit_tests.zig |
Handler response-shape tests that run through the test syntax driver instead of real compiler checking. |
test/syntax_test.zig |
Mid-level tests. Creates a SyntaxChecker, builds real Roc source, and calls getCompletionsAtPosition() directly. |
completion/context.zig (inline) |
Unit tests for detectCompletionContext and computeOffset. |
completion/builder.zig (inline) |
Unit tests for helper functions (stripModulePrefix, firstSegment, lastSegment). |
completion/builtins.zig (inline) |
Unit tests for isBuiltinType and BUILTIN_TYPES.len. |
Handler test structure (handler_integration_tests.zig)
Each test follows this pattern:
- Create a temp directory and a
.rocfile path + URI. - Build a sequence of framed JSON-RPC messages:
initialize(id: 1)initializedtextDocument/didOpenwith Roc source as thetextfieldtextDocument/completion(id: 2) with a specific line/charactershutdown(id: 3)exit
- Feed all messages into a
fixedBufferStreamreader. - Create a
Serverand callserver.run(). - Collect all JSON-RPC responses from the writer buffer.
- Find the response with
id: 2. - Assert
result.isIncompleteandresult.itemsexist. - Scan
itemsfor expected labels (e.g.,found_str,found_u64).
Existing handler test cases
| Test name | Source code | Position | Verifies |
|---|---|---|---|
returns local definitions |
foo = 42\nbar = |x| x + 1 |
2:0 | items array exists |
returns module members after dot |
app [...]\nimport Str\nresult = Str. |
2:13 | Str member completions |
returns module names in expression context |
app [...]\nimport Json\nresult = |
2:9 | Json appears |
returns types after colon |
app [...]\nMyList:List(Str)\nx : |
2:4 | Str, U64, Bool appear |
returns List module members after List dot |
app [...]\nresult = List. |
1:14 | List member completions |
returns local variables in block scope |
block with inner = |
inner scope | local vars visible |
returns lambda parameters |
foo = |x, y| ... |
inside body | x, y visible |
returns top-level definitions |
add = |a, b| ...\nresult = |
after = |
add appears |
returns record fields after dot |
rec = { name: "hi" }\nresult = rec. |
after rec. |
name field appears |
Syntax test structure (syntax_test.zig)
These tests call getCompletionsAtPosition() directly on a SyntaxChecker:
- Create a
SyntaxCheckerwith a temp working directory. - Write a
.rocfile with specific source. - Call
syntax_checker.getCompletionsAtPosition(uri, source, line, char). - Assert on the returned
CompletionResult.items—check specific labels,
kinds, and detail strings.
Running tests
# All completion-related LSP tests
zig build run-test-zig-module-lsp_integration -- --test-filter "completion"
# Just context detection unit tests
zig build run-test-zig -- --test-filter "detectCompletionContext"
# Just builder helper tests
zig build run-test-zig -- --test-filter "stripModulePrefix"
# Just builtin type tests
zig build run-test-zig -- --test-filter "isBuiltinType"
# Fast LSP unit tests
zig build run-test-zig-module-lsp_unit
# Compiler-backed LSP integration tests
zig build run-test-zig-module-lsp_integrationWriting a new completion test
For a handler-level integration test, follow the pattern inhandler_integration_tests.zig:
test "completion handler returns X in Y context" {
// 1. Setup: allocator, tmp dir, file path, URI
// 2. Build JSON-RPC message sequence with your Roc source
// - The source goes in the didOpen "text" field
// - The completion position goes in the completion "position" field
// 3. Run server
// 4. Find response with your request id
// 5. Scan items for expected labels
}For a mid-level test in syntax_test.zig, callgetCompletionsAtPosition(uri, source, line, character) and inspect the
returned items directly.
For a unit test of context detection, add a test to completion/context.zig:
test "detectCompletionContext: your new case" {
const source = "your source text here";
const ctx = detectCompletionContext(source, line, character);
try std.testing.expect(ctx == .your_expected_variant);
}Common Tasks
Adding a new completion source to an existing context
Find the switch (context) in syntax.zig:1890 and add yourbuilder.add*() call in the appropriate branch. If no suitable builder method
exists, add one to completion/builder.zig.
Adding a new completion context
- Add a variant to
CompletionContextincompletion/context.zig. - Update
detectCompletionContext()to recognise the new trigger. - Add a branch in the
switch (context)insyntax.zig. - Add context detection unit tests and at least one handler integration test.
Adding a new completion item kind
The CompletionItemKind enum in handlers/completion.zig already has all 25
LSP kinds. Pick the appropriate one when creating items in the builder.
Overview (`src/postcheck/monotype/README.md`)
Monotype lowering
This directory specializes checked Roc programs into fully concrete types and
lowers them into the program representation consumed by evaluation and code
generation.
Body lowering treats its instantiation graph as the authority for everyTypeId it inspects. This ownership boundary prevents body-local type state
from being confused with coordinator-owned program types, and allows completed
specialization work to cross into the final program only through ordered
commit and explicit type relocation.
Langref Style Guide (`docs/langref-style-guide.md`)
Language Reference Style Guide
This is how to write pages for the language reference. Every
page should read as if the same person wrote it.
(This file lives outside docs/langref/ because every Markdown file in that
directory gets published as a langref page.)
Talk to the Reader
Write to "you," the way you'd explain something to a colleague who already knows
how to program. Use contractions (can't, it's, doesn't, you'd). The langref is
detailed, but it's not a spec written in legalese.
- Yes: "Another way to think of an expression is that you can always assign it to a name—so, you can always put it after an
=sign." - No: "An expression is any construct that may appear on the right-hand side of an assignment."
Avoid instruction-manual phrasing like "Use X to…" or "Prefer X when…" as the main
way of explaining something. Explain what X does, and the reader will know when to
use it.
Define Terms Plainly, Where They're Introduced
Introduce a term in italics in the sentence that defines it, using everyday words.
"X is where…" and "X is something that…" are fine.
- "A block expression is an expression with some optional statements before the expression."
- "Dispatch is where the same call expression can result in a different function being run, depending on the types of its arguments and/or return value."
When a concept has a standard name in computer science, link the name to
Wikipedia (or to a paper) instead of re-explaining it: "Heap-allocated Roc values
are automatically reference-counted."
Bold a word when you're defining several properties at once in a bulleted list:
- "Structural means that you don't have to choose a name for the type…"
- "Extensible means that the type can accumulate new tags based on how it's used…"
Avoid Compiler-Internals Vocabulary
Readers know programming, not the compiler's internals. Don't use words like
unification, inhabited, materialize, lowering, backing row, monotype, solver, or
"lift" unless the page is specifically about that concept and has defined it
first. Describe what the reader can observe: what compiles, what's an error, and
what happens at runtime.
Explain Why, Including the Tradeoff
Say why something is the way it is, and what was weighed against what. That's
most of what makes a reference worth reading instead of just trying things in the
REPL.
- "That's because it's much more useful to have
{ x }be a block expression, for situations likeelse { x }, than syntax sugar for a single-field record like{ x: x }. Single-field records are much less common than blocks in conditional branches." - "(If it did not give an error at compile time, it would either crash or loop infinitely at runtime.)"
- "By design, Roc has no way to express reference cycles, so none of these solutions are necessary."
"By design" is the phrase for a deliberate omission. Use it when something is
missing on purpose, and say what the purpose was.
Compare to Other Languages When It Helps
Briefly, and without judging: "Like most programming languages, Roc uses strict
evaluation and does not support lazy evaluation like some non-strict languages do
(such as Haskell)." or "Other languages support reference cycles, which create
problems for reference counting systems."
Small, Annotated Examples
Prefer several tiny examples over one big one. Lists of one-line examples work well:
- "In
x = Foo,Foois a tag." - "In
y = Foo(4),Foois a tag with a payload of4."
In code blocks, use comments to point at the interesting line:# This line will never be reached. or Purple # ERROR! to_color returns ..[] and so does not accept new tags.
Show the error case right next to the working case: "This is allowed: … However,
this gives a shadowing error: …"
Nothing tests the langref's code blocks automatically, so check every example
(and every claim about what's an error) with a current build of roc before
publishing it.
Say Who Something Is For
Different readers care about different things. Say so directly: "This is rarely
useful to application authors, but it is useful to platform authors." Caveats that
only matter to one audience go in a block quote starting with "Note that":
Note that platform authors can choose to implement features based on memory
addresses, since platforms have access to lower-level languages…
Be Definitive
When a list is complete, say so: "There are no other types of expressions in the
language." When something is an error, say "This gives an error at compile time,"
not "This is generally not permitted."
Describe the language as it is. Don't describe how it used to work, and don't
describe planned features as if they exist.
Performance Sections
Every page whose subject exists at runtime gets a section on performance. It
should explain the representation in enough detail that the reader could predict
what a piece of code costs:
- Where the bytes live (inline vs. on the heap), how big things are on 64-bit and
32-bit targets, and whether there's a reference count. - What an operation copies, allocates, or mutates in place, and what decides
which one happens (usually opportunistic mutation). - What the compiler does for you, so the reader knows what they don't need to
hand-optimize (for example, record fields are reordered to minimize padding, so
reordering them yourself does nothing). - What the reader can do differently when performance matters, with an example.
Only state things the compiler actually does today. If something is planned but
not implemented, leave it out.
Mechanics
- Em dashes have no spaces around them (
value—regardless). Tidy enforces this. - Headings are Title Case.
- Link other langref pages with extensionless relative links:
[records](records). - Code fences use
roc. - Paragraphs are short: usually one to three sentences.
- Don't end every paragraph with "See X for more." Link the words where they're
used instead.
Llvm Codegen Comparison (`docs/llvm-codegen-comparison.md`)
LLVM Code Generation Comparison: Zig vs Legacy Rust
This document compares the new Zig LLVM evaluator (src/eval/llvm_evaluator.zig) with the legacy Rust LLVM code generation (crates/compiler/gen_llvm/).
Scale Comparison
| Metric | New Zig | Legacy Rust |
|---|---|---|
| Total lines | ~1,700 | ~18,000 |
| Main file | 1,659 lines | 6,907 lines (build.rs) |
| Number of files | 1 | 18 |
| Low-level ops | ~20 | ~100+ (2,897 lines) |
| Refcounting | 0 lines | 1,808 lines |
| Comparison ops | ~50 lines | 1,509 lines |
| List operations | ~20 lines | 738 lines |
Architectural Differences
Input IR
Legacy Rust:
- Works on Mono IR (
roc_mono::ir) - already monomorphized - All types are concrete, all functions are specialized
- Lambda sets resolved to concrete function pointers
- Input is
StmtandExprenums from monomorphization phase
New Zig:
- Works on CIR (Canonical IR) - pre-monomorphization
- Types still have polymorphism
- Direct from canonicalization phase
- Input is
CIR.Exprfromcan.CIR
LLVM API
Legacy Rust:
// Uses inkwell (Rust LLVM bindings)
use inkwell::context::Context;
use inkwell::builder::Builder;
use inkwell::module::Module;
use inkwell::values::{FunctionValue, BasicValueEnum};
// Creates full LLVM infrastructure
let context = Context::create();
let module = context.create_module("roc_app");
let builder = context.create_builder();New Zig:
// Uses Zig's standard library LLVM bindings
const LlvmBuilder = std.zig.llvm.Builder;
// Lightweight builder that serializes to bitcode
var builder = LlvmBuilder.init(.{
.allocator = allocator,
.name = "roc_repl_eval",
.target = &builtin.target,
.triple = getLlvmTriple(),
}) catch return error.OutOfMemory;Purpose
Legacy Rust:
- Full AOT (Ahead-of-Time) compiler backend
- Produces object files for linking
- Used by
roc buildand the defaultroccommand - Targets all supported platforms
New Zig:
- JIT evaluation for REPL
- Produces bitcode for immediate execution
- Used by
roc replwith--optflags - Single-expression evaluation only
Feature Comparison
Expression Support
| Expression Type | Legacy Rust | New Zig |
|---|---|---|
| Literals (int, float, str) | Yes | Yes |
| Binary operations | Yes | Yes |
| Unary operations | Yes | Yes |
| If expressions | Yes (full CFG) | Yes (select-based) |
| Match/Switch | Yes (full) | No |
| Struct creation | Yes | Yes |
| Struct field access | Yes | Yes |
| Tag creation | Yes (all union layouts) | Limited |
| Tag matching | Yes (full) | No |
| List literals | Yes | Limited |
| List operations | Yes (all) | Empty list only |
| Function calls | Yes (all) | Limited builtins |
| Lambdas | Yes | No |
| Closures | Yes | No |
| Foreign calls | Yes | No |
Statement Support
| Statement Type | Legacy Rust | New Zig |
|---|---|---|
| Let bindings | Yes | Yes (via blocks) |
| Return | Yes | N/A (expression-based) |
| Switch/Jump | Yes | No |
| Join points | Yes | No |
| Refcount ops | Yes | No |
| Expect | Yes | No |
| Crash | Yes | No |
Type Support
| Type | Legacy Rust | New Zig |
|---|---|---|
| i8-i128 | Yes | Yes |
| u8-u128 | Yes | Yes |
| f32, f64 | Yes | Yes |
| Dec | Yes | Yes |
| Bool | Yes | Yes |
| Str (small) | Yes | Yes |
| Str (large) | Yes | No |
| List | Yes | Limited |
| Record/Struct | Yes | Yes |
| Recursive unions | Yes | No |
| Non-recursive unions | Yes | Limited |
| Function pointers | Yes | No |
| Opaque types | Yes | No |
| Erased types | Yes | No |
Low-Level Operations
Legacy Rust (lowlevel.rs - 2,897 lines) supports all operations:
match op {
StrConcat => { /* full impl */ }
StrJoinWith => { /* full impl */ }
StrStartsWith | StrEndsWith => { /* full impl */ }
StrToNum => { /* handles all numeric types */ }
ListLen | ListGetUnsafe | ListSet => { /* full impl */ }
ListConcat | ListAppend | ListPrepend => { /* full impl */ }
ListMap | ListMap2 | ListMap3 | ListMap4 => { /* higher-order */ }
NumAdd | NumSub | NumMul | NumDiv => { /* all widths */ }
NumAbs | NumNeg | NumSin | NumCos => { /* math ops */ }
NumToStr => { /* for all numeric types */ }
// ... 100+ more operations
}New Zig (llvm_evaluator.zig) supports basic operations:
// Only handles simple builtin calls by ident matching
if (func_ident == idents.not and args.len == 1) { /* bool not */ }
if (func_ident == idents.abs and args.len == 1) { /* numeric abs */ }
if (func_ident == idents.negate and args.len == 1) { /* negate */ }
if (func_ident == idents.abs_diff and args.len == 2) { /* abs_diff */ }Memory Management
Legacy Rust (refcounting.rs - 1,808 lines):
- Full reference counting implementation
- Increment/decrement for all refcounted types
- Deallocation with alignment handling
- Reset/reuse optimization for unique references
- Special handling for lists, strings, recursive structures
pub fn increment_refcount_layout<'a, 'ctx>(
env: &Env<'a, 'ctx, '_>,
layout_interner: &STLayoutInterner<'a>,
layout_ids: &mut LayoutIds<'a>,
value: BasicValueEnum<'ctx>,
layout: InLayout<'a>,
) { /* recursive increment */ }
pub fn decrement_refcount_layout<'a, 'ctx>(
env: &Env<'a, 'ctx, '_>,
layout_interner: &STLayoutInterner<'a>,
layout_ids: &mut LayoutIds<'a>,
value: BasicValueEnum<'ctx>,
layout: InLayout<'a>,
) { /* recursive decrement with deallocation */ }New Zig:
- No reference counting (single expression evaluation)
- No memory management needed for REPL use case
Comparison Operations
Legacy Rust (compare.rs - 1,509 lines):
- Generic equality for all types
- Recursive comparison for structs, unions, lists
- Special handling for floats (NaN)
- String comparison via bitcode
pub fn generic_eq<'a, 'ctx>(
env: &Env<'a, 'ctx, '_>,
layout_interner: &STLayoutInterner<'a>,
layout_ids: &mut LayoutIds<'a>,
lhs_val: BasicValueEnum<'ctx>,
rhs_val: BasicValueEnum<'ctx>,
lhs_layout: InLayout<'a>,
rhs_layout: InLayout<'a>,
) -> BasicValueEnum<'ctx> { /* handles all types */ }New Zig:
- Integer/float comparison via LLVM icmp/fcmp
- String comparison returns UnsupportedType
Bitcode Integration
Legacy Rust (bitcode.rs - 1,351 lines):
- Calls into pre-compiled Zig bitcode for builtins
- Platform-specific ABI handling (Windows i128, macOS arm64)
- Wraps list/string operations
- Handles Dec arithmetic
pub fn call_bitcode_fn<'ctx>(
env: &Env<'_, 'ctx, '_>,
args: &[BasicValueEnum<'ctx>],
fn_name: &str,
) -> BasicValueEnum<'ctx> {
// Call into compiled Zig code
// Handle platform ABI differences
}New Zig:
- Generates standalone functions
- No external bitcode dependencies for evaluated expressions
Debug Information
Legacy Rust:
- Full DWARF debug info generation
- Source locations, line numbers
- Variable names and types
- Integration with
DebugInfoBuilder
New Zig:
- No debug info (REPL evaluation doesn't need it)
Optimization
Legacy Rust:
- Uses LLVM
PassManager - Configurable optimization levels
- Inlining, constant folding, etc.
New Zig:
- No optimization passes
- Relies on LLVM JIT's basic optimizations
Control Flow Comparison
If Expressions
Legacy Rust - Full CFG with basic blocks:
// Creates then_block, else_block, merge_block
// Uses conditional branch and phi nodes
env.builder.new_build_conditional_branch(cond, then_block, else_block);
// ... build then block ...
// ... build else block ...
// Merge with phiNew Zig - Full CFG with basic blocks and phi nodes:
// Creates merge block, then evaluates each branch
const merge_block = ctx.wip.block(num_incoming, "if_merge");
// For each branch: create then/else blocks, emit conditional branch
_ = ctx.wip.brCond(cond_i1, then_block, else_block, .none);
// Emit then block, branch to merge
ctx.wip.cursor = .{ .block = then_block };
const then_val = try ctx.emitExpr(branch.body);
_ = ctx.wip.br(merge_block);
// Merge with phi node
ctx.wip.cursor = .{ .block = merge_block };
const wip_phi = ctx.wip.phi(result_type, "if_result");
wip_phi.finish(phi_values[0..phi_count], phi_blocks[0..phi_count], ctx.wip);Switch/Match
Legacy Rust - Full switch implementation:
fn build_switch_ir<'a, 'ctx>(
env: &Env<'a, 'ctx, '_>,
// ...
switch_args: SwitchArgsIr<'a, 'ctx>,
) -> BasicValueEnum<'ctx> {
// Build switch instruction with cases
// Handle join points for complex control flow
}New Zig:
- Returns
UnsupportedTypefor match expressions
Union/Tag Handling
Legacy Rust
Handles all union layouts (UnionLayout enum):
NonRecursive- Tags stored by valueRecursive- Heap-allocated with pointer taggingNonNullableUnwrapped- Single recursive variantNullableWrapped- Optional-like with null pointerNullableUnwrapped- Single non-null variant
fn build_tag<'a, 'ctx>(
env: &Env<'a, 'ctx, '_>,
layout_interner: &STLayoutInterner<'a>,
scope: &Scope<'a, 'ctx>,
union_layout: &UnionLayout<'a>,
tag_id: u8,
arguments: &[Symbol],
// ...
) -> BasicValueEnum<'ctx> {
match union_layout {
UnionLayout::NonRecursive(_) => { /* stack allocation */ }
UnionLayout::Recursive(_) => { /* heap allocation with refcount */ }
// ... all variants
}
}New Zig
Only handles simple tags:
fn emitZeroArgTag(ctx: *ExprContext, tag: anytype) ExprError!LlvmBuilder.Value {
// For Bool tags, True is 1 and False is 0
const value: i128 = if (tag.name == ctx.module_env.idents.true_tag) 1 else 0;
return (ctx.builder.intConst(.i8, value) catch return error.CompilationFailed).toValue();
}Higher-Order Functions
Legacy Rust - Full support:
pub(crate) fn run_higher_order_low_level<'a, 'ctx>(
env: &Env<'a, 'ctx, '_>,
layout_interner: &STLayoutInterner<'a>,
layout_ids: &mut LayoutIds<'a>,
scope: &Scope<'a, 'ctx>,
layout: InLayout<'a>,
op: &HigherOrderLowLevel<'a>,
// ...
) -> BasicValueEnum<'ctx> {
// Handles List.map, List.map2, List.walk, etc.
// Generates loops with function pointer calls
}New Zig:
- No higher-order function support
- Returns
UnsupportedTypefor lambdas
Summary
| Aspect | Legacy Rust | New Zig (WIP) |
|---|---|---|
| Purpose | Full AOT compiler | Full compiler (work in progress) |
| Input | Mono IR (monomorphized) | CIR (pre-mono) |
| LLVM API | inkwell (full LLVM) | Zig std.zig.llvm.Builder |
| Lines of code | ~18,000 | ~1,700 |
| Type coverage | Complete | Basic scalars + simple records |
| Control flow | Full CFG | Full CFG (if), WIP (switch) |
| Memory mgmt | Full refcounting | Not yet implemented |
| Unions | All layouts | Bool tags only |
| Functions | Full (closures, HOF) | Limited builtins |
| Low-level ops | 100+ | ~20 |
| Debug info | Full DWARF | Not yet implemented |
| Optimization | PassManager | Not yet implemented |
The new Zig evaluator is a work in progress that aims to achieve full feature parity with the legacy Rust implementation. Current status:
Implemented:
- Basic scalar types (integers, floats, Dec, Bool)
- Binary and unary operations
- If expressions with proper CFG (basic blocks, conditional branches, phi nodes)
- Simple records and field access
- Multi-branch if-else chains
Not yet implemented:
- Switch/match expressions
- Reference counting and memory management
- All union layouts (only Bool tags work)
- Closures and lambda sets
- Higher-order functions
- Full low-level operation set
- Debug information
- Optimization passes
The legacy Rust code is a full production compiler backend that serves as the reference implementation for feature parity.
Mini Tutorial New Compiler (`docs/mini-tutorial-new-compiler.md`)
Mini Tutorial for Roc
Roc is still a work in progress and has not reached a 0.1 release, so expect missing features and bugs.
Helpful resources:
- Standard library docs
- Examples
- Language reference
- Roc syntax overview
- Learn roc by doing, with exercism
If you want help, the best place to get it is Roc Zulip. You're welcome to ask any questions in #beginners.
With those disclaimers in mind, let's get into the adventure!
Hello, World
First, follow the installation guide to install a nightly build.
It includes an executable named roc (or roc.exe on Windows). You'll know it is available on your PATH
if roc version works.
Next, copy/paste this into a new file named main.roc:
main! = |_args| {
echo!("Hello, World!")
Ok({})
}You can run this with:
roc main.rocYou should see this:
Hello, World!Hooray!
Tip: If you don't provide a path to a .roc file,
rocwill default to "main.roc" - so since your Roc program is named main.roc, you can run your program by just runningrocin the future.
The REPL
You can also try Roc expressions and definitions without creating a file by running roc repl.
roc replInside the REPL, enter an expression to evaluate it:
1 + 1You can define names and use them in later inputs:
x = 1 + 1
x * 2The REPL also works well with piped input. In piped mode it prints only evaluation output to stdout; prompts, the welcome banner, and the goodbye message are not printed.
In PowerShell, send multiple input lines with an array:
@("x = 1 + 1", "x * 2") | roc replYou should see:
assigned `x`
4.0In Unix shells, use printf for the same thing:
printf 'x = 1 + 1\nx * 2\n' | roc replDiagnostics from piped input are written to stderr, so tools can read successful results from stdout separately from parse and type errors. If you want plain diagnostics without ANSI color codes, use roc repl --no-color or set NO_COLOR environment variable.
The `main!` function
Let's take a look at main! next:
main! = |_args| {
echo!("Hello, World!")
Ok({})
}This defines our program's entrypoint. The code inside the curly braces will run when we do roc main.roc.
Pure and effectful functions
That |_args| { ... } is Roc syntax for an anonymous function with one argument, named _args. (If it had multiple arguments,
it would look like |arg1, arg2| { ... } instead.) Functions in Roc are ordinary values, just
like numbers, strings, or booleans; we can pass them around, put them in collections, and use = to name them.
We named it main! (and not the traditional main) because it's a function that runs the
side effect of printing to
stdout.
In Roc, we call functions that can run side effects effectful functions, and by convention
we always name them with a ! at the end of their names. In contrast,
pure functions don't have a ! at the end of their names.
Constants
This line defines a new constant called name:
name = "Rocco"Constants should not be reassigned or shadowed, if you try to do name = again in the same scope,roc will give a compile-time warning with exit code 2. That way, you can quickly write something with
shadowing if you want but the non-zero exit code prevents it from ending up in production code because CI will fail.
String interpolation
The string "Hello, ${name}!" will evaluate to "Hello, Rocco!".
In an ordinary string interpolation, each interpolated expression must also produce a string; Roc does not automatically convert other types. For example, if you wanted to print an integer you'd need to call to_str on it like so:
echo!("Number of things: ${thing_count.to_str()}")You can put any expression you like inside a string interpolation. If you really wanted to, you could do something like this:
echo!("Answer: ${((numerator / denominator) + 1).negate().to_str()}")...but at that point it'd probably be easier to read if you extracted that expression into a constant.
`for` and `fold`
Sometimes code is easier to understand in a functional style, and other times it's easier to understand
in an imperative style. Roc has support for both styles, even though its APIs are designed around immutable
values and a functional style.
Expect
To see both styles, let's write a function called digits_to_num which takes a list of digits and returns the number they represent. When we're done, we'll be able to run roc test and see these expects pass:
expect digits_to_num([1, 2, 3]) == 123
expect digits_to_num([4, 2]) == 42
expect digits_to_num([7]) == 7expect is Roc's lightweight testing keyword. You can put a boolean expression after it, and if that expression
evaluates to True, the test will pass, and if it evaluates to False, the test will fail. When you run roc test,
it runs all of top-level expects in your files, as well as all the files they import (unless they belong to downloaded packages).
A file run with roc test does not need a main! function (or an app header, which will be introduced shortly).
Another useful command is
roc fmt, which formats your source code according to standard Roc style. By design,roc fmthas no configuration options at all.
`for` style
Here's an imperative-style implementation of digits_to_num, using a for loop and reassignable vars:
digits_to_num = |digits| {
var $num = 0
for digit in digits {
$num = ($num * 10) + digit
}
$num
}Unlike a constant, a var like $num can be reassigned. Similarly to how effectful functions have names
ending in ! to distinguish them from pure functions, vars begin with $ to distinguish them from constants.
This means any time you see something beginning with $, you know it might be reassigned somewhere (such as
in a for loop), whereas if you don't see the $, there's no need to think about that possibility.
Note that vars can only be reassigned inside the function where they were declared. This code would give an error:
var $count = 0
things.for_each!(|thing| {
$count = $count + 1
# other logic goes here
})The error would say that $count can't be reassigned inside a different function from where it was declared with var.
As such, if you see a for_each! being used instead of a for loop, that tells you whatever logic inside is guaranteed
not to modify any vars in the outer scope—whereas if you see a for loop, you know that it might be modifying vars.
`fold` style
You can also implement digits_to_num in a functional style using fold:
digits_to_num = |digits| digits.fold(0, |num, digit| (num * 10) + digit)Blocks
Note that there are no curly braces in the digits_to_num definition. That's because every Roc function has one expression
after its arguments (e.g. you can write |arg| arg.to_str()), but that expression can be a block if you like.
Here's an example of a block expression:
answer = {
foo = 43
inner_constant = foo - 1
echo!("Inner constant: ${inner_constant.to_str()}")
inner_constant.abs()
}Block expressions go inside curly braces. They end with an expression (like inner_constant.abs() here),
and the entire block evaluates to whatever that expression evaluates to. This is why, when we use curly
braces in functions, the function returns whatever is at the end of the curly braces—that's just how blocks work!
Statements and expressions
Everything before the expression at the end of a block is a statement. The difference between statements
and expressions is:
- An expression evaluates to a value.
- A statement does not.
for loops and constant declarations like inner_constant = are statements. Since statements don't evaluate to
values, you can't do things like pass them as function arguments. You couldn't write echo!(for ...) orecho!(inner_constant = ...) - you'd get a compile-time error if you did.
Blocks are expressions, so you could write something like this inside an effectful function:
echo!({
var $total = 0
for number in 1..=10 {
$total = $total + number
}
$total.to_str()
})An easy way to think of it is that blocks are a way to incorporate statements into your expressions.
`return` statements
Roc's return keyword works the same way it works in most languages: it causes the function to immediately return.
digits_to_num = |digits| {
if digits.is_empty() {
return 0
}
# ...the rest of the function would go here
}If you use this in a block, you may get a warning if any statements or expressions come after it in the block,
as they will not be executed!
`crash` statements
Roc's crash keyword does what it says: it crashes the currently-running Roc application.
digits_to_num = |digits| {
if digits.is_empty() {
crash "Expected a nonempty list."
}
# ...the rest of the function would go here
}crash is not for error handling.
The reason Roc has a crash keyword is for scenarios where it's expected that no error will ever happen (like in unreachable branches), or where graceful error handling is infeasible (like running out of memory).
Errors that are recoverable should be represented using normal Roc types (like Try) and then handled without crashing. For example, by having the application report that something went wrong, and then continue running from there.
What happens after a crash is determined by the platform. Some may gracefully recover and have some way of continuing the process, but others may terminate the process immediately.
Just like return, if you use crash in a block, you may get a warning if any statements or expressions
come after it in the block, as they will not be executed!
`expect` statements
We mentioned expect earlier - if you put these at the top level of your file, they will be run whenever roc test runs.
You can also put them in blocks, in which case they will work essentially like a crash when you're doing roc test or
a debug build of roc, but when you do roc --opt=speed, they will be skipped.
digits_to_num = |digits| {
if digits.is_empty() {
return 0
}
# From here on, we assume digits is nonempty!
expect !digits.is_empty()
# ...the rest of the function would go here
}Importantly, these are not production assertions!
The point is that these are checks of things you assume will be true, and if they turn out not to be true,
you would like to be alerted about the assumption proving false during development or when running test. It is the
responsibility of other code to handle (or not) the situation where these assumptions turn out to be false.
For example, here are three different ways you can handle an assumption turning out to be false in production:
- Detect it and gracefully recover from it, because that's the best experience for people using the software
- Do not attempt to detect it, because doing so is either impractical to implement or too costly in terms of runtime performance; accept that if it happens, it will be bad, but trying to detect it defensively would be worse overall
- Detect it and crash the program using the
crashkeyword
All three of these have different tradeoffs, and different situations can reasonably call for one over the others.
The point of expect working the way it does is that it does not run in --opt=speed builds at all,
so it does not have production tradeoffs! You can use it as often as you like, and the consequences will
only be felt during development.
`dbg` statements
You can use the dbg keyword for the classic technique of printline debugging, like so:
main! = |_args| {
x = 5
dbg x
# dbg(x) works too
Ok({})
}Although printing is an I/O operation, the dbg statement can be used even in pure functions. It works in two different ways:
- Roc evaluates top-level constants at compile time. If evaluating one calls a pure function containing
dbg, you'll see thedbgoutput at compile time. (Evaluating known expressions ahead of time is called constant folding.) - At runtime,
dbgmay work differently depending on where you're running the program. For example, in this command-line application it will be printed to stderr. However, when running a Roc program that's compiled to WebAssembly and running in the browser, it would likely appear in the browser console instead.
Note that Roc's compile-time evaluation means there is no guarantee that a given dbg will run at runtime or at compile time.
Conditionals and Collections
`if` expressions
In Roc, if can be used as an expression like so:
name = if str.is_empty() "n/a" else strYou can optionally use blocks to make the different branches stand out more:
name = if str.is_empty() {
"n/a"
} else {
str
}Records
You can also create records, like so:
record = if str.is_empty() {
{ name: "n/a", has_name: Bool.False }
} else {
{ name: str, has_name: Bool.True }
}You can "update" a record by creating a new one that has some of its fields changed:
new_record = { ..record, name: "New Name" }You can also destructure a record to bring some of its fields into scope as constants:
{ name, has_name } = recordLists
In Roc, a List looks like this:
animals = ["bird", "crab", "lizard"]Method Calling
You can get the length of the list by calling List.len(animals), or as a shortcut, you can just call animals.len().
Both of them do the same thing. When you call .len(), Roc's type inference knows that the type of animals is List,
so it translates that animals.len() call into List.len(animals) at compile time. .len() is known as a method because
it's a function that is associated with a particular type (in this case, List).
Pattern Matching
You can get the first element in the list using animals.first(), but you can also do it using pattern matching:
points = match animals {
["bird", "crab", "lizard"] => 10 # exact match
["bird", "crab", ..] => 5 # partial match
["bird", ..] => 1 # partial match
[first, second, "lizard", ..] => count_points(first, second)
_ => 0 # default
}Patterns can nest as deeply as you like; if you had a list of lists of strings, you could do a pattern like [first_list, ["bird", ..], ..] => etc.
The default branch (_ =>) at the end is necessary so that it's always clear what value points should become,
since lists can be any length and have lots of different contents.
Tags
All items in a list must have compatible types.
The following will give a compile error because it's a list with both strings and numbers in it:
animals = ["eagle", 1]That said, you can tag each of them like so:
birds_or_numbers = [Bird("eagle"), Number(1)]Pattern matching on tags
You can use pattern matching to access the contents of tags:
label = match birds_or_numbers {
[Bird(bird), Number(num)] => "${bird} number ${num.to_str()}"
_ => "" # default
}`Try`
Roc does not have null, nil, undefined, or anything similar. Instead, it uses the tags Ok and Err to
represent whether an operation succeeded or failed:
numbers = [1, 2 ,3 ]
number = match numbers.first() {
Ok(first) => first + 1
Err(ListWasEmpty) => 0
}Here, ListWasEmpty is a tag that isn't wrapping anything. The List.first method is just returning it inside the Err
to describe what the failure was. This both makes the code more self-documenting and also lets you distinguish between
different error types.
For example:
num_or_err = if numbers.is_empty() {
I64.from_str("1")
} else {
numbers.first()
}
answer = match num_or_err {
Ok(num) => num + 1
Err(ListWasEmpty) => 0
Err(BadNumStr) => -1
}Here we have an extra Err branch, because List.first can return Err(ListWasEmpty) if the list was empty,
whereas I64.from_str can return Err(BadNumStr).
Note:
I64is a number type—specifically, a 64-bit integer. Roc also supports 8-bit, 16-bit, 32-bit, and 128-bit integers, and they can be either signed (likeI64) or unsigned (likeU64). For non-integer types, Roc hasF32andF64for the classic 32-bit and 64-bit binary floating point numbers, and alsoDecfor a 128-bit fixed-point decimal. If you don't specify a number type, Roc usesDecas the default number—which is why in Roc,0.1 + 0.2 == 0.3isTrue, whereas in most languages it isn't.
Exhaustiveness
Neither of these match expressions had a default _ => branch. They didn't need it because they are already exhaustive,
which means they have covered all possible cases. If a match on a known type omits a possible case, the compiler reports
a non-exhaustive match error. If you add _ => after earlier branches have already covered every case, the compiler warns
that the pattern is redundant.
Roc code tends to avoid _ => default branches because these exhaustiveness errors can be helpful for telling you when
you've forgotten to handle something. For example, in our second match above, if we'd written _ => 0 instead ofErr(ListWasEmpty) => 0, we would have been silently handling the Err(BadNumStr) case using the same logic. That might
not have been what we wanted! By writing out Err(ListWasEmpty) instead of _ =>, the compiler would let us know if we
were forgetting to handle any cases that could come up at runtime.
`Try` methods
Both List.first and I64.from_str are returning an extremely common Roc type, named Try. We define and pattern-matchStr values using "…", List values using […], and Try values using Ok and Err. So if I had a function that
accepted a Try with strings for both its Ok and Err types, then I could pass it Ok("foo") or Err("bar").
Just like Str and List, Try has methods. Here's an example of one:
number = numbers.first().ok_or(0)Underscore patterns
The Try.ok_or method is defined like this:
ok_or = |try, fallback| match try {
Ok(val) => val
Err(_) => fallback
}It returns the value inside the Ok tag, or else the provided fallback value if the Try was an Err tag instead of Ok.
The underscore pattern inside Err(_) => essentially means to ignore that part of the pattern. You can put underscores
anywhere in any pattern, including for the entire pattern. This method doesn't want to match a more specific pattern
(like Err(ListWasEmpty) => earlier) because it wants to be flexible. If it matched a more restrictive pattern, likeErr(ListWasEmpty) =>, then you couldn't use .ok_or with I64.from_str because it returns Err(BadNumStr).
The `?` postfix operator
It's common to want to early-return an Err from a Try. It's so common, Roc has a dedicated operator for it:
increment_first = |strings| {
first_str = strings.first()?
first_num = I64.from_str(first_str)?
Ok(first_num + 1)
}The ? postfix operator is syntax sugar for "if this is an Ok, evaluate to its value; otherwise, return the Err."
The desugared version of the above function would be:
increment_first = |strings| {
first_str = match strings.first() {
Ok(val) => val
Err(err) => return Err(err)
}
first_num = match I64.from_str(first_str) {
Ok(val) => val
Err(err) => return Err(err)
}
Ok(first_num + 1)
}The ? version is quite a bit more concise!
Types
So far we haven't seen any types. That's because although Roc is a statically type-checked language, it infers the types
of everything you write. All type annotations in Roc are optional, but the compiler still infers every type, so you'll
still get compile-time errors if you mix up types. Technically, Roc has sound, decidable, principal static type inference. All Roc values are semantically immutable, making them free of data races as well.
Roc has a "nonblocking compilation" design philosophy. This means that roc will still run your program and roc test
will still run your tests when possible, even if you have compile-time errors—including static type mismatches. The
diagnostics are still printed and the command exits with a nonzero status, so CI will treat the run as a failure.
This lets you try code despite known problems, although reaching invalid code at runtime may cause it to crash.
Use roc check && roc if you want to check for errors first and run the program only when there are none.
Type Annotations
You can write type annotations above any constant or var, like so:
name : Str
name = "Sam"
is_empty : Bool
is_empty = name.is_empty()Parameterized types
We noted earlier how all items in a List must have compatible types. That's represented in the List type
using a type parameter like so:
strings : List(Str)
strings = ["a", "b", "c"]
integers : List(I64)
integers = [1, 2, 3]Pure and effectful function types
Pure functions use a thin arrow (->) and effectful functions use a thick arrow (=>) to separate parameter types from
return types:
average : Dec, Dec -> Dec
average = |a, b| (a + b) / 2
# Note that you don't have to write out 2.0 to perform decimal division in Roc; you can just write 2 like normal!
read_str! : Path => Try(Str, ReadFileErr)
read_str! = |path| # ...Try type is a parameterized type with two type parameters. The first one is the Ok type and the second one is the Err type.
Type variables
Type variables allow you to write functions that work with any type. They are written as lowercase identifiers (like a, b, elem, etc.) in type annotations:
# This function works for a list of any type
type_var : List(a) -> List(a)
type_var = |lst| lstThe type variable a indicates that the function accepts a List containing elements of any type, and returns a List containing elements of that same type.
You can also constrain type variables to types that have specific methods using where:
stringify : a -> Str where [a.to_str : a -> Str]
stringify = |value| value.to_str()This function works for any type a that has a to_str method which takes an a and returns a Str.
Structural types
Tag union types
It's very common to see a Try with a tag union for its error type, even if it only has one tag in it. This allows
multiple errors to neatly combine into a tag union of all the possible errors that could occur, so you can pattern
match on them later like we saw with I64.from_str and List.first.
Nominal types
As we saw with ListWasEmpty and BadNumStr, tags don't have to wrap anything. You can also use them as enumerations, like so:
Color := [Red, Green, Blue]This is a nominal type definition.
Upgrading from alpha-4
If you've been writing Roc before it was mainstream and you want to upgrade your code from alpha-4, compare the two all syntax examples:
Dependencies
The initial Hello World example is a headerless app. Headerless apps automatically use Roc's built-in
Echo Platform, which provides the unqualified echo! function and is intentionally minimal.
Other Roc apps select a platform explicitly. For example, this header uses
basic-cli, a platform for command-line applications:
app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.22.2/9zUBxb1LtXYVc4eR4hAtd1WQDwBYDhM6HQdZz1UFCm2m.tar.zst" }Let's break the header down:
appmeans this .roc file specifies a Roc application - an executable, as opposed to a bundle of reusable code like a package[main!]specifies the application's entrypoint. Some applications have multiple entrypoints, but it's most common to have just one—and also it's most common for that one to be namedmain!{ pf: platform "https://..." }specifies the application's platform. If we wanted to add other dependencies, this is where we'd specify them - e.g. we might writeuni: "https://..."to add a dependency on unicode to process Unicode strings, at which point we'd be able to do things likeimport uni.Graphemeand so on.
See the modules documentation for more examples.
Platforms
Roc has a first-class concept of platforms and applications. You can read about the design philosophy, but for our purposes what matters is:
- Every Roc application specifies exactly one platform that it will be built on.
A headerless app implicitly uses the Echo Platform. - The selected platform provides the app's I/O primitives. For example, the
basic-cliplatform gives access to the standard in and out usingStdoutandStdin, which are imported using the platform's shorthand, such aspf.Stdoutandpf.Stdin. - Roc's standard library does not include any effectful functions; they all come from the platform. Here are some links to popular platforms:
Additional Resources
For more, check out:
- Roc's standard library and language documentation
- Examples
- The source code for all builtin functions
- A single file demonstrating Roc syntax
- The Exercism.org Roc track
Langref Boxes (`docs/langref/boxes.md`)
Boxes
A Box holds one value in its own heap allocation. Box.box(value) puts a value in a
box, and Box.unbox(box) gets it back out:
boxed : Box(U64)
boxed = Box.box(42)
answer = Box.unbox(boxed) # 42A Box doesn't do anything a plain value can't do, other than change how the value is stored in
memory. So it's only for situations where that matters, which mostly means performance and
platform interop.
Memory Layout
A Box is a pointer to a heap allocation, which holds the value along with a
reference count. So a Box takes up 8 bytes on a 64-bit target
(4 bytes on a 32-bit target), no matter how big the value inside it is.
Boxing a value copies it into a new heap allocation, and unboxing it copies it back out. So
boxing and unboxing aren't free, but copying a Box around afterward only copies the pointer
(and updates the reference count).
When to Use a Box
Shrinking a Tag Union
A tag union takes up as much space as its largest payload. If one tag
has a much larger payload than the others, and you store lots of these values, then boxing the
large payload means the tag union only needs room for a pointer:
Event : [
KeyPress(U8),
Click(U16, U16),
Snapshot(Box({ width : U64, height : U64, title : Str, pixels : List(U8) })),
]Without the Box, every Event in a List(Event) would take up 72 bytes on a 64-bit target (64
for a Snapshot's payload, plus 1 for the tag, plus padding), even the KeyPress events that only
need 1 byte for their payload. With the Box, each Event takes up 16 bytes.
Recursive Types
You don't need a Box to make a recursive type. When a nominal type refers to
itself, the compiler already stores that part in its own heap allocation, just as if you had
boxed it yourself.
Passing Values to the Platform
A platform's hosted functions can only have type variables inside aBox. That's because the host is compiled ahead of time, so it needs to know
how big each argument is. A Box(a) is always the size of a pointer, no matter what a is, so a
single host function can work with every type a could be.
Equality
Boxes don't have an is_eq method, so you can't compare them with ==. To compare the values
inside two boxes, unbox them first: Box.unbox(a) == Box.unbox(b).
Langref Comments And Docs (`docs/langref/comments-and-docs.md`)
Comments
Comments always begin with a # character and extend to the end of the line.
Roc supports single-line comments as well as doc comments. There is no multi-line comment syntax.
Single-line Comments
Here are some examples of single-line comments:
# This comment takes up a whole line.
# So does this one.
# There is no dedicated multi-line comment syntax.
x = 5 # end-of-line commentBy design, Roc's compiler never derives any semantic meaning from comments. Modifying comments should
never affect the behavior of your running program, aside from source code locations in stack traces.
Shebang Comments
Some shells look for a #! (known as a shebang) at the start of an executable text file. For example:
#!/usr/bin/env rocRoc doesn't have special support for shebangs, but since # begins an ordinary Roc comment,
this example would be treated as a comment by Roc's compiler, while potentially being read
as a shebang by a shell.
Doc Comments
Doc comments add documentation to a top-level definition. They have a special comment syntax:
- Each line of a doc comment begins with
"## "- so, two#s and then a space, at the very beginning of the line. - Each consecutive line that begins with
"## "continues the doc comment. - The next line after a doc comment's final
"## "line must begin with an assignment statement or a type annotation.- If one or more lines beginning with
"## "are not followed immediately by one of those, then none of them are considered a doc comment; they're treated as an ordinary comment instead.
- If one or more lines beginning with
For example:
## Returns the given number unmodified if it's even,
## and negated if it's odd.
##
## ```roc
## expect negate_if_odd(1) == -1
## expect negate_if_odd(2) == 2
## ```
negate_if_odd = |num| if num.is_odd() {
num.negate()
} else {
num
}The text of a doc comment is Markdown. Headings, lists, tables, blockquotes, inline code,
emphasis, and links all work the way they do in other Markdown documents.
Code Blocks in Doc Comments
A fenced code block (a line of three backticks, then some lines of code, then another line of
three backticks) is rendered as a code block in the generated documentation. The example above
uses one to show how negate_if_odd behaves.
Code blocks in doc comments are for readers only. The compiler does not type-check them or run
them, so an expect inside a doc comment's code block will not be run by roc test.
If you want an example to be checked, write it as a top-level expect next to the function too.
Autolinks
Writing a name inside square brackets, such as [Str] or [Str.concat], creates an autolink
to that item's documentation. For example:
## Converts a [Greeting] to a [Str].
##
## To go the other direction, use [Greeting.from_str].
to_str : Greeting -> StrIn the generated documentation, [Greeting], [Str], and [Greeting.from_str] all become links
pointing to the documentation for those items. Autolinks can refer to items in the current
module, to other modules in the same package, and to builtin types and their associated items.
An autolink is only created when the brackets contain nothing but a name (optionally with
dot-separated parts). Ordinary Markdown links, like [the Roc website](https://roc-lang.org),
continue to work as normal links.
Generating Docs with `roc docs`
The roc docs command generates HTML documentation for a package or platform, using the
doc comments and type annotations in its modules:
roc docs main.rocThis writes a static website to a directory named generated-docs (use --output=some/other/dir
to write it somewhere else). The site has a page for each module the package exposes, and each
page lists that module's exposed items along with their types and doc comments. It also includes
a search box for finding items by name.
Since the result is a static website, you can publish it anywhere that can host static files.
Viewing Docs Locally with `--serve`
To preview the documentation while you are writing it, pass --serve:
roc docs --serve main.rocThis generates the documentation as usual and then starts a local HTTP server
(at http://localhost:8080) that serves the generated files until you stop it with Ctrl+C.
Bidirectional controls
Comments (including doc comments) can't contain Unicode bidirectional control characters. These
are invisible characters that change the direction text is displayed in, which means they can make
source code look different from what the compiler actually sees. (This has been used to sneak
malicious code past code review; see CVE-2021-42574.)
If you want to mention one of these characters in a comment, write out its code point, likeU+202E. Ordinary Arabic and Hebrew text is allowed. Strings
explains how to put these characters in a string when you need them at runtime.
Langref Compile Time (`docs/langref/compile-time.md`)
Compile-Time Evaluation
When possible, Roc evaluates expressions at compile time instead of at runtime. Once they have been evaluated, their values are stored in the compiled binary as static constants.
Which Expressions Get Evaluated?
Top-level declarations
Top-level declarations in modules all get evaluated at compile time. For example:
x = 1
y = 2
z = x + y
main! = |_args| {
echo!("z is ${z.to_str()}")
}Here, the top-level declarations are x, y, z, and the main! function. x, y, and main! are all literals (number literals for x and y and a lambda literal for main!), so they evaluate to themselves.
z gets evaluated at compile time to x + y. So if you rewrote this code to have z = 3 and ran the program, it would produce the same output at runtime, with the same performance.
Top-level equivalent expressions
Any expression which could have been a top-level declaration, even if it isn't actually a top-level declaration, also gets evaluated at compile time.
For example, z.to_str() could have been a top-level declaration because it only references a top-level declaration (namely, z). As such, z.to_str() will also get evaluated at compile time to produce the string "3".
The string interpolation expression "z is ${z.to_str()}" also gets evaluated at compile time. It could have been a top-level declaration because it depends only on a pure function (to_str) being called on a top-level value (z).
Putting all this together, the string "z is 3" will end up embedded in the static data section of this program's final binary. The values 1, 2, 3, and "z is " (from the interpolation) will be dead-code eliminated and will not be stored in the binary.
Effectful functions never run at compile time
You may recall that effectful functions may only be called from within other effectful functions. This implies that they can't be called from top-level declarations; the following top-level declaration would give an error:
foo = echo!("hi")This means effectful functions never run at compile time. So in the following expression:
echo!("z is ${z.to_str()}")The entire string gets evaluated at compile time, but the echo! itself only gets evaluated at runtime.
To summarize, here's what happens to each part of that expression:
zgets evaluated at compile time.z.to_str()also gets evaluated at compile time.- The string interpolation also gets evaluated at compile time.
- The call to
echo!does not get evaluated at compile time; it runs at runtime, using the string that was already computed at compile time.
Terminology
Compile-time evaluation is sometimes called comptime, as in "this runs at comptime." In the literature,
it's often called compile-time function execution,
or CTFE for short.
Empty builtin collections never get stored
List.with_capacity is a pure function that returns a list with zero length and nonzero capacity. However, storing that empty list in the static binary would defeat the entire purpose of List.with_capacity! In general, storing empty lists (or empty strings, dictionaries, or sets) in the static binary is pointless at best and counterproductive at worst, so Roc doesn't do it. Empty builtin collections never get stored.
An eligible call like List.with_capacity(123) will still be evaluated at compile time, it's just that nothing will be stored in the static data section of the binary. Instead, the resulting list (no items, length zero, capacity 123) will evaluate at runtime to a call to List.with_capacity(123). A call to List.with_capacity(0) will be equivalent to the empty list literal ([]), and will not result in a function call at runtime.
Note that it's not List.with_capacity itself that's special-cased; rather, it's the empty builtin collections. You can still build nonempty lists at compile time by calling functions which use List.with_capacity; storing it in the binary is only skipped if its length is actually zero at the end of compile-time evaluation.
Code With Errors
Roc keeps doing useful work in a program that has compilation errors (such as type mismatches): the
code that has an error crashes if it runs, and everything else works normally. Compile-time
evaluation follows the same principle, so every expression that can be evaluated at compile time
still is.
If evaluating an expression at compile time reaches code that has a compilation error, no matter how
indirectly it gets there (through a chain of function calls, a method call, a call made by == or a
parser, and so on), Roc stops evaluating that expression and reports nothing beyond the
original error. Using the expression's value at runtime crashes, just as running the code with the
error would. For example:
Format := [Default].{
parse_u8 : Format, {} -> Try({ value : U8, rest : {} }, [Bad])
parse_u8 = |_, _| Err(OtherErr)
}
parsed = (U8.parser_for(Format.Default))({})
fine = 1 + 2parse_u8 has a type mismatch (it can return OtherErr, which its annotation doesn't list), and
the parser that U8.parser_for(Format.Default) builds calls parse_u8. So evaluating parsed
reaches code with an error: the type mismatch is reported once, not a second time as a compile-time
crash, and using parsed at runtime crashes. fine doesn't reach parse_u8, so it gets evaluated
at compile time as usual. So does an expression that calls a function containing an error but never
reaches that part of it, for example because a condition takes the other branch.
The same rule applies to top-level expects: if running one reaches
code with an error, roc test reports it as a compiler error instead of a pass or a failure.
Performance
Doing work at compile time instead of runtime ordinarily makes a program run faster because it does less work.
That said, compile-time evaluation can result in larger binaries, which can negatively impact runtime performance. For example, consider this function:
make_fives = |count| List.repeat(5, count)If you call this at runtime with a large number, it can make a big list at runtime which takes up a lot of memory. If you only do this under rare circumstances, it rarely needs to take up that memory.
In contrast, if you do it at compile time, and the big list ends up in the program's static binary data, then when the binary gets loaded into memory to run the program, the memory is always being taken up even if that code path never gets run.
Values that were computed at compile time are also never freed, and they're never
unique, since the compiled program shares one copy of each of
them. So the first time a program updates one of these values (for example, by appending to a list
that was built at compile time), the update starts by copying it. After that, the copy is unique,
so later updates to it can happen in place as usual.
Uses
Compile-time evaluation makes it practical to do expensive setup work once, while the program is being compiled,
instead of every time the program runs.
Parsers are a good example. A parser built with parser_for
(for example, by calling Json.parser_camel()) is assembled specifically for the type being parsed. When that
parser is a top-level constant, all the work of assembling it happens at compile time, and the compiled program
only contains the finished parser. See Parsers at Compile Time.
Compile-time evaluation also combines well with importing non-Roc files.
For example, this parses a JSON configuration file at compile time:
import "config.json" as config_text : Str
config : Try({ port : U16, host : Str }, [InvalidJson(Str), MissingRequiredField(Str)])
config = Json.parse(config_text)Since config is a top-level constant, the program contains the already-parsed configuration, and neither
reads nor parses config.json at runtime.
Other common uses include precomputing lookup tables, and building data structures (such as
dictionaries) whose contents are known in advance.
Langref Dictionaries And Sets (`docs/langref/dictionaries-and-sets.md`)
Dictionaries and Sets
A Dict (short for "dictionary") is a collection of key-value pairs, where each key
appears at most once. A Set is a collection of values, where each value appears at most once.
Dictionaries
Dict(k, v) is the type of a dictionary with keys of type k and values of type v. For example, a
dictionary that maps people's names to their ages could have the type Dict(Str, U64).
There's no special syntax for dictionaries. Instead, you create them using functions such asDict.empty, Dict.single, and Dict.from_list:
ages = Dict.from_list([("Sam", 30), ("Alex", 25)])Then you can use insert, get, remove,
and so on:
with_jo = ages.insert("Jo", 41)
sam_age = with_jo.get("Sam") ?? 0 # 30Like all values in Roc, dictionaries are immutable, so insert and remove return a new dictionary rather
than modifying the existing one. (If nothing else is referencing the original dictionary, Roc will
update it in place behind the scenes, so this is often
just as fast as mutating it.)
Inserting a key that's already in the dictionary replaces its value.
Looking Up Values
Since a key might not be in the dictionary, Dict.get returns a Try:Ok with the value if the key was found, and Err(KeyNotFound) if it wasn't. The?? operator is a convenient way to provide a default value for a
missing key, as in the example above. To check whether a key is present without getting its value,
use Dict.contains.
To change the value for a key based on its current value (or lack thereof), useDict.update. Here's how you might count how many times each word appears in a list:
count_words : List(Str) -> Dict(Str, U64)
count_words = |words| {
var $counts = Dict.empty()
for word in words {
$counts = $counts.update(word, |existing| match existing {
Ok(count) => Ok(count + 1)
Err(Missing) => Ok(1)
})
}
$counts
}The function passed to update receives Ok(value) if the key is present, or Err(Missing) if it isn't. It returnsOk(new_value) to store a value for the key, or Err(Missing) to remove the key from the dictionary.
Iteration Order
Dictionaries remember the order in which keys were inserted, and iterate over them in that order:
for (name, age) in ages {
echo!("${name} is ${age.to_str()}\n")
}As this example shows, iterating over a dictionary yields (key, value) tuples.
Replacing an existing key's value doesn't change that key's position in the order. However,
removing a key moves the most recently inserted key into the removed key's position, so after
a removal, the order is no longer purely based on when keys were inserted. (This makes removal
fast, because nothing else needs to be moved.)
Sets
Set(a) is the type of a set of values of type a. Like dictionaries, sets are created with functions,
such as Set.empty and Set.from_list:
primes = Set.from_list([2, 3, 5, 7, 7, 7])
primes.len() # 4, because each value appears at most once
primes.contains(5) # TrueSets support operations like union, intersection,
and difference. They follow the same iteration order rules as dictionaries.
Under the hood, a Set(a) is a Dict(a, {}): a dictionary whose keys are the set's values, and whose values
are all the empty record, which takes up no memory.
Keys and Hashing
Dictionaries and sets are hash tables, so their keys must support
two operations:
- Equality, using the
is_eqmethod (which is what==uses) - Hashing, using the
to_hashmethod
Most types have both of these automatically. Builtin types like strings and numbers have them, and
so do records, tuples, tag unions, and lists, as long as all the types
they contain do too. So you can use a record like { x: 0, y: 0 } as a key without doing anything special.
Nominal types can use the compiler's derived implementations
by declaring is_eq : _ and to_hash : _, or they can define their own.
Functions can't be compared for equality, so they can't be dictionary keys or set elements.
Hash tables which use a predictable hashing function can be vulnerable to hash flooding
attacks, where an attacker sends many keys which are known to have the same hash in order to slow down
the program. To prevent this, dictionaries which are built at runtime use a hash seed which is
randomly chosen each time the program runs.
Performance
Memory Layout
A dictionary stores its key-value pairs in a list of (key, value) tuples, in
iteration order. That's why iterating over a dictionary is just as fast as iterating over a list:
it is iterating over a list.
To find keys quickly, the dictionary also has a second list, of buckets. Each bucket is 8
bytes: 4 bytes saying which entry it points to, and 4 bytes containing some bookkeeping along
with a few bits of that entry's key's hash (so that most of the time, a lookup can tell that a bucket isn't the one it's looking for
without having to compare the actual keys). Looking up a key means hashing it, finding its
bucket, and then comparing the key against the entry the bucket points to.
Since a set is a dictionary whose values are all {}, which takes up no memory, a set's
entries list holds only the set's values.
Growing
When a dictionary gets full enough, inserting another key makes it allocate a bigger buckets list
and recompute which bucket each key goes in. This happens less and less often as the dictionary
grows, but if you know how many keys you'll be inserting, you can start withDict.with_capacity and skip all of that.
As with other collections, inserting into or removing from a dictionary that nothing else refers
to updates it in place. If something else does refer to it,
the dictionary gets copied first, so the same advice applies as with lists: when you're building
up a dictionary in a loop, don't hold onto old versions of it.
Dictionaries Built at Compile Time
A dictionary that's built at compile time can't use the random hash seed that
protects against hash flooding, because the seed isn't chosen until the
program runs. So it uses a fixed seed instead. That's fine for looking up keys, since the keys
were all chosen at compile time and can't have been picked by an attacker.
However, the first time you insert a key into one of these dictionaries at runtime, it switches
over to the random seed, which means recomputing the bucket for every key it already has. If a
program inserts into a large compile-time dictionary, it pays that cost once (per copy of the
dictionary).
Langref Errors (`docs/langref/errors.md`)
Error Handling
Roc has two ways for something to go wrong:
- A
Tryis a value that's either a success (Ok) or a failure (Err). Most errors are
represented this way, and the type system makes sure they get handled. - A crash stops the program. This is for situations where there's no reasonable way
to continue, like a bug that has put the program in a state it was never supposed to be in.
Roc doesn't have exceptions. A function's type always tells you how it can fail (other than by
crashing), because the ways it can fail are part of its return type.
`Try`
Try is a nominal tag union with two tags:
Try(ok, err) := [Ok(ok), Err(err)]A function that might fail returns a Try. For example, List.first returns aTry(item, [ListWasEmpty]), since an empty list has no first element:
[1, 2, 3].first() # Ok(1)
[].first() # Err(ListWasEmpty)Since a Try is an ordinary value, you handle it the same way you'd handle any other tag union,
for example with a match:
describe = |list|
match list.first() {
Ok(first) => "It starts with ${first}"
Err(ListWasEmpty) => "It's empty"
}Error Types Are Tag Unions
Notice that the error type above is [ListWasEmpty]: a structural tag union
with one tag. That's the usual way to write error types in Roc, and it works especially well with
the ? operator, because structural tag unions can be combined:
parse_and_double : Str -> Try(U64, [BadNumStr, Overflow])
parse_and_double = |str| {
n = U64.from_str(str)? # can fail with BadNumStr
doubled = n.times_try(2)? # can fail with Overflow
Ok(doubled)
}Here, the two ?s can return different errors, and the function's error type is the union of
both: [BadNumStr, Overflow]. If you leave off the type annotation, the compiler infers that for
you. You don't need to declare an error type that wraps both of them, or convert one into the
other.
And since the error type lists every error that can happen, a match on the result knows exactly
which errors to handle, and gives an exhaustiveness error if
you forget one.
Handling Errors
There are several ways to handle a Try, depending on what you want to do with the error.
Returning Errors Early (with `?`) {#returning-errors-early}
Writing ? after an expression that evaluates to aTry gives you the Ok payload if it's Ok. If it's Err, the function immediately returns thatErr:
x = U64.from_str(a)?This is the right choice when the caller is in a better position to decide what to do about the
error.
If you'd like to change the error before it gets returned (for example, to say which input was
bad), put a function or a tag after the ?, with spaces around it. See? with a Handler:
x = U64.from_str(a) ? |_| InvalidFirst # returns Err(InvalidFirst)
y = U64.from_str(b) ? InvalidSecond # returns Err(InvalidSecond(BadNumStr))Using a Default Value (with `??`) {#using-a-default-value}
?? gives you the Ok payload, or a default value if it's Err:
first = list.first() ?? 0Transforming Errors
The Try module has functions for working with Try values without unwrapping them:
| Function | What it does |
|---|---|
map_ok |
Transforms the Ok payload, leaving Err alone |
map_err |
Transforms the Err payload, leaving Ok alone |
on_err |
Runs a function on the Err payload which returns another Try, for example to try something else |
ok_or |
Gives the Ok payload, or a default value (like ??) |
is_ok, is_err |
Returns whether it's Ok or Err |
Each of these that takes a function also has a version ending in ! (like map_ok!) which takes an
effectful function.
Crashing
The crash statement stops the program, with a message:
if index >= len {
crash "This should never happen: index ${index.to_str()} was past the end."
}What happens after a crash is up to the platform. Some platforms exit the program,
whereas others (such as a web server) might recover by returning an error response and moving on
to the next request.
A few builtin operations crash too, when there's no sensible value they could return:
- Integer overflow, like
255.U8 + 1 - Integer division by zero
- Running out of memory
Each of these has a non-crashing alternative when you want to handle the problem yourself. For
example, a.plus_try(b) returns Err(Overflow) instead of crashing.
When to Crash
Use Try for things that can go wrong even when the program is working correctly: a file that
doesn't exist, user input that isn't a valid number, a network request that times out, and so on.
The person calling your function needs to know these can happen, and Try makes sure they do.
Use crash for things that should be impossible if the program is correct. Returning a Try for
these would force every caller to write code for a situation that can't happen, and there would
be no good way for that code to handle it anyway.
A crash during compile-time evaluation is reported as a compile-time error, so a
crash in code that only depends on compile-time constants never reaches your users.
Performance
A Try is an ordinary tag union. It's stored inline, it never
allocates, and creating one or checking which tag it has is about as cheap as it gets. ? and ??
compile to a single check of the tag, followed by a jump.
So there's no performance reason to avoid Try in favor of crashing. In particular, unlike
throwing an exception in some languages, returning an Err costs the same as returning an Ok.
One thing to watch out for: since a tag union takes up as much space as its largest payload, a
large error payload makes every Try of that type large, including the Oks. If an error payload
is much bigger than the Ok payload, and the function is called a lot, consider putting the
error's details in a Box.
Langref Expressions (`docs/langref/expressions.md`)
Expressions
An expression is something that evaluates to a value.
You can wrap expressions in parentheses without changing what they do. Non-expressions
can't be wrapped in parentheses without causing an error. Some examples:
xis a valid expression. It evaluates to a value.(x)is valid.foo(1)is a valid expression. It evaluates to a value.(foo(1))is valid.1is a valid expression. It's already a value.(1)is valid.import Foois a statement not an expression.(import Foo)is invalid.# Somethingis a comment, not an expression.(# Something)is invalid.package […]is a module header, not an expression.(package […])is invalid.
Another way to think of an expression is that you can always assign it to a name—so, you
can always put it after an = sign.
[Types of Expressions](#types-of-expressions) {#types-of-expressions}
Here are all the different types of expressions in Roc:
- String literals, e.g.
"foo"or"Hello, ${name}!" - Number literals, e.g.
1or2.34or0.123e4 - List literals, e.g.
[1, 2]or[]or["foo"] - Record literals, e.g.
{ x: 1, y: 2 }or{}or{ x, y, ..other_record } - Tag literals, e.g.
FooorFoo(bar) - Tuple literals, e.g.
(a, b, "foo") - Function literals (aka "lambdas"), e.g.
|a, b| a + bor|| c + d - Lookups, e.g.
blahor(blah)or$blahor($blah)orblah!or(blah!) - Calls, e.g.
blah(arg)orfoo.bar(baz) - Operator applications, e.g.
a + bor!x, which desugar to calls - Block expressions, e.g.
{ foo() }
There are no other types of expressions in the language.
[Values](#values) {#values}
A Roc value is a semantically immutable piece of data.
[Value Identity](#value-identity) {#value-identity}
Since values take up memory at runtime, each value has a memory address.
Roc treats memory addresses as behind-the-scenes implementation details that should not affect
program behavior, and by design exposes no language-level way to access or compare addresses.
This implies that Roc has no concept of value identity, reference equality (also known as physical equality), or pointers, all of which are semantic concepts based on memory addresses.
Note that platform authors can choose to implement features based on memory addresses,
since platforms have access to lower-level languages which can naturally see the addresses
of any Roc value the platform receives. This means it's up to a platform author to decide
whether it's a good idea for users of their platform to start needing to think about memory
addresses, when the rest of the language is designed to keep them behind the scenes.
[Reference Counting](#reference-counting) {#reference-counting}
Heap-allocated Roc values are automatically reference-counted.
Heap-allocated values include strings, lists, boxes, and recursive tag unions. Numbers,
records, tuples, and non-recursive tag unions are stored inline (for example, directly on
the stack, or directly inside a list's elements), and so are not reference counted. (A record
can still contain a string, of course, and that string has its own reference count.)
The platform's host may share Roc values between threads, so reference counts on values the
host can see are updated atomically,
for thread-safety. Atomic updates are slower than ordinary ones, so when the compiler can tell
that a value never reaches the host (for example, a list that a function builds up and then
only uses internally), it updates that value's reference count without atomic instructions.
[Reference Cycles](#reference-cycles) {#reference-cycles}
Other languages support reference cycles, which create problems for reference counting systems. Solutions to these problems include runtime tracing garbage collectors for cycles, or a concept of weak references. By design, Roc has no way to express reference cycles, so none of these solutions are necessary.
[Opportunistic Mutation](#opportunistic-mutation) {#opportunistic-mutation}
Roc's compiler does opportunistic mutation using the Perceus "functional-but-in-place" reference counting system. The way this works is:
- Builtin operations on reference-counted values will update them in place when their reference counts are 1
- When their reference counts are greater than 1, they will be shallowly cloned first, and then the clone will be updated and returned.
For example, when List.set is passed a unique list (reference count is 1), then that list will have the given element replaced. When it's given a shared list (reference count is not 1), it will first shallowly clone the list, and then replace the given element in the clone. Either way, the modified list will be returned—regardless of whether the clone or the original was the one modified.
This means that whether an update is fast (changing one element in place) or slow (copying the
whole list first) depends on whether anything else still needs the old value. For example:
updated = list.append(42)
# If `list` is never used after this point, the append happened in place.updated = list.append(42)
List.len(list) # `list` is still used here, so the append had to copy it first.In the second example, the compiler can't change list in place, because the laterList.len(list) call needs to see the original. (See Lists for more on
how this works with lists.) So when you're updating a large
collection in a loop or a recursive function, make sure you aren't holding onto the
old version anywhere.
Note that this applies to values the compiler can't see at compile time. Values that are
known at compile time get evaluated during compilation
and embedded in the compiled program, and they're never freed. If you update one of
those at runtime, the update starts with a copy.
[Block Expressions](#block-expressions) {#block-expressions}
A block expression is an expression with some optional statements
before the expression. It has its own scope, so anything assigned in it can't be accessed
outside the block. The entire block evaluates to the expression at its end.
The statements are optional, so { x } is a valid block expression. This is useful
stylistically in situations like conditional branches:
x = if foo {
…
} else {
fallback
}Note that
{ x, y }is a record with two fields (it's syntax sugar for{ x: x, y: y }),
but{ x }is always a block expression. That's because it's much more useful to have{ x }
be a block expression, for situations likeelse { x }, than syntax sugar for a single-field
record like{ x: x }. Single-field records are much less common than blocks in
conditional branches.
[Evaluation](#evaluation) {#evaluation}
Evaluation is the process of an expression becoming a value.
Like most programming languages, Roc uses strict evaluation and does not support lazy evaluation like some non-strict languages do (such as Haskell).
Expressions that are already values (such as 4, "foo", etc.) evaluate to themselves.
More complex expressions, such as function calls or block expressions
may require multiple steps to evaluate to a value.
[Side Effects during evaluation](#side-effects-during-evaluation) {#side-effects-during-evaluation}
Normally, only evaluating effectful functions can
cause side effects, but evaluating any other type of expression cannot.
One exception to this rule is dbg statements, which perform the side effect of
logging a value for debugging purposes. This is intended to be an interface for debugging, much
like a step debugger, not part of a program's semantics,
and so the side effect is allowed outside effectful functions (just like step debugging works on
any expression, not just calling effectful functions).
The only other exception to the rule is expect statements, which similarly perform the side effect of reporting failed expectations. Like dbg, this output is intended for the programmer only, and is not part of a program's semantics.
Note that platform authors are in charge of what happens when memory gets allocated and
deallocated, and can therefore decide to perform side effects during memory allocation
and deallocation. This can easily cause surprising behavior for Roc application authors,
and should not be relied upon because Roc's optimizer assumes memory allocation and deallocation
has no observable effect on the program (unless allocation fails), which means these side
effects may be optimized away differently between patch releases of the compiler.
[Compile-Time Evaluation](#compile-time-evaluation) {#compile-time-evaluation}
When possible, Roc's compiler will evaluate expressions at compile-time instead of at runtime.
This is only possible for expressions that depend only on values known at compile-time. The
following are not known at compile time:
- Values returned from effectful function calls (which can vary based on runtime state)
- Values provided by the platform
If a crash is encountered during compile-time evaluation,
it will be reported at compile time just like any other compilation error (such as
syntax errors, naming errors, and type mismatches).
If evaluating an expression at compile time reaches code that has a compilation error (for
example, a function whose body has a type mismatch), the original error is reported once, and is
not reported a second time as a compile-time crash. Using that expression's value at runtime
crashes. See Code With Errors.
Note that functions are values! If you define a top-level function by calling other
top-level functions, all of that work will be done at compile time:
make_adder = |amount_to_add| |num| num + amount_to_add
add_one = make_adder(1)
add_two = make_adder(2)
add_three = |num| num + 3Here, the add_one, add_two, and add_three functions will be identical in the compiled
Roc binary (aside from the number being added). The fact that the first two were defined by
calling other functions is irrelevant, as that work happens at build time when add_one andadd_two are being evaluated.
Langref Functions (`docs/langref/functions.md`)
Functions
Functions are first-class values in Roc. They can be passed to other functions, returned,
stored in data structures and so on.
Roc has only one syntax for defining functions, namely the "lambda" syntax:
my_fn = |arg1, arg2| arg1 + arg2Some languages have alternative syntaxes for defining functions. Roc intentionally does not,
for the same reason that it doesn't have alternative syntax for defining any other type of
value (such as numbers or strings): functions are ordinary values, and all values in Roc are
defined using the same name = value syntax.
Functions take a fixed number of arguments. Calling a function always passes all of them at
once, as in my_fn(1, 2), and there's no automatic currying
or partial application. If you want a function that has some of its arguments filled in already,
you can write one with a closure: |arg2| my_fn(1, arg2).
Pure Functions
Pure functions are functions which:
- Always return the same answer when given the same values for arguments
- Have no side effects (side effects involve either performing I/O or mutating state outside the function)
Roc makes a first-class distinction between pure functions and effectful functions (functions
that are not pure), and uses that distinction to enable automatic performance optimizations. A
function is effectful if it calls another effectful function, and otherwise it's pure. Effectful
functions can only be called by other effectful functions; pure functions and top-level
constants can only call pure functions.
A common example of a performance optimization where Roc's compiler makes use of this
distinction: all top-level values are evaluated at compile time. Pure functions always return
the same answers when given the same inputs, and top-level values all have inputs that are
fixed at compile time. That means running the pure functions at compile time can't possibly
give different answers than if they were run at runtime.
Pure functions also have no side effects. That said, pure functions in Roc can run things
that can reasonably be considered side effects:
- Crashing. Roc pure functions can crash (or get stuck in an infinite loop), as they are not guaranteed to be total. If they crash at compile time, it will be reported as a compile error, which is considered an improvement over the alternative of the end user of the program encountering a crash at runtime. If they get stuck in an infinite loop, this currently hangs the compiler (although there are plans to improve this in the future), which is also considered better than the alternative of an end user encountering a program hang at runtime.
- Memory allocation and deallocation. Although memory allocation and deallocation absolutely does depend on mutating state for bookkeeping, Roc does automatic memory management, and so program correctness should never depend on the state of this bookkeeping. Allocation can fail, which in Roc results in a crash—and as previously noted, a crash at compile time is considered preferable to the end user encountering a program crash at runtime.
dbgandexpectoutput. Pure functions are allowed to usedbgandexpect, as these outputs are intended to be for the programmer only. By design, program behavior should never depend on them, so it's considered fine to display these outputs at compile time only (or not at all, if their code paths end up getting optimized away entirely), as this only means the programmer will see them even earlier in the process.
Effectful Functions
An effectful function is a function that may perform side effects when it's called.
For example, echo! is an effectful function which prints a string:
greet! : Str => {}
greet! = |name| echo!("Hello, ${name}!")A function is effectful if it calls another effectful function. Here, greet! is effectful because
it calls echo!. The type of an effectful function uses => instead of -> (see
Function Type Annotations), and its name ends in ! (see! suffix in function names).
Effectful functions can only be called from within other effectful functions. That means
pure functions can't call them, and neither can top-level constants:
message = greet!("Sam") # Error: top-level values can't perform effects
shout : Str -> {}
shout = |name| greet!(name) # Error: this function is annotated as pureThis rule is what makes it possible for Roc to evaluate top-level constants and pure function calls
at compile time, because it guarantees none of them will ever perform effects.
Ultimately, every effectful function traces back to one provided by the platform,
because Roc code can only perform side effects by calling functions that the platform provides.
The program's entry point is also effectful (for example, main!), so that it can call those
functions. When the program runs, the platform calls the entry point, and the entry point calls
other effectful functions as needed.
Side Effects
A side effect is anything a function does other than computing its return value from its
arguments. Common examples include:
- Reading or writing files
- Printing to the terminal
- Sending or receiving network requests
- Reading the current time
- Generating random numbers
- Reading or changing any state that lives outside the function
A function without side effects always returns the same answer when given the same arguments.
That's not true of a function that reads the current time or a random number, and it's not true of
a function whose return value depends on the contents of a file (which could change between calls).
In Roc, those functions are all effectful.
Some things that might seem like side effects are allowed in pure functions, such as crashing,
memory allocation, and dbg; see Pure Functions for details.
Function Type Annotations
Here's a type annotation for a pure function, and then right below it,
an annotation for an effectful function:
pure_fn : Str, Str -> Str
run_fx! : Str, Str => StrEach function takes two Str values as arguments, and returns a Str. Unlike pure_fn, run_fx! may perform side effects when called.
`->` and `=>` in function type annotations
Note that pure_fn uses a -> to indicate that it's a pure function, whereas run_fx!
uses a => instead to indicate that it's an effectful function. That arrow style is the
way you can tell a pure function annotation apart from an effectful one.
By design, Roc has no syntax for "either pure or effectful." That is, there's no concept
of effect polymorphism like you might find in some languages that support algebraic effects.
`!` suffix in function names
By convention, all effectful functions—and only effectful functions—have names that end
in !. This design has two purposes:
- It makes it easy to see at a glance exactly which parts of your code are potentially performing effects.
- It makes it easy to distinguish between higher-order functions like
Try.map_okandTry.map_ok!which differ only in the effectfulness of the functions they accept.
Roc's compiler reports a warning if an effectful function's name does not end in !.
Closures
A function can use names from the scope it was defined in, not just its own arguments:
make_adder : U64 -> (U64 -> U64)
make_adder = |amount| |n| n + amount
add_five = make_adder(5)
answer = add_five(1) # 6Here, the inner function |n| n + amount uses amount, which isn't one of its arguments. It
captures amount from make_adder's scope. A function that captures values this way is
called a closure.
Since values in Roc are immutable, a closure captures the captured value itself, not some
location in memory that might change later. So add_five will always add 5, no matter what
happens after it was created. (See Performance for how closures are stored.)
Purity Inference
Roc infers which functions are pure and which are effectful. You can choose to annotate
functions as pure or effectful, and the compiler will warn you if the annotation is incorrect.
For example, suppose you wrote a function which called an effectful function, and annotated
it as if it were a pure function. You'd get a warning saying this is an effectful function
that's been incorrectly annotated as a pure function, but you could still run the program.
This is a useful feature, as it means you can do things like take a function that has been
historically pure and add some debugging that involves doing I/O in the middle of the function.
You'll get a warning (and potentially miss out on some optimizations) but you won't have to do the chore of going around changing a bunch of annotations just to be able to run the program.
(Note: this is not fully implemented yet. Currently, the compiler reports an incorrect purity annotation as a type mismatch error rather than a warning.)
Note that this still doesn't make it possible to call effectful functions at compile time.
The rule still applies that effectful functions can only be called from within other
effectful functions; if you annotate a function as pure, and the compiler warns you that
the annotation is mistaken, that's because the compiler knows the function is
effectful—and so can't be run at compile time.
One reason for this rule is that all effectful functions originate in the platform,
which provides their implementations using low-level code that has been compiled for a
specific target system. Roc's compiler does not run platform-provided low-level code
during compilation, which means that when you runroc checkorroc build, none of
your dependencies—including platforms—are permitted to perform arbitrary I/O operations
on your system. You have to actually run the compiled Roc program for that.
Recursive Functions
Functions can be recursive, meaning they call themselves.
Self-recursive Functions
Self-recursive functions are functions that call themselves directly.
Here's a contains function for lists of strings that calls itself directly:
contains : List(Str), Str -> Bool
contains = |list, item| match list {
[] => False
[first, .. as rest] => {
if first == item {
True
} else {
contains(rest, item) # recursion
}
}
}By default, recursive functions have an increased risk of stack overflowing compared to
non-recursive functions, although tail-call optimization
can eliminate that risk for some recursive functions. (In doing so, the optimization does
mean a function that recurses forever will loop forever instead of overflowing the stack.)
Tail Calls
A tail call is a function call followed immediately by returning from the current function,
without doing any other work in between.
In the previous example, contains(rest, item) is a tail call. It's calling a function and then
immediately returning without doing any other work. In this case, it's calling itself (making
this a self-tail-call, which is also known as self-tail-recursion), although tail calls
can be to other functions too.
Self-Tail Calls
A self-tail call is a tail call where a function calls itself. For example, here's a
function which computes a factorial:
factorial : U64 -> U64
factorial = |n| if n <= 1 1 else n * factorial(n - 1)The recursive call factorial(n - 1) is not a tail call, because after it returns, there's still
work to do: its result gets multiplied by n. That means each call needs to remember its n until
the recursive call returns, so calling factorial(n) requires n nested calls to be in progress
at the same time, each one using some stack space.
Here's a version that uses a self-tail call instead, by passing along an accumulated result as an
extra argument:
factorial : U64 -> U64
factorial = |n| factorial_help(n, 1)
factorial_help : U64, U64 -> U64
factorial_help = |n, acc| if n <= 1 acc else factorial_help(n - 1, n * acc)Now the recursive call factorial_help(n - 1, n * acc) is the last thing the function does, so it's
a self-tail call. This version is eligible for tail-call optimization,
which means it will run as a loop and can't overflow the stack.
Rewriting a function to pass along an accumulator like this is a common way to turn a recursive
call into a self-tail call.
Tail-Call Optimization
Compilers can optimize tail calls in various ways. Here are some that Roc's compiler performs:
- If a self-recursive function only ever calls itself using tail calls, the entire function will be optimized into a
whileloop behind the scenes, and all the recursive calls will be eliminated. This optimization makes the function run faster, and makes it impossible for the function to stack overflow (although it can now loop forever), and otherwise will not affect observable program behavior. - Tail calls modulo cons get the same treatment, as described in the next section.
- When a tail call is to a different function, the function being called takes over the
current function's stack space instead of using more. This is guaranteed, and it includes
calling a function value (like a function that was passed in as an argument). So a group of
functions that only ever call each other using tail calls can't overflow the stack either. See
Mutually Recursive Functions.
One visible consequence of that last one is that a function which has made a tail call is no
longer on the stack, so it won't show up in a stack trace taken later on.
Modulo Cons
Some recursive calls aren't tail calls, but are almost tail calls: the only thing that happens
to the result of the recursive call is that it gets wrapped in a tag, which is then returned.
This is sometimes described as being a tail call modulo cons, where "cons" refers to constructing
a value. Here's an example:
LinkedList(a) := [Nil, Cons(a, LinkedList(a))]
count_up : U64, U64 -> LinkedList(U64)
count_up = |current, end| {
if current >= end {
Nil
} else {
Cons(current, count_up(current + 1, end))
}
}The recursive call count_up(current + 1, end) is not a tail call, because after it returns, its
result gets put inside a Cons tag. However, Roc's compiler performs an optimization called
tail recursion modulo cons: when a self-recursive function's result is a tag of its own
(recursive) return type, and a recursive call's result goes directly into that tag, the compiler
allocates the tag first, and then has the recursive call write its result directly into the tag's
payload. This way, nothing is left to do after the recursive call, so it becomes a loop just like
a self-tail call would.
This means that count_up(0, 1_000_000) builds a linked list with a million elements without
overflowing the stack. It also means you don't need to rewrite functions like this one using an
accumulator in order to get this benefit.
Mutually Recursive Functions
Mutually recursive functions are functions that call each other. If one function calls
another, and that function calls the first one, then the first function did end up calling
itself (so, recursing)—just with the other function being involved in the middle.
is_even : U64 -> Bool
is_even = |n| if n == 0 True else is_odd(n - 1)
is_odd : U64 -> Bool
is_odd = |n| if n == 0 False else is_even(n - 1)Both is_odd(n - 1) and is_even(n - 1) are tail calls, so callingis_even(10_000_000) goes back and forth between the two functions ten million times without
overflowing the stack. Each call takes over the stack space of the one that called it, so the
stack never grows.
Performance
Calls
Calling a function whose name you wrote directly, like contains(rest, item), compiles to an
ordinary direct call. The compiler can also inline
small functions, meaning it replaces the call with the function's body, so that there's no
call at all.
Calling a generalized function costs the same as calling any other
function, because each type it's used with gets its own
monomorphized version.
Arguments and Reference Counts
When a function only reads an argument (rather than, say, returning it or storing it in a
data structure), passing that argument doesn't change its
reference count. The compiler figures this out
automatically, by looking at what each function does with each argument.
This is also what makes opportunistic mutation
work well across function calls. If you pass a list to a function that only reads it, the
list is still unique afterward, so you can keep updating it in place.
Function Values
When you pass a function as an argument, return one, or store one in a record, the
compiler knows every function that could possibly end up there. So rather than storing a
pointer to a function's machine code, it stores the function value like a
tag union, with one tag for each function that could be there. Each tag's
payload holds the values that function captured.
For example, in this code, the value returned by pick is either double or a closure that
captured an amount:
double = |n| n * 2
pick : Bool -> (U64 -> U64)
pick = |should_double| if should_double double else make_adder(10)So pick's return value is stored as if it were a tag union like[Double, Adder(U64)], and calling it checks the tag and then directly calls the right
function. This has some performance benefits:
- Function values don't need a heap allocation, even when they capture values. They take up
as much space as the largest set of captured values (plus a tag, if there's more than one
possible function). - A function value that's only ever one specific function takes up no space at all if it
captures nothing, and calling it is exactly as fast as calling that function directly. - Since every possible call target is known, the compiler can inline them.
This technique is called defunctionalization.
The exception is functions that cross the boundary between Roc and the platform,
since the platform is compiled separately and the compiler can't see what it does with them.
Those are stored as a heap allocation that holds a pointer to the function along with its
captured values.
Note that with
roc build --specialize=no, all function values are stored this way
instead. See the types performance section for more on that option.
Langref If Else (`docs/langref/if-else.md`)
`if` / `else`
Like most languages, Roc has if and else keywords that determine which code to run based on a boolean's value at runtime.
`if` and `else`
Roc's if keyword works exactly like a match on a boolean. This code:
if foo {
bar()
} else {
baz()
}Does the exact same thing as this code:
match foo {
True => bar()
False => baz()
}Roc does not have a concept of "truthiness" (where values can be "truthy" or "falsy"); if accepts only Bool values.
`Bool`
Bool is a nominal tag union with two tags:
Bool := [False, True]Since True and False are tags, you can pattern match on them like any other
tags, as in the match above. And since a tag union with two tags and no payloads only needs a
1-byte discriminant, a Bool takes up 1 byte of memory. (So aList(Bool) with a million elements takes up a megabyte; Roc doesn't pack booleans into individual
bits.)
`else if`
Roc does not have a separate else if concept like some languages do, but you can write else followed immediately by if to achieve that functionality. This code...
if foo {
bar()
} else {
if something {
baz()
} else {
blah()
}
}...does exactly the same as this code, without the extra curly braces:
if foo {
bar()
} else if something {
baz()
} else {
blah()
}`if` without `else`
You can write an if without an else, but only when the body of the if evaluates to {}. For example, this is allowed:
if foo {
do_something!()
}It is essentially equivalent to:
{} = if foo {
do_something!()
} else {
{}
}...except that you don't have to write the {} = or the else. The rules are the same, though; the body of the if must evaluate to {}, which in practice only makes sense for effectful functions, or statements like return, crash, or expect.
`and` / `or`
The keywords and and or perform short-circuiting evaluation. This code:
a() or b()...does exactly the same thing as:
if a() True else b()Similarly:
a() and b()...does exactly the same thing as:
if a() b() else FalseNote that Roc does not have && or || operators for boolean logic like some languages do. This is mostly because Roc uses | to define functions, and || means a function with no arguments. Using the keyword or avoids overloading || to mean a function sometimes and a conditional other times.
Langref Iterators (`docs/langref/iterators.md`)
Iterators
An iterator produces a sequence of values, one at a time. The builtin Iter type is Roc's
iterator type: an Iter(item) produces values of type item.
Iterators are what for loops use to go through collections. Many types have an iter
method which returns an iterator over their contents. For example, List.iter returns an
iterator over a list's elements, and Dict.iter returns an iterator over a dictionary's (key, value) pairs.
Ranges have one too:
total = (1..=100).iter().sum() # 5050Transforming Iterators
Iterators can be transformed using methods like map, keep_if,drop_if, with_index, take_first,
and drop_first. Each of these returns a new iterator:
evens_times_ten : List(U64)
evens_times_ten = [1, 2, 3, 4, 5, 6]
.iter()
.keep_if(|n| n % 2 == 0)
.map(|n| n * 10)
.collect() # [20, 40, 60]Iterators can also be combined into a single value, using methods like fold,sum, min, and max.
Collecting
collect gathers all of an iterator's items into a collection. The type of
collection is determined by type inference; it works for any type with a from_iter method, such asList, Set, and Dict:
as_list : List(U64)
as_list = [3, 1, 3].iter().collect() # [3, 1, 3]
as_set : Set(U64)
as_set = [3, 1, 3].iter().collect() # Set.from_list([3, 1])Alternatively, you can name the collection type explicitly by calling its from_iter function,
as in List.from_iter(iterator).
Laziness
Iterators are lazy, meaning they don't do any work until something asks them for their next item.
For example, in this code, the function passed to map only runs twice, because take_first(2)
only asks for two items:
first_two : List(U64)
first_two = (0..<1_000_000)
.iter()
.map(|n| n * 2)
.take_first(2)
.collect() # [0, 2]This also means that chaining several transformations together doesn't create a new collection for
each step. Instead, each item goes through the whole chain before the next item is produced, and only
the final collect creates a collection.
Since they're lazy, iterators can even represent sequences that never end, as long as whatever uses
them eventually stops asking for more items (for example, using take_first).
Custom Iterators
Iter.custom creates an iterator from a starting state and a function that
advances it:
powers_of_two : Iter(U64)
powers_of_two = Iter.custom(1, Unknown, |n| Ok((n, n * 2)))
first_five : List(U64)
first_five = powers_of_two.take_first(5).collect() # [1, 2, 4, 8, 16]Iter.custom takes three arguments:
- The initial state. Here, it's
1. - The number of items the iterator will produce, if it's known in advance:
Known(count)orUnknown. Collections use this to allocate the right amount of memory up front. - A function that takes the current state and returns either
Ok((item, next_state))to produce an item and continue with a new state, orErr(NoMore)to end the iteration.
Here's an iterator that counts down to 1, and then ends:
countdown : U64 -> Iter(U64)
countdown = |start| Iter.custom(start, Known(start), |n| {
if n == 0 {
Err(NoMore)
} else {
Ok((n, n - 1))
}
})To make your own type work with for loops, give it an iter method which returns an Iter;
see Iteration for an example.
How Iteration Works
Under the hood, going through an iterator means repeatedly calling its next method, which returns one of:
One({ item, rest }), meaning the iterator produceditem, andrestis an iterator for the remaining itemsSkip({ rest }), meaning the iterator didn't produce an item this time (for example, becausekeep_iffiltered one out), but there may be more items inrestDone, meaning there are no more items
A for loop does this automatically, but you can also call next directly:
match iterator.next() {
One({ item, rest }) => …
Skip({ rest }) => …
Done => …
}Iterators themselves are values, so calling next doesn't change the iterator you called it on. Instead,
it gives you rest, which you use to continue the iteration.
Effectful Iteration
Iter is for pure computations, so its functions (like the one passed to map) can't be
effectful. For sequences that involve effects, there's a separateStream type, whose functions can be effectful. Iter.stream turns an
iterator into a stream, and Stream.custom builds one from a source whose items
come from effects.
Like iterators, streams are lazy. map, keep_if,drop_if, with_index,take_first, and drop_first each return a new stream
without pulling any items, and the functions passed to them can be effectful.
A stream's items are pulled when something consumes it: a for! loop,fold!, for_each!, or collect!.
Pulling runs effects, so all of these can only be used in effectful functions:
print_long_lines! : Stream(Str) => {}
print_long_lines! = |lines| {
for! line in lines.keep_if(|line| line.count_utf8_bytes() > 80) {
Stdout.line!(line)
}
}Loops that perform effects on each item don't need a Stream, though; a for loop can call effectful
functions on each item of an ordinary iterator.
Performance
Even though iterators are values, and a chain like .keep_if(…).map(…) looks like it builds one
iterator on top of another, none of those iterators exist at runtime. The compiler knows at
compile time exactly which operations are in the chain, so it combines the whole chain into a
single loop, the same way a for loop over a list or range compiles to a
plain loop. For example, this:
total = [1, 2, 3, 4, 5, 6]
.iter()
.keep_if(|n| n % 2 == 0)
.map(|n| n * 10)
.sum()compiles to a loop that goes through the list once, checking each element, multiplying the ones
that pass, and adding them to a running total. No iterator gets allocated, and the functions
passed to keep_if and map can be inlined into the loop body.
This works for chains of up to 16 operations, including when you pass an iterator to a function
or return one from a function. It doesn't work when the number of operations in the chain
depends on something that's only known at runtime. For example, a recursive function that callsmap once per recursive call might build a chain with any number of maps in it. Iterators like
that (and chains longer than 16 operations) are stored on the heap instead, and getting each item
involves an indirect function call per operation in the chain. They still work; they're just
slower.
Collecting
When you collect an iterator into a list, the number of items the iterator says
it will produce (the Known(count) or Unknown that Iter.custom takes)
determines how the list gets allocated. If it's Known, the list's memory gets allocated once,
at exactly the right size. If it's Unknown (for example, after a keep_if, since nobody knows
in advance how many items will be kept), the list starts small and grows as items are added.
So when you write a custom iterator, it's worth passing Known(count) whenever you know the count.
Just make sure the count is exact, because collecting relies on the iterator producing exactly
that many items.
Langref Lists (`docs/langref/lists.md`)
Lists
A List is a sequence of values, all of the same type, stored next to each other in
memory. For example, [1, 2, 3] is a list of three numbers. List(Str) is the type of a list of
strings, List(List(U8)) is the type of a list of lists of bytes, and so on.
Lists are Roc's most common collection. Unlike in some other functional languages, a Roc list is
not a linked list. It's an
array, more like a Vec in Rust or anArrayList in Java, which means you can get any element by its index without going through the
elements before it.
List Literals
A list literal is a list of comma-separated expressions between [ and ]:
numbers = [1, 2, 3]
names = ["Sam", "Alex"]
empty = []Every element has to have the same type. If you want a list that holds a few different kinds of
values, you can make it a list of tags:
shapes = [Circle(1.5), Rectangle(2, 3)]Using Lists
Lists have lots of functions in the List module. Since they're
methods, you can call them with .:
[1, 2, 3].len() # 3
[1, 2, 3].append(4) # [1, 2, 3, 4]
[1, 2, 3].map(|n| n * 2) # [2, 4, 6]
[1, 2, 3].drop_first(1) # [2, 3]
[1, 2].concat([3, 4]) # [1, 2, 3, 4]Like all values in Roc, lists are immutable, so functions like append return a new list rather
than changing the one they were given. (Behind the scenes, they often do change the original
list; see Performance.)
Indexes
List indexes are U64 numbers, starting at 0. Since an index might be past the end of the list,
functions that take an index return a Try:
[1, 2, 3].get(1) # Ok(2)
[1, 2, 3].get(5) # Err(OutOfBounds)
[1, 2, 3].set(1, 9) # Ok([1, 9, 3])So there's no such thing as an "index out of bounds" crash in Roc; you always get an Err you can
handle (or pass along with ?).
Iterating
You can go through a list's elements with a for loop:
var $total = 0
for n in [1, 2, 3] {
$total = $total + n
}Lists also have an iter method, which returns an iterator over the
list's elements. That's useful for chaining several transformations together without building a
new list for each step.
Pattern Matching
You can pattern match on lists, to check how many elements they
have and what those elements are:
describe = |list|
match list {
[] => "empty"
[only] => "just ${only}"
[first, .. as rest] => "${first} and ${rest.len().to_str()} more"
}Performance
Memory Layout
A List value is 24 bytes on a 64-bit target (12 bytes on a 32-bit target), made up of three
pointer-sized pieces: a pointer to the elements, the number of elements (the list's length), and
how many elements there's room for before the list needs more memory (its capacity).
The elements themselves are stored next to each other in a heap allocation, along with a
reference count. Each element takes up exactly as much space as
its type does; for example, a List(U8) with a million elements takes up a megabyte (plus a few
bytes for the reference count), and a List({ x : F64, y : F64 }) with a million elements takes up
16 megabytes. Storing elements next to each other like this makes going through a list in order
very fast, because CPUs are good at reading memory in order.
An element that has heap-allocated parts, like a Str, stores its fixed-size part in the list,
and its heap-allocated part separately. So a List(Str) stores one 24-byte Str per element,
each of which points to its own allocation (unless the string is
small enough to fit in the 24 bytes).
An empty list doesn't allocate anything. Neither does a list whose elements take up no space at
all, like a List({}).
Updating in Place
Lists use opportunistic mutation: when nothing else refers
to a list, functions like set, append, concat, sort, and map change it in place, rather
than making a new list. For example:
squares = |count| {
var $list = List.with_capacity(count)
for n in 0..<count {
$list = $list.append(n * n)
}
$list
}Here, $list is the only reference to the list, so each append writes the new element into the
existing memory. That's exactly what you'd do with a growable array in a language with mutation,
and it performs the same way.
When something else does refer to the list, the function copies the list first, and then changes
the copy. That copy takes time proportional to the length of the list, so the most important thing
to know about list performance in Roc is: if you're updating a list many times (for example, in a
loop), make sure nothing else is still holding onto the old version.
map can reuse the original list's memory too, as long as nothing else refers to the list, and the
new elements take up the same amount of space as the old ones. So list.map(|n| n * 2) on a uniqueList(U64) doesn't allocate anything.
Growing
When append (or concat, and so on) needs more room than the list's capacity, it allocates a
bigger block of memory and moves the elements there. To avoid doing that on every append, it
allocates extra room each time: an empty list grows to at least 64 bytes' worth of elements, and
after that, each time the list grows, its capacity goes up by 1.5 to 2 times. That means appendingn elements one at a time only grows the list about log(n) times.
If you know how many elements a list will end up with, you can skip all of that by starting withList.with_capacity, or by calling List.reserve on an
existing list. If a list ends up with a lot more capacity than it needs, and it's going to stick
around for a while, List.release_excess_capacity gives the
extra memory back.
Adding to the end of a list is fast, but adding to the front withprepend has to move every element over by one, so it takes time proportional
to the list's length. If you need to build a list in reverse order, it's usually faster to append
everything and then reverse the list once at the end (or iterate over it backwards withiter_rev).
Slices
Functions that return part of a list, like drop_first,take_first, and sublist, don't copy any elements.
Instead, they return a slice: a list that points into the middle of the original list's memory.
So they take the same amount of time no matter how long the list is. The same is true of naming
the rest of a list in a pattern, like [first, .. as rest].
The tradeoff is that a slice keeps the whole original list's memory alive. So if you take a small
slice of a huge list, and then keep the slice around for a long time after you're done with the
rest of the list, all of the huge list's memory stays allocated too.
When a function like drop_last takes elements off the end of a unique list, it doesn't need a
slice at all; it just makes the list's length shorter, in place.
Lists of Lists
In a List(List(U8)), each inner list is a separate heap allocation, with its own reference count.
The outer list's memory only holds the 24-byte List values that point to them. So a list of a
million small lists means a million and one allocations.
When all the inner lists are the same length (for example, the rows of a grid), it's often faster
to use one flat list, and compute each element's index yourself:
get_cell = |grid, width, x, y| grid.get(y * width + x)Lists Built at Compile Time
A list literal (or any other list) that's evaluated at compile time gets embedded
in the compiled program, and using it at runtime doesn't allocate anything. These lists are never
unique, though, so the first time the program changes one of them, it gets copied. After that,
the copy is unique, and later changes to it happen in place.
Langref Loops (`docs/langref/loops.md`)
Loops
Loops let you run the same code multiple times, in...well, in a loop.
`for` Loops
A for loop lets you run code on each item in an iterator. For example:
var $sum = 0
for n in 1..<5 {
$sum = $sum + n
}Here, 1..<5 is a range, which describes the numbers from 1 up to (but not including) 5. Writing 1..=5 instead would include the 5.
A loop body only includes statements; it does not have a final expression. The loop itself evaluates to {}.
That includes the last line of the body. If a line in a loop body produces a
value that nothing uses, like f() where f returns Bool, that's a compile-time
error, just as it would be anywhere else in a block. (If you don't need the value,
you can ignore it with _ = f().)
Iterating over types that have `iter`
for can also be used on types that have aniter method, as long as that method returns anIter. The loop then calls next on the returned iterator.
For example, List has List.iter, so you can do a for
loop over a list:
var $sum = 0
for n in [1, 2, 3, 4] {
$sum = $sum + n
}This [1, 2, 3, 4] code snippet works the same way as the earlier 1..<5 one. The range stores its bounds and produces an iterator when the loop calls its iter method; the list's iter method also produces an iterator over the same values. The loop then repeatedly calls next on that Iter.
Looping backwards
To visit a list's items from last to first, use List.iter_rev instead ofiter:
var $visited = []
for n in [1, 2, 3, 4].iter_rev() {
$visited = $visited.append(n)
}
# $visited is now [4, 3, 2, 1]This reads the list backwards in place. Unlike List.rev, it does not build a
reversed copy of the list first. Dictionaries and sets also provide iter_rev
to traverse their current iteration order backwards. To reverse values from
another iterator source, first collect them with List.from_iter, then calliter_rev on that list.
Pattern matching in `for`
Whatever you put between for and in is treated as a pattern, meaning (for example) that the item can be destructured inline:
var $total = 0
for (x, y) in [(1, 2), (3, 4), (5, 6)] {
$total = $total + x + y
}As usual, you can nest patterns as much as you like, and can use _ if you don't want to name a pattern:
var $count = 0
for _ in items {
$count = $count + 1
}Just like with assignments, the pattern you use here must be exhaustive. For example, the following would give an exhaustiveness error because the loop body couldn't know what value to use for amount_to_add if the item was ever Err at runtime:
var $count = 0
for Ok(amount_to_add) in items {
$count = $count + amount_to_add
}If you can't write an exhaustive pattern-match, you can name the entire iterator item and then use match on it inside the loop body.
(Note: the dedicated exhaustiveness error is not implemented yet for for patterns, even though it is for assignments. Currently, a non-exhaustive tag pattern like this one is reported as a type mismatch instead, and non-exhaustive patterns that the type checker can't rule out—such as a number literal pattern—are not caught at compile time and crash at runtime.)
Looping over Streams with `for!`
A for! loop goes through a Stream: a sequence whose items
come from running effects, like reading lines from a file. It works like a for loop,
except that it gets each item by calling the stream's effectful next! method:
print_lines! : Stream(Str) => {}
print_lines! = |lines| {
for! line in lines {
Stdout.line!(line)
}
}Getting each item runs an effect, so a for! loop can only be used in an
effectful function, just like calling a function whose name ends in !.
for! calls the stream method on the value it loops over, so it works on any Stream, and also
on any Iter (through Iter.stream). Everything else about it is the same as a for loop:
the pattern between for! and in must be exhaustive, and break and return work the same way.
A plain for loop can't go through a Stream, because a for loop only gets its items
from pure iterators. (A plain for loop can still call effectful functions in its body, though.)
`while` Loops
A while loop repeatedly executes its body while a condition is true:
var $i = 0
var $sum = 0
while $i < 5 {
$sum = $sum + $i
$i = $i + 1
}The condition must evaluate to a boolean value.
`break` Statement
break exits the innermost loop immediately:
var $sum = 0
for i in [1, 2, 3, 4, 5] {
if i == 4 {
break
}
$sum = $sum + i
}
# $sum is 6 (1 + 2 + 3, loop exits before 4)In nested loops, break only exits the innermost loop:
var $result = 0
for i in [1, 2, 3] {
for j in [10, 20, 30] {
if j == 20 {
break # only exits inner loop
}
$result = $result + j
}
}Loops are typically used for variable reassignment or for calling effectful functions.
Infinite Loops
A while loop whose condition is True will keep running until something inside it
exits the loop, such as a break or a return:
var $n = 1
while True {
$n = $n * 2
if $n > 100 {
break
}
}
# $n is now 128This is useful when the condition for exiting the loop is easiest to check in the middle of
the loop body, rather than at the beginning.
If nothing ever exits the loop, it will run forever. For example, a server might have a loop
that waits for a request, handles it, and then goes back to waiting for the next request.
A loop that runs forever during compile-time evaluation will currently hang the compiler, as
noted in Pure Functions.
Performance
A for loop over a list or a range compiles to the same kind of loop you'd write by hand in C:
a counter that goes up by one each time. No iterator gets allocated on the heap, and there's no
function call per item. This is also true when the loop goes over a chain of
iterator operations, like for n in list.iter().map(double).keep_if(is_big). The
compiler combines the whole chain into one loop, which does the mapping and filtering for each
item as it goes, without building any intermediate lists.
while loops compile to ordinary loops too, and since variables are
just names for values, reassigning them in a loop doesn't allocate anything by itself.
Building up a list in a loop is fast when the list is unique, because each append can add to the
list in place:
squares = |count| {
var $list = List.with_capacity(count)
for n in 0..<count {
$list = $list.append(n * n)
}
$list
}Since $list holds the only reference to the list, each append just writes the new element
into the list's existing memory. When the list runs out of room, append gets more memory (with
room to spare, so this doesn't happen on every append). Starting withList.with_capacity means it never runs out of room in this loop, since
the loop knows how many elements it'll add.
Langref Modules (`docs/langref/modules.md`)
Modules
Every .roc file is a module. Modules have two purposes:
- Namespacing
- Hiding
Roc has several different categories of modules, and they each hide different things:
- Type modules expose a single type, including all its associated items (methods, nested types, etc.) and hide implementation details such as private helper functions called by that type's methods.
- Package modules expose one or more type modules and hide private modules that are only used behind the scenes.
- Application modules expose the entrypoints (e.g.
main) required by the platform, and hide the implementation details which go into building those entrypoints. - Platform modules expose the type modules that application authors can import from the platform, and hide the configuration it uses to communicate with its lower-level host implementation.
Type Modules
Type modules are specified by a .roc file with a capitalized name, such as Url.roc.
The file must contain a top-level nominal type
(defined with :=, or optionally with :: to make it opaque) whose name is
the same as the filename without the .roc extension. Note that type aliases
(defined with :) don't satisfy this requirement.
So for example, if a type module has a filename of Url.roc, then it must have
something like Url := or Url :: defined at the top level. We call that "the module's type."
For the Url.roc module, we'd say that its type is Url.
Hiding implementation details
While modules can import the Url type from Url.roc, they can't see anything else
defined in the top level of Url.roc. So if Url defines a separate top-level nominal
type of Foo := then that Foo type will only be visible inside Url.roc. Other
modules won't be able to access it. Similarly, if it defines a function or constant
named blah = at the top level, other modules won't be able to see that either.
The way to expose other nominal types, functions, and constants is to make them be
associated items on the Url type itself. For example:
Url :: { self : Str }.{
# Other modules can access as Url.ParseErr
ParseErr := …
# Other modules can access as Url.from_str
from_str : …
}In this example, since Url.ParseErr is itself a type, you can nest other types inside it
to get something like Url.ParseErr.Foo.blah. The nesting can go as deep as you like, and
other modules can flatten out the nesting using import with the as keyword.
Alias modules
If you want to make these nested modules easier to import, you can make an "alias module" whose
type is an alias of another type. For example, you could make ParseErr.roc and have its
type be a type alias of Url.ParseErr like so:
# ParseErr.roc
import Url
ParseErr : Url.ParseErrNow you could import ParseErr directly. This isn't commonly done for things like error types,
though, because having it qualified as Url.ParseErr is useful; it tells you that it's
specifically a URL parsing error, which is more informative than a generic name like ParseErr.
Alias modules are more useful when exporting mutually recursive types.
(Note: alias modules have not been implemented yet. Currently, the compiler requires a type
module's type to be nominal, and reports an error if it's a type alias.)
"Void" modules
Although it is most common to organize modules around a single type, sometimes you just
want a collection of functions or constants. A classic example of this would be something likeUtil.roc, which is a pattern that can be found in countless programming languages.
This is easy to do in Roc: expose a type which has no data inside it.
# Util.roc
Util :: [].{
public_utility_function : …
another_public_function : …
}
private_helper_fn : …This is known as a void module because it exposes an opaque void type (namely, [], which is
the empty tag union type; the empty tag union type is known as "void" for short).
Util is opaque, which prevents other modules from instantiating it,
and its backing type is [], which means it can't even be instantiated inside Util.roc
itself. Choosing [] over {} for the backing type makes it clear that the Util type's
purpose is just to be a namespace, not to be a value that ever gets passed anywhere.
Design Notes on Type Modules
Roc's "type modules" design is informed by the experiences of using modules in Elm and Rust.
Elm
In Elm, modules are formally decoupled from types, but there's a strong cultural norm for
modules to be built around one central type—and for the module's filename to be that type's
name followed by the .elm extension.
For example, Url.elm defines the Url type
and lists that type among the module's publicly exposed items, along with the public functions which
operate on Url values. Private helpers are left out of the module's exposing list, and consumers
of this module typically write import Url exposing (Url) to bring the Url type into scope.
In Elm, when you call a function like Url.parse, the capitalized Url refers to the
module Url, not the type. But in a type annotation, like Url -> Bool, the capitalizedUrl refers to the type Url that was imported from the Url module via exposing (Url).
If Url.elm exposes a ParseError type, you might refer to it as Url.ParseError in type
annotations, where Url is the module name and ParseError is the type.
Similarly, if you have a module like Util.elm, you still capitalize it, and still use Util.foo
to call a foo function it exposes, but you don't have to define a Util type like you do in Roc.
Mutually recursive types (discussed below) work similarly in Elm to how
they work in Roc; you'd define FooBar.elm which exposes the mutually recursive types Foo and Bar,
and then import them using something like import FooBar exposing (Foo, Bar).
Comparing Roc and Elm, the Util case is nicer in Elm (you don't need the void Util type),
and it's more obvious how to organize mutually recursive types (in Elm, you're already doingimport ____ exposing ____ as a matter of course).
Roc optimizes for the common case at the expense of these less-common ones. You canimport Url instead of import Url exposing (Url), you don't need to list the type(s)
and/or function(s) that Url.roc exposes (it's always just the type Url based on the
filename, and only that type's associated items are exposed), and both Url.foo andUrl -> Bool refer to the type Url. Similarly, Url.ParseErr refers to a ParseErr
type associated with a Url type.
Rust
In Rust, it would be common to give the module a lowercase filename and then import the Url type
with use crate::url::Url;. Inside the .rs file (likely named either mod.rs or url.rs),
you'd find a definition of the Url type, along with impl Url { … } where its associated items
would be found. When you call a function like Url::parse in Rust, the Url is referring to
the type, because the url module is commonly lowercase in Rust.
Rust does not share Elm's strong cultural norm of organizing a module around a particular type.
This does happen, such as in the standard library's string module
being organized around the String type,
but you also see examples like the ffi module which
exposes the types CStr,CString,OsStr,OsString, and doesn't particularly focus on any one of them.
The documentation for Rust's ffi::NulError states:
While Rust strings may contain nul bytes in the middle, C strings can't, as that byte would
effectively truncate the string.This error is created by the new method on
CString.
Both ffi::IntoStringError andffi::FromVecWithNulError
are likewise used only in CString methods. Because these errors are specific to CString, Roc's convention
would be to nest them under the CString type. Something like:
# CString.roc
CString :: { … }.{
NulError :: …
IntoStringError :: …
FromVecWithNulError :: …
}Similarly, ffi::FromBytesWithNulError is only
used by a CStr method, so in Roc it would typically be nested under the CStr type.
Unlike Roc, Rust has a concept of private methods. This is not strictly necessary, as any Rust programmer could use
the same technique Roc embraces—namely, putting private helper functions at the top level, which is where they go in Elm too.
Rust already has a pub modifier, but Roc would have to introduce some equivalent just for allowing private methods as
a stylistic alternative to top-level helper functions (which would work the same way semantically), and it would necessarily
make Roc code more verbose.
The pitch of "increase complexity and verbosity to enable an alternative way to express something you can already express"
was not strong enough to justify adding private methods to Roc.
Roc
Both Elm and Roc do module-level caching, and disallow cyclic imports as a natural consequence.
Rust allows cyclic module imports because it caches at the package ("crate" in Rust parlance)
level rather than the module level. (As a similar consequence, Rust disallows packages from
cyclically depending on one another, as do Elm and Roc.) Cyclic module imports can be
convenient in Rust, but Rust's lack of module-level caching is a significant contributing
factor to Elm and Roc generally being known for much faster build times than Rust.
`import` Statements
Roc's import statement brings a type into scope from a type module:
import Color
import json.Parser
import pf.StdoutImport statements can only appear at the top level of a module, and they can only import types.
They can't be used with any other category of module besides type modules.
Exposing
Adding exposing to an import brings specific items into scope, so you can use them without
writing the type's name in front:
import pkg.Json exposing [to_str, decode]
import Http exposing [Request, Response]Now to_str and decode can be called directly instead of pkg.Json.to_str and pkg.Json.decode. Types like Request and Response can be used in annotations without the Http. prefix.
Renaming imported modules with `as`
Adding as to an import gives the imported type a different name in this module:
import Color as CC
import json.Parser as JPModules in subdirectories
To import a module that's in a subdirectory, separate the directory names with /:
import Src/Widget as Widget
import Internal/Http/Client exposing [send]These load Src/Widget.roc and Internal/Http/Client.roc, relative to the directory of the file
doing the importing. You can also start the path with ./ (which means the same thing), ../ to go
up a directory, or / to start at the package's root directory:
import Helper
import ./Internal/Parser
import ../Shared/Codec
import /Public/ApiNote the difference between / and . here. A / means "look in this directory," whereas a .
means "the type nested inside this one." So import Url/ParseErr loads the file Url/ParseErr.roc,
whereas import Url.ParseErr loads Url.roc and imports the ParseErr type nested inside Url.
A package can expose a module that's in a subdirectory by importing it with as and listing that
name in the package header:
package [Widget] {}
import Src/Widget as WidgetModules outside the package then import it as Widget (for example, import ui.Widget). They
never see the Src/ part, so the package can move the file around without breaking anyone.
When importing from another package, write the package's shorthand, then., then the module's name. Any more .s after that refer to nested types:
import json.Parser
import json.Parser.ParseErr as PEImporting types from packages
Packages contain a collection of modules that are imported by applications, platforms or packages. Package dependencies are specified in the module header:
app [main!] { pf: platform "https://...", json: "https://..." }This defines two package aliases: pf for the platform package and json for a JSON package. Use these aliases as prefixes when importing types from those packages:
import pf.Stdout
import json.ParserImporting constants
Since import only imports types, constants (and functions) are imported by way of the type they're
associated with. For example, given this type module:
# Color.roc
Color := [Red, Green, Blue].{
all : List(Color)
all = [Red, Green, Blue]
to_str : Color -> Str
to_str = |color| match color {
Red => "red"
Green => "green"
Blue => "blue"
}
}...another module can import Color and then refer to Color.all and Color.to_str. To use them without
the Color. prefix, use exposing:
import Color exposing [all, to_str]
names = all.map(to_str)If you have some constants that don't naturally belong to any particular type, you can associate them
with a void module's type instead.
Importing mutually recursive types
Occasionally, you may want to define two types in terms of each other. For example:
Foo := [BarVal(Bar), Nothing]
Bar := [FooVal(Foo), Nothing]These mutually recursive types do not come up often, but when
they do, there's a helpful technique you can use to make them easier to import.
Since type modules expose a single type, you can't expose both Foo and Bar from the
same .roc file. However, you can wrap them both in a void module named something like FooBar.roc:
FooBar :: [].{
Foo := [BarVal(Bar), Nothing]
Bar := [FooVal(Foo), Nothing]
}At this point you can import FooBar and then reference FooBar.Foo and FooBar.Bar,
or you could import FooBar.Foo and import FooBar.Bar to bring Foo and Bar into
scope unqualified.
You could also make separate alias modules for Foo and Bar:
# Foo.roc
Foo : FooBar.Foo# Bar.roc
Bar : FooBar.BarThis would let you import Foo and import Bar even though they were defined in a single
module for purposes of referencing each other. This technique can be especially useful in
package modules, which can choose to expose Foo and Bar but notFooBar, such that end users don't even see the FooBar wrapper type.
(Note: as mentioned in the alias modules section, alias modules have not
been implemented yet, so this technique doesn't work yet either.)
Design Notes on Imports
Obviously, mutually recursive types take more effort to work with than other types.
This was an intentional design decision based on how rarely mutually recursive types
come up in practice. The cost of making the rare case nicer was making the common case more
complex, which seemed like the wrong tradeoff to make. As such, the rare case (mutually
recursive types) is now more work, which is the accepted drawback of this design.
Another important factor in this choice was build times. Roc is designed to make each
individual module cacheable, so that the compiler doesn't need to redo work when there
are no relevant changes to modules.
Some languages allow modules to import each other, forming import cycles. When module
imports form a cycle, then changing one module requires all the others in the cycle to be
rebuilt too. This makes cyclic imports a footgun for build times; it becomes very easy to
accidentally create a cycle, get no feedback that you have done this, and silently lose a
huge amount of caching. Worse, you can do this when a code base is small and not notice that
the compiler's ability to cache things has been decimated because even scratch-builds are
fast when a code base is small.
Roc intentionally disallows import cycles in order to prevent this from happening. If you
want to have modules reference each other, you have to put them in the same .roc file. This
adds friction (imports get more verbose, and the antidote for that is to create alias modules,
which is also extra effort), and that friction is how the language naturally pushes back on a code
organization strategy which unavoidably harms build times.
Having a large module cycle is easy to do by accident when cyclic imports are allowed,
but it is very difficult to do accidentally when doing so requires putting everything in one
giant .roc file. Putting things into one file also makes it more obvious that the compiler
can't benefit from module-level caching when doing this, since everything is in one big file.
In summary, mutually recursive types (and module cycles) inherently slow down builds by
precluding caching. Roc's design naturally leads to faster builds by disallowing cyclic
imports in favor of putting everything involved in a cycle into a single module, which makes
the unavoidable build time cost of doing so more obvious.
Module Headers
Type modules specify which type they expose by choosing a filename that
matches it. Package modules, platform modules, and application modules all specify what
they expose or hide using a module header, which is a section at the top of the file
that includes other information besides what's hidden and what's exposed.
Exactly what information goes in which headers will be discussed below.
Package Modules
A package is a collection of modules that can be shared between projects. Its
package module (usually named main.roc) has a header that lists which type modules the package
exposes, along with the package's own dependencies:
package [
Parser,
Encoder,
Decoder,
] { json: "..." }A platform-specific package marks its platform dependency with the sameplatform keyword used by an application:
package [FxHttp] {
pf: platform "https://example.com/platform/1.0.0/content-hash.tar.zst",
json: "https://example.com/json/2.0.0/content-hash.tar.zst",
}
import pf.HttpA package like this can use everything the platform exposes, including its effectful functions.
It doesn't provide anything for the platform's requires section, though; only the application
does that.
When an application uses a package like this, every platform mentioned anywhere in the dependency
graph has to be exactly the same platform. For URLs, that means the same version and the same hash.
(The usual version selection doesn't apply to platforms, since an
application can only have one.) For paths, they all have to point to the same file.
Package Shorthands
The record at the end of a package module's header gives a shorthand to each of the
package's dependencies. Any module in the package can then use that shorthand to import types from the
dependency, as in import json.Parser. See Packages for how dependencies are located and versioned.
Platform Modules
A platform module is the root module of a platform. Its header describes how
applications and the platform's host connect to each other:
platform "my-platform"
requires { main : Str -> Str }
exposes [Http, File]
packages { json: "../json/main.roc" }
provides { "roc__entrypoint": main }
targets: { … }requires
The requires section says what the application has to provide to the platform:
requires { main : Str -> Str }If the platform needs the application to choose a type, use a for clause:
requires {
[Model : model] for main : {
init : model,
update : model, Event -> model,
render : model -> Str
}
}Here, the application defines a type named Model, and the platform refers to it as the type
variable model. That way, each application can choose its own Model type, and the platform
code works with all of them without knowing what's inside.
exposes
The exposes section lists the type modules that applications (and packages) can import from the platform:
exposes [Stdout, Stderr, File, Http]packages
The packages section lists the platform's own package dependencies, with their
shorthands:
packages { json: "../json/main.roc" }provides
The provides section lists the Roc functions the host can call, and the name of the symbol each
one will have when the host links against it:
provides { "roc__entrypoint": main }targets
The targets section lists which targets the platform supports, what gets linked together for
each one, and what kind of file each one produces:
targets: {
inputs_dir: "targets/",
x64linux: { inputs: ["crt1.o", "host.o", app] },
arm64mac: { inputs: ["host.o", app] },
wasm32: { inputs: ["host.wasm", app], output: Shared, exports: ["run"] },
}inputs_diris the directory (inside the platform's bundle) that contains the files for each target.- Each target lists its
inputs, which are the files that get linked together, and optionally anoutput. - These paths have to stay inside the platform's directory. An absolute path (like
/usr/lib/foo.a
orC:\foo.lib), or a path with a..in it, gives an "Invalid Target Path" error. Otherwise,
a platform you downloaded could makeroc buildlink in any file on your computer. - WebAssembly targets that get linked must list the functions the final module exports to the
outside world, usingexports. (exports: []exports none.)
The output field says what kind of file the target produces:
Exe(the default): a linked executable. For wasm32, a command module with an entry point.Shared: a shared library (.so,.dylib,.dll). For wasm32, a reactor module: no entry point, with theprovidesentrypoints exported.Archive: a static archive (.a,.lib) containing the host inputs, the compiled app, and the builtins, for linking in another build.
The platform decides what kind of file gets built, so application authors never need to tellroc build that.
app in the inputs list stands for the compiled Roc application. The order of the inputs matters,
because that's the order they get passed to the linker.
Running roc build without a --target flag builds for the first target in this list that's
compatible with the machine running the build.
Linking takes nothing from a toolchain installed on the machine running the build: no system C runtime, SDK, or default library is searched for. On Linux, BSD, and Windows targets the link uses only the listed inputs and Roc's own objects. A macOS target additionally links libSystem, and the frameworks in a macos-sysroot directory the platform provides, from that sysroot or the minimal one bundled with Roc. A Windows target (x64win, arm64win, x64mingw, arm64mingw) therefore lists everything a Windows link needs:
- the entry point the linker infers,
mainCRTStartup(or_DllMainCRTStartupfor aSharedoutput), from a startup object or archive; - whatever C runtime the host calls into;
- import libraries for
kernel32andntdll, which Roc's runtime imports from, and for any other DLL the host uses.
x64win: { inputs: ["host.lib", app, "startup.lib", "ucrtbase.lib", "kernel32.lib", "ntdll.lib"] },An import library can be generated from a module-definition file with zig dlltool -m i386:x86-64 -d kernel32.def -l kernel32.lib. Because no input comes from an installed toolchain, a Windows target builds the same on a machine without Visual Studio and when cross-compiling.
Hosted type modules
A platform's type modules can declare hosted functions, which are implemented by the platform's
host rather than in Roc. A hosted function is declared as an associated item with a type
annotation but no implementation:
# Stdout.roc
Stdout := [].{
line! : Str => {}
}The platform module's header then lists each hosted function in its hosted section, along with the
name of the symbol the host uses to implement it:
platform ""
requires { main! : List(Str) => Try({}, [Exit(I8), ..]) }
exposes [Stdout]
packages {}
provides { "roc_main": main_for_host! }
hosted { "roc_stdout_line": Stdout.line! }Here, the host must provide a function named roc_stdout_line, and whenever Roc code calls Stdout.line!,
that host function will be called.
Hosted functions have a few restrictions:
- They must be effectful functions (with
=>in their types), because the compiler can't run host code during compile-time evaluation. - Every hosted function declaration must be listed in the platform's
hostedsection; otherwise, there would be no host function for calls to it to reach. - Type variables in a hosted function's type can only appear inside a
Box. ABoxis always a pointer at runtime no matter what it contains, so this lets a single host function work for every type the variable could be.
Application Modules
An application module is the root module of a Roc program. It provides whatever the platform's
requires section asks for:
app [main!] { pf: platform "https://..." }
import pf.Stdout
main! = |_| {
Stdout.line!("Hello!")
}The application header has two parts:
- The list (
[main!]here) names the things the application provides to the platform. - The record (
{ pf: platform "…" }here) lists the application's dependencies, with their shorthands.
Exactly one of the dependencies has to be a platform, marked with the platform keyword:
app [main!] {
pf: platform "../basic-cli/main.roc",
json: "../json/main.roc"
}Packages the application uses may also depend on a platform, but it has to be exactly the same
platform the application uses. Only the application provides what the platform requires.
Pinning a Roc version
An application, package, or platform can say which version of the Roc compiler it was written
for, using a roc entry in its dependencies:
app [main!] {
pf: platform "../basic-cli/main.roc",
roc: "nightly-2026-08-05-24f0b47"
}This is optional. If it's there, it has to be a version in the format that roc version prints:
either a nightly like nightly-2026-08-05-24f0b47, or a release like 0.1.0. (That's also whyroc can't be used as the shorthand for a package.)
If you compile it with a different version of the compiler, you get a warning, but the build
still goes ahead.
roc fmt keeps a nightly version up to date: if the compiler running roc fmt is a nightly that's
at least as new as the one written in the header, it updates the header to say that compiler's
version. It leaves release versions alone, since writing a release version is a deliberate choice,
whereas a nightly version usually just means "whatever nightly was current when I wrote this."
Since this is part of formatting, roc fmt --check reports an out-of-date nightly version as
needing to be formatted.
Nominal type identity across packages
Two nominal types from different places are the same type if they have the
same name, and the modules that declare them have exactly the same contents (including the
contents of every module they import, and every module those import, and so on).
This matters when the same module shows up more than once in a project's dependencies. For
example, two different versions of a package might both include a module that didn't change
between those versions, or you might have downloaded the same package from two different URLs.
In those cases, the types that module declares are the same type, no matter where they came
from, so you can pass a value from one to code expecting the other. On the other hand, if the
module (or anything it imports) changed at all, even by one byte, then its types are different
types, even if they have the same names and look the same.
The exception is hosted functions and provides entries in platforms. Those are identified by
the symbol names in the platform header, not by the module's contents, so two hosted functions
with different symbol names are always different functions.
Headerless Application Modules
To facilitate tutorials, Roc permits application modules to omit the header entirely.
When this is done, the application automatically receives the built-in "Echo Platform"
which exposes a single function—echo!—that prints to stdout when compiled to machine code,
and to an externed wasm function (which might be wired up to either console.log or to
a UI for displaying printed output) in WebAssembly. This echo! function is automatically
imported unqualified into the application's scope, so that a complete Hello World in Roc can be:
main! = |_args| echo!("Hello, World!")The main! function the Echo Platform receives will get command-line arguments, if applicable,
as a List(Str). (In WebAssembly, these won't be command-line arguments, but rather arbitrary
arguments from the outside world.)
An application that needs package dependencies can still use the Echo Platform, by writing anapp header that names no platform:
app [main!] {
unicode: "https://github.com/roc-lang/unicode/releases/download/4.0.0/3DGC3M4b2pxaRLg4i8cmxWkm2E2WbCPCLntQzf2mkbUV.tar.zst",
}
import unicode.Grapheme
main! = |_| {
echo!(Grapheme.owned("café") |> Str.inspect)
Ok({})
}Such an application gets the Echo Platform and its unqualified echo! exactly as a headerless
one does; the header is there only to declare the packages it imports from. Writing the header
without any packages (app [main!] {}) is the same thing as writing no header at all.
The Echo Platform is intentionally limited to this one effectful function because that's all
that is needed to teach a wide variety of beginner Roc concepts—expressions, defining and calling functions,
looping over inputs that get evaluated at runtime (as opposed to compile-time, as user-defined constants would be),
type annotations, effectfulness, and so on. Once the learner has gotten the desired amount of experience,
the tutorial can introduce the app module header and move on to a more featureful platform.
The Echo Platform is explicitly intended to be too bare-bones for production use cases. The reason for this
is partly to avoid excessive favoritism in platforms (reputation alone creates plenty of bias towards some
platforms over others; not even having to separately download certain blessed platforms would discourage
competition and innovation), but also to prevent needing to version the platform, document it (the tutorial can
cover the handful of facts there are to know about it), and so on.
Langref Naming (`docs/langref/naming.md`)
Naming
Rules
Valid names in Roc have different rules depending on what they are used for.
Lowercase Names
Lowercase names, used by patterns (which include assignments), type variables, record fields, and package shorthands, must follow these rules:
- The name is a combination of ASCII letters, numbers, and underscores.
- Consecutive underscores are allowed, but discouraged stylistically.
- It must begin with either
_,$, or a lowercase ASCII letter.- The
$prefix is only for reassignment withvar, and must be followed by an ASCII lowercase letter. - The
_prefix is only for naming things that don't actually get used, and must be followed by an ASCII lowercase letter.- The compiler will give a warning if a name begins with
_and is referenced in the same scope. - Note that the
_pattern is not a name and doesn't actually name anything.
- The compiler will give a warning if a name begins with
- The
- It can optionally end with
!if it's naming an effectful function.
Type variables, record fields, and package shorthands not only follow these rules, but also have the additional restriction that they may not include $ or ! anywhere. (All of them may still begin with an underscore to indicate that they are unused.)
Uppercase Names
Uppercase names, used by type names and tag names, have the same rules as lowercase names except:
- They must begin with an ASCII uppercase letter (which implies they may not begin with an underscore)
- They may not include
$or! - Stylistically, they should not include any underscores
Unused Names
The compiler gives a warning when a name is introduced but never used. For example:
total = |price, quantity| price * 2 # Warning: quantity is never usedTo tell the compiler that a name is intentionally unused, begin it with an underscore:
total = |price, _quantity| price * 2 # No warningThe reverse is also true: if a name begins with _ but is used after all, the compiler
warns that the underscore is misleading and suggests removing it.
If you don't want to give a name to the value at all, you can use the_ pattern instead:
total = |price, _| price * 2Naming the unused value (as in _quantity) can still be worthwhile, because
the name documents what the value would have been.
This warning applies to names inside functions and blocks, including function arguments
and names introduced by patterns. Top-level names in a module don't get unused warnings.
Shadowing
Shadowing is when a new name has the same spelling as a name that's already in scope.
Roc gives a warning for shadowing:
count = 1
increment = |count| count + 1 # Warning: count shadows the count aboveIt's usually easier to understand code when each name refers to exactly one thing
in a given scope, so the warning suggests picking a different name. Shadowing is
a warning rather than an error, so the program can still be run; the new name
refers to the new value until the end of its scope.
This applies to redefining a name in the same scope too:
answer = 1
answer = 2 # Warning: answer is already definedIf you want a name whose value changes over time, use var instead.
Constants
A name introduced with an ordinary assignment is a constant. Once it
has been given a value, that value never changes.
pi = 3.14159
area = |radius| pi * radius * radiusConstants defined at the top level of a module are evaluated at
compile time whenever possible, so using them has no runtime cost beyond
reading the already-computed value.
Constants are the default in Roc; the only names whose values can change are
variables.
Variables (with `var`)
A variable is a name whose value can be changed after it has been assigned.
var $count = 0
for item in items {
$count = $count + 1
}`var` keyword
Variables are declared by writing var before their first assignment. After that,
the variable can be reassigned with an ordinary =, without writing var again.
Variables may only be declared inside a function body or block, not at the top level of a module:
var $total = 0 # Error: var is not allowed at the top level
sum = |numbers| {
var $total = 0 # OK
for num in numbers {
$total = $total + num
}
$total
}A variable can only be reassigned by the function that declared it. A nested function
(such as a lambda defined inside the function) can read the variable, but it cannot
reassign it:
count_items = |items| {
var $count = 0
add_one = || {
$count = $count + 1 # Error: can't reassign $count from a nested function
}
…
}When a nested function reads a variable, it sees the value the variable had
when the nested function was defined, not whatever value the variable may have later.
Note that a variable is not a "box" that holds a value, like a mutable reference would be in some
other languages. It's just a name, and reassigning it changes which value the name refers to.
The value it used to refer to doesn't change. So reassigning a variable doesn't allocate or copy
anything by itself, and a variable costs the same at runtime as using a series of constants
would.
This works well with opportunistic mutation. In a loop like
this one, $list is the only thing referring to the list, so each append can add to the list in
place rather than copying it:
var $list = List.with_capacity(100)
for n in 0..<100 {
$list = $list.append(n * n)
}Like every other value that isn't a function, a variable is never generalized. It has exactly one
type, and giving it a type annotation with a type variable in it is an error. (See
Generalization for more on this.)
`$` prefix
Variable names must begin with $. The $ is part of the name, so $count and count are
different names.
The $ prefix makes it easy to tell at a glance which names might have their values
change over time, and which names are guaranteed to be constant. For example, when reading$count + count, you can tell that $count might have a different value on the next
line, whereas count never will.
The compiler warns if a name declared with var does not begin with $, and also if a
constant's name begins with $:
var count = 0 # Warning: should be named $count
$total = 0 # Warning: $total is not declared with varType Variables
A type variable is a lowercase name used in a type annotation, which stands for
"any type." For example:
first : List(elem) -> Try(elem, [ListWasEmpty])Here, elem is a type variable. It means first works on lists of any element type, and
whatever that element type is, the Ok payload will have the same type.
Type variables follow the rules for lowercase names, except that they can't
include $ or !. They can be longer than one letter, and descriptive names like elem, key,
or state often make annotations easier to read than single letters do.
Using the same type variable more than once in an annotation means those types must be the same.
To indicate that a type can be anything and doesn't need to be the same as any other type,
you can use a type variable that begins with an underscore (such as _elem), or just
an underscore by itself:
len : List(_elem) -> U64
is_empty : List(_) -> BoolSee Types for more on how type variables work.
Type Aliases
A type alias gives a new name to an existing type, using :. Type alias names follow
the rules for uppercase names.
Point : { x : F64, y : F64 }The alias and the type it names are interchangeable; see Type Aliases
for details.
Parameterized Type Aliases
A type alias can have type parameters, which are written in parentheses after its name,
just like the type variables in a type annotation:
Pair(a) : (a, a)
swap : Pair(a) -> Pair(a)
swap = |(x, y)| (y, x)When the alias is used, the arguments given for its parameters are substituted into its
definition. For example, Pair(Str) means (Str, Str).
Type parameters of an alias must be named. Underscores are not allowed in type
declarations, because a type declaration needs to say exactly what type it is.
Module Names
Type modules are named after the type they define. For example,
the type module that defines the Url type must be in a file named Url.roc. This means
type module names follow the same rules as uppercase names, plus the.roc file extension.
Other modules, such as a package's main.roc or an application's main.roc, don't define
a type with the module's name, so their file names are not restricted in this way.
When you import a module from a package, the module's name is
qualified with the package's shorthand, as in import json.Parser. The shorthand
(json here) is a lowercase name chosen by the importing module; see
package shorthands.
`as`
The as keyword gives an additional name to something. It's used in a few places:
- In imports,
import json.Parser as JPmakes
the imported type available under the nameJPinstead ofParser. - In patterns,
Ok(n) as resultmatches
anOkand names its payloadn, and also names the whole matched valueresult. - In list patterns,
[first, .. as rest]names the
remaining elements of the listrest.
The name after as follows the usual rules for its position: an uppercase name
for an import, and a lowercase name in a pattern.
Langref Numbers (`docs/langref/numbers.md`)
Numbers
Roc ships with several numeric types, on top of which you can make new custom number types.
Number Literals
Roc number literals can consist of any combination of the following:
- Number Digits (0-9; zeros at the very beginning of the number never change the number)
- Letter Digits (a-f, optionally capitalized; these represent the digits 10 through 15 in hexadecimal number literals)
- Base Prefix (
0xin front of the number means the digits will be treated as hexadecimal, which is base-16 instead of base-10. The other options are0ofor octal, which is base-8, and0b, for binary, which is base-2. If there's no prefix, the digits default to being interpreted as decimal, which is base-10. The letter must be lowercase.) - Scientific Notation Suffix (if the number is base-10 and ends in
e___, everything before theewill be multiplied by 10 to the power of the number in the___. This suffix can't be used if any base prefix is specified.) - A decimal point (can optionally be combined with scientific notation, but cannot be used if a base prefix is specified because uppercase hexadecimal letters after the decimal point would be ambiguous with a type suffix such as
.F64.) - Underscores (the compiler skips over these; they're just for making long numbers easier to read. They can appear in between any digits, including letter digits, and digits after the decimal point, but each underscore must always have a digit on either side of it.)
- Minus sign in front (for negative numbers, not to be confused with the unary negate operator which is an operator that applies to expressions. For example,
-xapplies the unary negate operator tox, but-1is just an ordinary number literal and no negate operation will be executed. This distinction can matter for custom number types.)
Here are some examples of valid number literals:
1
-1
1.23
-123.456e789
0x1abcde42
-1_000_000.123_456_789Type Suffixes
Roc's compiler will infer the type of your number literal based on how it's used. For example:
List.get(my_list, 3)Here, the type of 3 will be U64 based on how it's used here, because List.get takes a List as its first argument and a U64 as its second argument.
If you want to specify an explicit type for the number (perhaps for documentation, or maybe because you want an error report if it gets used as any other type), you can add the type you want after a dot at the end. For example, here's how you would specify that the number -12.34 should be interpreted as a Dec:
-12.34.DecThis not only works with builtin number types, but also with any custom number type you might make—the only requirement is that the type name must be in scope (which you can accomplish using import as long as it's accessible to your module).
Defaulting to `Dec`
In some situations, a number literal never gets inferred to a specific type. For example:
if 2 > 1 {
# ...
}Here, 2 > 1 must be evaluated in order to tell whether the if should be taken, yet it's never used in a way that would associate it with any particular number type. In these cases, Roc will use the builtin Dec number type for the literal. So this code will do exactly the same thing as:
if 2.Dec > 1.Dec {
# ...
}This almost never comes up in practice, unless you're playing around putting numbers into the REPL. In those situations, Dec can be a nice default because it both supports fractions and can give precise answers when doing quick calculations in the REPL.
Builtin Number Types
All of Roc's builtin number types have a fixed size (and that size never varies by what target you're building for), and they only ever perform heap allocations when converting to heap-allocated types like Str.
Integers
Here are Roc's builtin integer types, along with their ranges and sizes in memory:
| Range | Type | Size |
|---|---|---|
-128 |
I8 | 1 Byte |
127 |
||
| -------------------------------------------------------- | ------- | ---------- |
0 |
U8 | 1 Byte |
255 |
||
| -------------------------------------------------------- | ------- | ---------- |
-32_768 |
I16 | 2 Bytes |
32_767 |
||
| -------------------------------------------------------- | ------- | ---------- |
0 |
U16 | 2 Bytes |
65_535 |
||
| -------------------------------------------------------- | ------- | ---------- |
-2_147_483_648 |
I32 | 4 Bytes |
2_147_483_647 |
||
| -------------------------------------------------------- | ------- | ---------- |
0 |
U32 | 4 Bytes |
(over 4 billion) 4_294_967_295 |
||
| -------------------------------------------------------- | ------- | ---------- |
-9_223_372_036_854_775_808 |
I64 | 8 Bytes |
9_223_372_036_854_775_807 |
||
| -------------------------------------------------------- | ------- | ---------- |
0 |
U64 | 8 Bytes |
(over 18 quintillion) 18_446_744_073_709_551_615 |
||
| -------------------------------------------------------- | ------- | ---------- |
-170_141_183_460_469_231_731_687_303_715_884_105_728 |
I128 | 16 Bytes |
170_141_183_460_469_231_731_687_303_715_884_105_727 |
||
| -------------------------------------------------------- | ------- | ---------- |
(number below is over 340 undecillion) 0 |
U128 | 16 Bytes |
340_282_366_920_938_463_463_374_607_431_768_211_455 |
Integers come in two flavors: signed and unsigned.
- Unsigned integers can never be negative. The lowest value they can hold is zero.
- Signed integers can be negative.
Integers also come in different sizes. Choosing a size depends on your performance
needs and the range of numbers you need to represent. At a high level, the
general trade-offs are:
- Larger integer sizes can represent a wider range of numbers. If you absolutely need to represent numbers in a certain range, make sure to pick an integer size that can hold them!
- Smaller integer sizes take up less memory. These savings rarely matter in variables and function arguments, but the sizes of integers that you use in data structures can add up. This can also affect whether those data structures fit in cache lines, which can be a performance bottleneck.
- Certain CPUs work faster on some numeric sizes than others. If the CPU is taking too long to run numeric calculations, you may find a performance improvement by experimenting with numeric sizes that are larger than otherwise necessary. However, in practice, doing this typically degrades overall performance, so be careful to measure properly!
Overflow and Division by Zero
If an integer operation's answer doesn't fit in the integer's type, that's called overflow. For
example, 250.U8 + 10 would be 260, which is bigger than the biggest U8 (255).
In Roc, integer overflow crashes the program, and so does dividing an integer by zero:
too_big = 250.U8 + 10 # Crashes with "Integer addition overflowed"Some languages instead wrap around when integers overflow, so that 250.U8 + 10 would be 4.
Roc doesn't do this by default, because when it happens by accident, it can cause bugs that are
hard to track down; a program that keeps running with a wrong number can be worse than a program
that stops. (Remember that if the overflow happens during compile-time evaluation,
the crash will be reported as a compile-time error.)
When you want some other behavior, each integer type has methods for that:
| Method | 250.U8 and 10 |
Behavior on overflow |
|---|---|---|
plus (what + calls) |
crashes | Crash |
plus_try |
Err(Overflow) |
Return an Err instead of crashing |
plus_wrap |
4 |
Wrap around |
plus_saturated |
255 |
Stay at the highest (or lowest) possible value |
plus_overflows |
True |
Return whether it would overflow |
There are minus and times versions of each of these too. For division, div_try returnsErr(DivByZero) instead of crashing when dividing by zero.
Fractions
Roc has three builtin types for numbers that can have digits after the decimal point:
| Type | Size | Representation |
|---|---|---|
Dec |
16 Bytes | Fixed-point decimal with 18 decimal places |
F64 |
8 Bytes | 64-bit binary floating-point |
F32 |
4 Bytes | 32-bit binary floating-point |
Dec stores numbers in base-10, so it can represent decimal fractions like 0.1 exactly. That means
arithmetic on it gives the answers you'd get by doing the math by hand:
0.1.Dec + 0.2 == 0.3 # TrueDec has exactly 18 digits after the decimal point, and can represent numbers between-170141183460469231731.687303715884105728 and 170141183460469231731.687303715884105727. Dividing
a Dec by zero crashes.
F32 and F64 are IEEE 754 floating-point numbers, which
store numbers in base-2. Many decimal fractions (including 0.1) can't be represented exactly in base-2,
so floating-point arithmetic often gives answers that are slightly off:
0.1.F64 + 0.2 == 0.3 # False, because the sum is 0.30000000000000004In exchange, floating-point numbers can represent a much wider range of values (including extremely large and
extremely small numbers), and CPUs have dedicated hardware for floating-point arithmetic, which makes it
much faster than Dec arithmetic. Floats also have special values that Dec doesn't: dividing a float
by zero doesn't crash, but rather evaluates to infinity (or negative infinity), and some operations
evaluate to NaN.
As a rule of thumb, Dec is a good choice for things like money, where exact decimal answers matter,
and floats are a good choice for things like graphics and scientific simulations, where speed matters more
than exact decimal answers.
Ranges
The range operators build a reusable Range value describing
a span of numbers. start..<end excludes end, while start..=end includes
it:
var $sum = 0
for n in 0..<3 {
$sum = $sum + n # runs with n = 0, then 1, then 2
}Both bounds must have the same type. A U8 range is a Range(U8), a Dec
range is a Range(Dec), and so on. When nothing pins the bounds' type, range
literals default the same way other number literals do.
A range starts with a step of 1. Calling range.step_by(new_step) replaces that
absolute step; calling it a second time replaces the first step rather than
multiplying them. range.size_hint() returns Known(count) when the exact
count fits in a U64, and Unknown otherwise.
Call range.iter() to obtain a forward iterator. Integer and Dec ranges also
support range.iter_rev(), which produces the same lower-anchored members in
reverse order. For example, (5.I64..=12).step_by(2).iter_rev() yields 11, 9,
7, and 5. F32 and F64 support forward ranges but deliberately do not
support iter_rev, because repeated floating-point addition is not exactly
reversible. Once adding the step can no longer produce a larger float, forward
iteration yields the current value once and then ends.
A range is empty when its lower bound is not below (..<) or at (..=) its
upper bound, or when its step is not positive. for loops call the range'siter method automatically, as in the example above.
Like the other operators, ranges use static dispatch:start..<end calls range_exclusive_to on the bounds' type, andstart..=end calls range_inclusive_to. The correspondingrange_exclusive_from and range_inclusive_from methods opt a type into exact
reverse iteration. Third-party numeric types can construct the stored value
with Range.custom and define range_iter using their own arithmetic—no
interaction with U64 is required for the stored step.
Custom Number Types
We already saw how you can use optional number type suffixes to specify the type of a number literal instead of letting it be inferred. For example:
-12.34.F64F64 is a builtin type, but you can use your own custom number type in the same way. Let's say you made a custom number type called Ratio which stores both a numerator and denominator, so it can represent fractions like two-thirds which can't be precisely represented using either decimals or floating-point numbers. You could create a Ratio value like this:
-12.34.RatioHere's what will happen if you write this:
- Just based on the syntax here, at compile time, Roc will call
Ratio.from_numeral(...) - It will pass an argument to specify that this is a negative number with the digits
12before the decimal point and34after it. Ratio.from_numeralwill return aTryrepresenting whether the specified digits are a validRatio. (Some custom number types may have limits on the size of the numbers they store, may or may not support negative numbers, may or may not support digits after the decimal point, etc.)- If
Ratio.from_numeralreturned aTry.Oktag, then that tag's payload will contain the actual number value that these digits resolved to. - If it returned an
Err, then (since this is all being evaluated at compile time), the compiler will report an error for this number literal before the program even runs.
- If
from_numeral is one of Roc's
well-known static-dispatch methods.
Inferred Custom Number Types
Just like with builtin number types, you don't have to annotate your number literals to specify that they use your custom number type. Instead, you can let the compiler infer the type based on usage. For example, let's say you have a function named from_ratio which takes a Ratio. Then you could write:
from_ratio(12.57)First, the compiler would determine that the argument to from_ratio is a Ratio, and therefore call Ratio.from_numeral specifying that the digits before the decimal point are 12 and the digits after the decimal point are 57. Assuming that returns Ok, the value inside that Ok would be what ended up getting passed to from_ratio.
From there, everything works the same way as in the previous example with the explicit .Ratio suffix. The only difference is that you didn't have to write the word Ratio because the compiler inferred that was the type, and called its from_numeral method.
Custom Number Types and Operators
Operators like + and / work on custom number types the same way they work on builtin ones: they
desugar to method calls. So if Ratio has a div_by method, then a / b
calls Ratio.div_by(a, b) when a is a Ratio.
Here's a Ratio type which supports number literals, +, and /:
Ratio := { numerator : I64, denominator : I64 }.{
from_numeral : Numeral -> Try(Ratio, [InvalidNumeral(Str)])
from_numeral = |numeral| {
if numeral.digits_after_pt_count() > 0 {
Err(InvalidNumeral("Ratio literals must be whole numbers, like 3 or 5"))
} else {
match I64.from_numeral(numeral) {
Ok(n) => Ok({ numerator: n, denominator: 1 })
Err(err) => Err(err)
}
}
}
plus : Ratio, Ratio -> Ratio
plus = |a, b| {
numerator: (a.numerator * b.denominator) + (b.numerator * a.denominator),
denominator: a.denominator * b.denominator,
}
div_by : Ratio, Ratio -> Ratio
div_by = |a, b| {
if b.numerator == 0 {
crash "Ratio division by zero"
} else {
{ numerator: a.numerator * b.denominator, denominator: a.denominator * b.numerator }
}
}
}Now we can write two-thirds as a fraction:
two_thirds : Ratio
two_thirds = 2 / 3Here, the literals 2 and 3 are converted to Ratio values using Ratio.from_numeral, and then /
calls Ratio.div_by on them. Since two_thirds is a top-level constant, all of this happens at
compile time, so the program just has the finished Ratio value embedded in it.
That also means problems get reported at compile time. There are two ways a custom number type can reject
something:
from_numeralcan return anErr, which rejects the literal itself. Here,Ratiorejects literals with
digits after the decimal point, so writing2.5 / 3would give a compile-time error with the message"Ratio literals must be whole numbers, like 3 or 5".- An operator's method can
crash, which rejects the operation. Here,2 / 0is made of two valid
literals, but dividing by zero crashes indiv_by. Since this happens during compile-time evaluation, it's
reported as a compile-time error too.
Of course, if the same operation happens at runtime (for example, if the denominator came from user input),
then a crash in the operator's method would happen at runtime.
Creating a `from_numeral` Implementation
A from_numeral method has this type (where T is the custom number type):
from_numeral : Numeral -> Try(T, [InvalidNumeral(Str)])The Numeral argument describes the literal's exact value, using these methods:
| Method | Returns |
|---|---|
numeral.is_negative() |
True if the literal had a minus sign in front of it |
numeral.digits_before_pt() |
A List(U8) of the digits before the decimal point, in base-256 |
numeral.digits_after_pt() |
A List(U8) of the digits after the decimal point, in base-256 |
numeral.digits_after_pt_count() |
How many base-10 digits the literal had after its decimal point |
The digits are given in base-256, with the most significant digit first, because that uses every bit of
each U8. For example, for the literal 356.5170:
digits_before_ptis[1, 100], because 356 = (1 × 256) + 100digits_after_ptis[20, 50], because 5170 = (20 × 256) + 50digits_after_pt_countis4, because5170has four digits
The count is needed because the digits after the point are stored as a whole number, so the count
is what distinguishes .5170 from .517 or .005170. (For example, 1.0 has a digits_after_pt of [] and adigits_after_pt_count of 1, whereas 1 has a digits_after_pt_count of 0, which is how you can tell
whether the literal had a decimal point.)
Zero digits are represented by an empty list, so the literal 0 has a digits_before_pt of [],
and 0.25 has a digits_before_pt of [] and a digits_after_pt of [25].
The compiler normalizes the literal before calling from_numeral, so these don't need to be handled separately:
- Underscores are removed, so
1_000is the same as1000. - Base prefixes are applied, so
0xffhas adigits_before_ptof[255], just like255does. - Scientific notation is applied, so
1e3is the same as1000, and1.5e-2is the same as0.015.
Since the digits can be arbitrarily long, a custom number type can support numbers of any size. For
example, an arbitrary-precision integer type could accept literals with hundreds of digits.
The simplest way to implement from_numeral is often to delegate to a builtin number type'sfrom_numeral, as in I64.from_numeral(numeral) in the Ratio example above,
and then convert the result.
If the literal isn't valid for the type, return Err(InvalidNumeral(message)). The compiler will
report the message as a compile-time error, pointing at the literal.
Performance
Memory
Numbers are always stored inline. They never involve a heap allocation or a
reference count, and each type's size is the same on every
target (see the tables in Builtin Number Types).
When you store lots of numbers, such as in a big list, the size of each one adds up. AList(U8) with a million elements takes up about 1 megabyte, whereas a List(U64) with the same
number of elements takes up about 8. Using the smallest type that can hold the values you need
means more of them fit in the CPU's cache, which can
make a big difference when going through lots of them.
Overflow Checks
Since integer overflow crashes, integer +, -, and * each
check whether the result overflowed. Most CPUs can do this check very cheaply, with a single
instruction that's almost always correctly predicted not to jump anywhere. On top of that, when
the compiler can prove an operation can't overflow (for example, because an earlier comparison
already ruled it out), it leaves the check out entirely.
If you actually want wrapping behavior (in a hash function, for example), use the _wrap methods
like plus_wrap. Those never check for overflow, so they compile to the same instruction as
integer addition in C.
`Dec` vs. Floats
F32 and F64 arithmetic happens in the CPU's floating-point hardware, so it's very fast.
Dec arithmetic has to be done in software, using 128-bit integers. Adding and subtractingDecs is still fast (about as fast as adding and subtracting I128s), but multiplying and
dividing them takes many more instructions than multiplying or dividing floats. So if your
program does a lot of multiplication or division on fractional numbers, and doesn't need exact
decimal answers, floats will be faster.
Literals
Number literals never cost anything at runtime beyond the number itself. Even for
custom number types, from_numeral gets called at compile time, so the
program contains the finished value.
Langref Operators (`docs/langref/operators.md`)
Operators
Desugaring
Most of Roc's operators are syntax sugar for calling a method. For
example, a + b is sugar for a.plus(b), and -x is sugar for x.negate(). This means that
you can use these operators on any type that has the right method, including your own types.
| Operator | Method |
|---|---|
a + b |
a.plus(b) |
a - b |
a.minus(b) |
a * b |
a.times(b) |
a / b |
a.div_by(b) |
a // b |
a.div_trunc_by(b) |
a % b |
a.rem_by(b) |
a == b |
a.is_eq(b) |
a != b |
a.is_eq(b), then Bool.not on the result |
a < b |
a.is_lt(b) |
a <= b |
a.is_lte(b) |
a > b |
a.is_gt(b) |
a >= b |
a.is_gte(b) |
a ..< b |
a.range_exclusive_to(b) |
a ..= b |
a.range_inclusive_to(b) |
-a |
a.negate() |
!a |
a.not() |
Which method gets called is decided at compile time, based on the type of a. (That's what
static dispatch means.) So there's no runtime cost to the desugaring; a + b
on two U64s compiles to the same machine instruction that adding two integers would in C.
The operators that aren't in this table (and, or, ??, ?, and |>) don't desugar to method
calls. They're described in their own sections below.
Precedence
When an expression has more than one operator in it, the ones higher in this table are grouped
together first. For example, 1 + 2 * 3 is 1 + (2 * 3), because * is higher than +.
| Operators | Grouping |
|---|---|
Function calls, method calls, .field access, the pipe operator, postfix ?, prefix - and ! |
left to right |
* / // % |
left to right: a / b * c is (a / b) * c |
+ - |
left to right: a - b - c is (a - b) - c |
?? |
|
? (with spaces around it) |
|
== != < <= > >= |
can't be chained (see below) |
and |
|
or |
|
..< ..= |
can't be chained |
Comparison operators return a Bool, so chaining them, as in a < b < c, would compare a Bool
to c. That gives a compile-time error (unless c happens to be a Bool, which is never what
you want), so write a < b and b < c instead.
Binary Infix Operations
And
a and b evaluates to True if both a and b are True, and False otherwise. Both operands must be Bool values.
The and operator short-circuits: if a is False, then b is not evaluated at all, because the answer is already known to be False. This matters when b is expensive to compute, or when it calls an effectful function:
is_valid = input.len() > 0 and expensive_check(input)Here, expensive_check is only called if input.len() > 0.
Since it has to skip evaluating b sometimes, and can't be an ordinary method call (method
calls evaluate all their arguments first). and / or shows the
equivalent if expression.
Or
a or b evaluates to True if either a or b (or both) is True, and False otherwise. Both operands must be Bool values.
Like and, or short-circuits: if a is True, then b is not evaluated, because the answer is already known to be True.
use_default = config_missing or user_requested_default()Here, user_requested_default is only called if config_missing is False.
Arithmetic Operators
+, -, *, /, //, and % call methods on their left operand. The result has the
left operand's type. The right operand usually has the same type as the left one, but it doesn't
have to; that's up to the method. Static dispatch covers how to
define these methods for your own types, and numbers covers what they do on the
builtin number types (including what happens on overflow and division by zero).
Comparison Operators
==, !=, <, <=, >, and >= call methods that return a Bool. Both operands must have
the same type. Static dispatch covers how to define these for your
own types.
Range Operators
start..<end and start..=end create a Range, which describes the numbers
from start up to end. With ..<, the range stops just before end; with ..=, it includesend:
for n in 1..<4 {
# n is 1, then 2, then 3
}
for n in 1..=4 {
# n is 1, then 2, then 3, then 4
}Both operands must have the same type, and the result is a Range of that type. Creating a range
doesn't allocate anything or produce any numbers yet; it's just a description of the numbers. See
Ranges for more.
Range operators group after every other operator, so 1..<n + 1 is 1..<(n + 1). They can't be
chained, so 1..<5..<10 gives a compile-time error.
`??` (default value on `Err`)
a ?? b evaluates to the Ok payload if a is Ok, and to b if a is Err:
first = List.first(items) ?? 0
name = Dict.get(users, id) ?? "Unknown"It's equivalent to this:
first = match List.first(items) {
Ok(val) => val
Err(_) => 0
}Like and and or, ?? short-circuits: b only gets evaluated if a is an Err.
`?` with a Handler
Writing ? with spaces around it, followed by a function, works like postfix ?,
except that the error gets passed to that function first. Whatever the function returns is what
gets returned (wrapped in Err):
parse_pair : Str, Str -> Try((U64, U64), [InvalidFirst, InvalidSecond])
parse_pair = |a, b| {
x = U64.from_str(a) ? |_| InvalidFirst
y = U64.from_str(b) ? |_| InvalidSecond
Ok((x, y))
}If U64.from_str(a) returns an Err, then parse_pair returns Err(InvalidFirst).
You can also write a tag instead of a function. In that case, the original error becomes the
tag's payload. So U64.from_str(b) ? InvalidSecond would return Err(InvalidSecond(BadNumStr))
if b wasn't a valid number. This is a convenient way to keep track of where an error came
from, while keeping the details of the original error.
`|>` (pipe)
a |> f(b, c) is another way to write f(a, b, c). In other words, it calls the function on the
right, passing the value on the left as the first argument. If there are no other arguments, you
can leave off the parentheses, so a |> f is the same as f(a).
This is useful for writing a series of function calls in the order they happen, rather than
nested inside each other:
result = input |> parse |> List.map(normalize) |> summarize
# This is the same as:
result = summarize(List.map(parse(input), normalize))For calling methods, you can usually write value.method(arg) instead (see
Calling Methods). |> is for calling functions that aren't
methods on the value's type, or for when you'd rather name the function explicitly.
The thing after |> has to be a function's name (like parse or List.map), not some other
expression. Also note that |> groups before binary operators like +, so 1 + 2 |> double is1 + double(2), not double(1 + 2).
Unary Prefix Operators
`-` (`.negate()`)
-x is sugar for x.negate(). The operand and result have the same type.
`!` (`.not()`)
!x is sugar for x.not(). The operand and result have the same type.
Unary Postfix Operators
`?` (unwrap if `Ok`; early `return` if `Err`)
Writing ? right after an expression which evaluates to a Try does one of two things:
- If the expression evaluated to
Ok, the?expression evaluates to theOktag's payload. - If the expression evaluated to
Err, the enclosing function immediately returns thatErr.
For example:
parse_pair : Str, Str -> Try((U64, U64), [BadNumStr])
parse_pair = |a, b| {
x = U64.from_str(a)?
y = U64.from_str(b)?
Ok((x, y))
}That's equivalent to this:
parse_pair = |a, b| {
x = match U64.from_str(a) {
Ok(val) => val
Err(err) => return Err(err)
}
y = match U64.from_str(b) {
Ok(val) => val
Err(err) => return Err(err)
}
Ok((x, y))
}If U64.from_str(a) returns Err(BadNumStr), then parse_pair returns Err(BadNumStr) right away, and U64.from_str(b) never runs.
Since ? can return from the function, the function's return type has to be a Try whose error
type includes every error that ? might return. When different ?s in the same function have
different error types, the function's error type is the union of all of them. For example, if
one call can fail with Err(BadNumStr) and another with Err(FileNotFound), then the function's
return type could be Try(…, [BadNumStr, FileNotFound]).
? is often more convenient than ?? when the right way to handle an
error is to let the caller deal with it, whereas ?? is more convenient when there's a sensible
default value to use instead.
When ? is used directly inside a top-level expect, there's no function to return from. Instead, if the expression evaluates to Err, the expect fails, and the test report shows which Err it was.
Using ? directly inside an inline expect gives a compile-time error,
because returning from the enclosing function would make the program behave differently in
optimized builds, which leave out inline expects. (See
control flow and variables inside expect.)
`[…]` (subscript operator)
(This has not been implemented yet.)
Writing [ and ] directly after an expression, with another expression in between them (for example, list[index]), will call the subscript method on the first expression, passing the second expression as its argument. In other words, collection[key] will desugar to collection.subscript(key).
Several builtin types already have subscript methods, which you can call directly in the meantime:
| Expression | Equivalent to | Evaluates to |
|---|---|---|
list.subscript(index) |
List.get |
Try(item, [OutOfBounds]) |
dict.subscript(key) |
Dict.get |
Try(value, [KeyNotFound]) |
set.subscript(item) |
Set.contains |
Bool |
Since this uses static dispatch, any type can opt into subscript syntax by defining a subscript method.
Langref Packages (`docs/langref/packages.md`)
Packages
A package is a collection of Roc modules which can be shared between projects. For example, a
package for working with JSON might expose modules named Parser and Encoder.
A package's root module is its package module, which is usually named main.roc.
Its header lists which type modules the package exposes, along with the
package's own dependencies:
package [Parser, Encoder] {}Modules in the package which aren't listed there can still be imported by the package's other modules,
but they can't be imported by anything outside the package. This lets packages have private
implementation details.
Depending on Packages
Applications, platforms, and packages declare their package dependencies in the record at the end of
their module header. Each dependency is given a shorthand and a location:
app [main!] {
pf: platform "https://example.com/basic-cli/1.0.0/4ZuGvfzgQ8bm8nVz7dLNYRTYHoNeuDLPdrvzeAUm7gwF.tar.zst",
json: "https://example.com/json/2.1.0/7AfjuYkuixaofoi12pAudKZpk557rKy1aSW3Ugxr6dab.tar.zst",
util: "../util/main.roc",
}A package's location can be either a URL or a filesystem path.
URL Packages
A URL package is a compressed .tar.zst bundle of the package's .roc files, downloaded over HTTPS.
(Plain HTTP is only allowed for localhost, which is useful when testing.) Downloaded packages are
cached, so each one only needs to be downloaded once.
The last part of the URL, before .tar.zst, must be a hash of the bundle's contents. After downloading
a bundle, Roc verifies that its contents match that hash, and refuses to use it if they don't. This means
that a given URL always refers to exactly the same package contents, even if the server hosting it gets
compromised.
If the server responds with a redirect, Roc follows it (up to three redirects in a row), but each
place it gets redirected to has to follow the same HTTPS-or-localhost rule as the original URL.
To keep a malicious or broken package from filling up your disk, Roc also limits how big
downloaded packages can be once they're decompressed:
- Each package can be at most 10 megabytes. You can change this with
--max-package-mb. - Everything a dependency brings in (the package itself plus all of its own dependencies,
recursively) can be at most 100 megabytes combined, or 512 megabytes for a platform. You can
change this with--max-transitive-mb.
Passing 0 to either of these turns that limit off.
The roc bundle command creates a bundle from a package's .roc files, and names the resulting file
after its hash:
roc bundle main.roc Parser.roc Encoder.rocThis creates a file with a name like 7AfjuYkuixaofoi12pAudKZpk557rKy1aSW3Ugxr6dab.tar.zst, which can
then be uploaded anywhere that can serve static files over HTTPS.
Package Versions
A package URL can include a version number in MAJOR.MINOR.PATCH format, such as the 2.1.0 inhttps://example.com/json/2.1.0/7Afj….tar.zst. The version can appear anywhere in the URL before the hash,
but only once.
When the same package is depended on multiple times in a project's dependency graph (for example,
because two different packages both depend on it), Roc uses version numbers to pick a single version
for the whole project:
- Versions with different major numbers (for example,
1.4.0and2.0.0) are considered completely different packages, so both are used. - Versions with the same major number (for example,
2.1.0and2.3.1) are considered compatible, so only the highest version mentioned is used. - For versions with a major number of
0, the minor number is treated as the major number. So0.3.0and0.4.0are considered different packages, while0.3.0and0.3.5are considered compatible.
An application's own dependencies are used exactly as written. If some other package in the dependency graph
requires a higher version of the same package than the application specifies, that's an error which shows the
chain of dependencies involved, rather than a silent upgrade.
The roc bump command compares a package's public API against a previous version, and reports whether
the changes require a major, minor, or patch version bump.
URLs without a version number don't participate in this process; each one is treated as a separate package.
Path Packages
A dependency can also be a path to a package's main.roc on the local filesystem, relative to the module
declaring the dependency:
util: "../util/main.roc"Path packages are convenient for developing multiple packages together, or for splitting a large
project into multiple packages without publishing them. They aren't downloaded, cached, or versioned;
Roc reads them directly from the filesystem.
The expects in path packages are run by roc test, because they're part of your
project. The expects in URL packages are not.
Shorthands
A package's shorthand is the lowercase name its dependent gives it in the module header, such as json
in json: "https://…". Modules use the shorthand to import the package's
modules:
import json.ParserShorthands follow the rules for lowercase names, and they may not
include $ or !. The name roc is reserved for pinning a Roc version,
so it can't be used as a shorthand.
A shorthand is chosen by the dependent, not by the package itself, and it's only in scope within the
application, package, or platform whose header declares it. So if an application refers to a JSON package as json, and a package
it depends on refers to the same JSON package as j, both work. This also means that if two packages
happen to have similar names, each dependent can choose whatever shorthands it likes to tell them apart.
Platforms
A platform is a special kind of package which provides the lower-level functionality that
an application builds on. Every application has exactly one platform, which is marked with the platform
keyword in the application's header. See Platform Modules for details.
Inspecting Dependencies with `roc deps` {#inspecting-dependencies-with-roc-deps}
roc deps main.roc figures out the whole dependency graph of an application, package, or
platform, and prints it as a tree. It doesn't compile anything, so it's quick:
main.roc (/home/me/app/main.roc) [app]
├── https://example.com/basic-cli/0.23.0/3hT3So…4r1i.tar.zst [platform]
├── https://example.com/roc-ascii/0.5.0/5WxqRf…9Z3H.tar.zst
└── https://example.com/roc-ansi/0.13.0/JXLM47…ovL.tar.zst
└── https://example.com/roc-ascii/0.5.0/5WxqRf…9Z3H.tar.zst [shared]
[shared] this package was already shown above, where its dependencies are listedEach line shows the full URL or path exactly as the module header wrote it (not its shorthand), so
you can copy it straight into --replace-dep.
When a package shows up more than once, its dependencies are only listed the first time, and later
appearances are marked [shared]. When version selection picks a different
version than a header asked for, the tree shows [resolved to URL] next to it.
Replacing Dependencies with `--replace-dep` {#replacing-dependencies-with-replace-dep}
Sometimes you want to build against a different version of a dependency without editing any module
headers; for example, to test a local change to a package, or to try out a fork. For that, you can
pass --replace-dep OLD NEW to roc run, roc build, roc check, roc test, roc docs, orroc deps:
# Use a local copy of the platform instead of its published URL.
roc build app.roc --replace-dep "$PLATFORM_URL" ../basic-cli/platform/main.roc
# Replace a package everywhere it's used, including inside other packages.
roc test app.roc --replace-dep "$ASCII_URL" ../roc-ascii/main.rocOLD and NEW are each either a complete package URL or a path to a package's root .roc file.
You can pass --replace-dep more than once to replace several dependencies.
OLD has to match exactly. Every place that depends on exactly that URL (including its version
and hash) or that path uses NEW instead, no matter what shorthand it gave the dependency. Nothing
else gets replaced, so different versions of the same package each need their own--replace-dep. It's an error if OLD doesn't match anything, or if it's a shorthand (likeascii) rather than a full URL or path.
The replacement package's own header decides what its dependencies are, and --replace-dep applies
to those too. Running roc deps main.roc --replace-dep … shows the resulting dependency graph, with
each replaced dependency marked [replaced by …].
Replacements only last for the one command you passed them to. They never edit any files, or
change any cached packages.
Langref Parsers (`docs/langref/parsers.md`)
Parsers
Roc has a built-in system for parsing data (such as JSON) into Roc values, and for encoding Roc values
back into data. It's based on static dispatch, and it separates two concerns:
- The format (such as JSON) knows how to read and write basic things like strings, numbers, lists, and records.
- The type being parsed (such as
{ name : Str, age : U64 }) knows which of those basic things it's made of.
This means any format can work with any type, without either one needing to know about the other in advance.
Parsing JSON
The builtin Json module parses JSON into any type that supports it, which is determined by type inference:
parse_user : Str -> Try({ name : Str, age : U64 }, [InvalidJson(Str), MissingRequiredField(Str)])
parse_user = |json| Json.parse(json)
parse_user("{\"name\": \"Sam\", \"age\": 30}") # Ok({ name: "Sam", age: 30 })
parse_user("{\"name\": \"Sam\"}") # Err(MissingRequiredField("age"))
parse_user("{\"name\": 5}") # Err(InvalidJson("Invalid JSON"))The parser is determined entirely by the type. Here, since parse_user returns a record with a name : Str field
and an age : U64 field, Json.parse expects a JSON object with a string field named "name" and a numeric field
named "age".
Json.to_str goes the other direction, encoding a value as JSON:
Json.to_str({ name: "Sam", age: 30.U64 }) # {"age":30,"name":"Sam"}The Json module also has variations for different conventions. For example, Json.parser_camel() returns a
parser which expects JSON fields in camelCase and converts them to Roc's snake_case field names (so a Roc field
named user_id is read from a JSON field named "userId"), and Json.parse_trailing_commas allows trailing
commas in arrays and objects.
Which Types Can Be Parsed
Parsing and encoding work on these types automatically, as long as all the types they contain can be parsed or encoded too:
- Strings, numbers, and
Bool - Records
- Tuples
- Tag unions
- Lists, dictionaries, and sets
How each of these is represented depends on the format. For example, in JSON, a record is an object,
and a tag without a payload (like Active) is a string (like "Active").
Optional Fields
When a record field might be missing from the input, you can make it an optional field,
or you can give it a Try type whose error is [Missing]:
parse_user : Str -> Try({ name : Str, nickname ?: Str, email : Try(Str, [Missing]) }, [InvalidJson(Str), MissingRequiredField(Str)])
parse_user = |json| Json.parse(json)
parse_user("{\"name\": \"Sam\"}") # Ok({ name: "Sam", nickname: <missing>, email: Err(Missing) })Fields which are neither optional nor a Try of [Missing] are required, and it's an error
(MissingRequiredField) if they're missing from the input.
Nominal Types
Nominal types aren't parseable or encodable automatically, because their authors may
not want them to be. (For example, an opaque type might have internal details
that shouldn't be exposed in encoded data, or invariants that parsing arbitrary data could violate.)
To opt into the compiler's derived parser and encoder, a nominal type declares parser_for : _ and encoder_for : _:
Point := { x : I64, y : I64 }.{
parser_for : _
encoder_for : _
}Now Json.parse and Json.to_str work with Point, using the same representation as its backing record.
Alternatively, a type can implement parser_for and encoder_for itself, to use a custom representation. See
Parsing and Encoding for an example.
Parsers at Compile Time
Building a parser for a type is done once per type, and when it happens in a
top-level constant, it happens at compile time. For example:
parse_request : Str -> Try(Request, [InvalidJson(Str), MissingRequiredField(Str)])
parse_request = Json.parser_camel()Here, all the work of assembling a parser specialized for the Request type, including converting its
field names to camelCase, happens during compilation. At runtime, the program just runs the finished parser.
Writing Generic Parsing Functions
A function which works with any JSON-parseable type can express that requirement using the Json.Parseable
where alias. Similarly, Json.Encodable describes any type which can be encoded as JSON:
from_json : Str -> Try(a, [InvalidJson(Str), ..errs]) where [a.Json.Parseable([InvalidJson(Str), ..errs])]
from_json = |src| Json.parse(src)
to_json : a -> Str where [a.Json.Encodable([])]
to_json = |value| Json.to_str(value)Other Formats
JSON isn't special. Any format can work with any parseable type, as long as the format implements
the methods that the type's parser needs, such as parse_str for reading strings and parse_record_start
for reading records. A format only needs to implement methods for the shapes it supports; using a type
whose shape a format doesn't support (for example, a tuple, with a format that has no tuple syntax) is a
compile-time error rather than a runtime one.
For example, besides Json, Roc's builtins also include Encoding.HttpHeader, which parses HTTP headers
into records:
parse_headers : Str -> Try({ content_length : U64, x_auth_token : Try(Str, [Missing]) }, [BadHeader, MissingRequiredField(Str)])
parse_headers = Encoding.HttpHeader.parser_for()Formats can be implemented in packages, too, without any changes to the types they parse.
Performance
A parser reads its input directly into the Roc value being parsed. It doesn't first build some
general-purpose representation of the input (like a tree of JSON values) and then convert that
into the type you asked for. So parsing a JSON object into a record doesn't allocate anything
except what the record itself needs, such as its strings and lists.
That works because each parser is built specifically for one type, using
static dispatch. The parser for { name : Str, age : U64 } knows at
compile time that it's looking for exactly those two fields, and it calls the format's methods for
reading a string and a number directly. When the parser is a top-level constant, all of that
assembly happens at compile time.
Encoding works the same way: Json.to_str writes the JSON text directly from the value, without
building an intermediate representation first.
Langref Pattern Matching (`docs/langref/pattern-matching.md`)
Pattern Matching
A pattern describes what a value looks like, and can give names to parts of it. For example,
the pattern Ok(value) matches any Ok tag, and gives the name value to its payload. It
doesn't match an Err tag.
Patterns show up in four places:
- The branches of a
matchexpression - The left side of an
=assignment - Function arguments, like the
{ x, y }in|{ x, y }| x + y - The part of a
forloop betweenforandin
`match`
A match expression evaluates a value, then goes through its branches from top to bottom,
looking for the first one whose pattern matches the value. Then it evaluates that branch's body,
and that's what the whole match expression evaluates to:
describe = |result|
match result {
Ok(value) => "success: ${value}"
Err(_) => "error"
}The value after match only gets evaluated once. All the branches' patterns have to work on the
same type (the type of the value being matched), and all the branches' bodies have to evaluate to
the same type (the type of the whole match).
Names that a pattern introduces, like value above, can only be used in that branch.
Since the first matching branch wins, the order of branches matters when more than one could
match. Here, 0 matches the first branch, not the second:
classify = |number|
match number {
0 => "zero"
_ => "another number"
}Branch Alternatives
You can use | to give several patterns the same body:
is_primary = |color|
match color {
Red | Green | Blue => True
Yellow | Orange | Purple => False
}This works the same way as writing out a separate branch for each pattern, each with the same body.
If the alternatives give names to things, they all have to give the same names, with the same
types. Otherwise the body might refer to a name that the matching alternative didn't provide.
`if` Guards on Branches
A branch can have an if after its pattern, which is called a guard:
describe = |numbers|
match numbers {
[first, .. as rest] if first > 0 => PositiveStart(first, rest)
_ => Other
}The guard only runs if the pattern matched, so it can use names from the pattern (like first
here). If the guard is False, the branch doesn't count as matching after all, and match moves
on to the next branch.
For exhaustiveness purposes, the compiler doesn't count a guarded branch as
covering anything, since its guard might be False. So even _ if condition => … needs another
branch after it.
Kinds of Patterns {#pattern-forms}
Names and `_`
A lowercase name matches anything, and gives that name to the value:
identity = |value|
match value {
anything => anything
}_ also matches anything, but doesn't give it a name. A name that starts with an underscore, like_ignored, does give the value a name, but tells the compiler (and anyone reading the code) that
you don't plan to use it, so you don't get an unused name warning.
Literals
Number and string literals match values that are equal to them:
describe = |value|
match value {
0 => "zero"
1 => "one"
_ => "many"
}Since numbers and strings have way too many possible values to list them all, matching on them
almost always needs a _ branch at the end.
Number literal patterns can have type suffixes, just like number literals
in expressions:
classify : F32 -> Str
classify = |number|
match number {
1.5.F32 => "one and a half"
_ => "another number"
}So can single-quoted characters, which are number literals too:
is_ascii_a = |byte|
match byte {
'A'.U8 => True
_ => False
}True and False are tags, so they're matched the same way as any other tag.
String Patterns with Captures
A string pattern can contain ${…} with a name inside, which matches any text in that position and
gives it that name:
user_id = |path|
match path {
"users/${id}" => Some(id)
_ => None
}Here, "users/42" matches, with id being "42". "posts/42" doesn't match, because it doesn't
start with "users/".
When there's more text after a capture, the capture stops at the first place that text appears.
For example, matching "a/b/c.txt" against "${dir}/${file}.txt" gives dir the value "a" andfile the value "b/c".
You can write ${_} to match some text without naming it. Two captures can't be right next to
each other, as in "${a}${b}", because there would be no way to tell where a should stop and b
should start.
Tags
A tag pattern matches a particular tag, and has a pattern for each of its payloads:
unwrap_or = |result, fallback|
match result {
Ok(value) => value
Err(_) => fallback
}The payload patterns can be any kind of pattern, including other tags. For example,Ok(Pair(left, right)) matches an Ok whose payload is a Pair tag, and names the Pair's two
payloads.
Records
A record pattern names a record's fields:
full_name = |person| {
{ first, last } = person
"${first} ${last}"
}You can use : to give a field's value a different name, or to match it against another pattern:
name = |person|
match person {
{ name: displayed_name, active: True } => Some(displayed_name)
{ active: False, .. } => None
}A record pattern only matches records with exactly the fields it lists, unless it ends in ..,
which means "and any other fields." If you write a name after the .., that name gets a record
containing all the other fields:
remove_name = |person|
match person {
{ name, ..rest } => Pair(name, rest)
}
answer = remove_name({ name: "Sam", age: 32 }) # Pair("Sam", { age: 32 })Tuples
A tuple pattern has a pattern for each element:
swap = |pair| {
(first, second) = pair
(second, first)
}Like tuples themselves, tuple patterns have at least two elements. See
Destructuring Tuples.
Lists {#list-patterns}
A list pattern matches lists of a particular length:
describe = |items|
match items {
[] => Empty
[only] => One(only)
[first, second] => Two(first, second)
[_, _, ..] => Many
}.. matches any number of elements (including zero), so [_, _, ..] matches any list with at
least two elements. A list pattern can have one .., and it can be at the beginning, the middle,
or the end:
ends = |items|
match items {
[first, .., last] => Some(Pair(first, last))
_ => None
}(Note that [first, .., last] doesn't match a list with only one element, since first and last
are two separate elements.)
You can write .. as name to give a name to the elements that .. matched, as a list:
split_first = |items|
match items {
[] => None
[first, .. as rest] => Some(Pair(first, rest))
}Nested Patterns and `as`
Patterns can go inside other patterns, the same way values can go inside other values:
get_name = |response|
match response {
Ok({ user: { name } }) => Some(name)
Err(_) => None
}You can write as and a name after a pattern, to give a name to the whole value in addition to
its parts:
keep_point = |value|
match value {
(x, y) as point => { x, y, point }
}Nominal Types
To match a nominal type's backing value, you write the type's name, then .,
then a pattern in parentheses:
Distance := U64
unwrap : Distance -> U64
unwrap = |distance|
match distance {
Distance.(meters) => meters
}For nominal records, you can leave off the parentheses:
Point := { x : U64, y : U64 }
sum : Point -> U64
sum = |Point.{ x, y }| x + y(Point.({ x, y }) also works, but Point.{ x, y } is the usual way to write it.) For nominal tag
unions, you can match the tags directly, as in Red => or Color.Red =>. See
Qualified Tags.
Exhaustiveness
A match has to handle every possible value. If it doesn't, you get a compile-time error that
shows some example values that aren't handled.
For example, this match is exhaustive (it handles every possible value), because a Try is
always either Ok or Err:
is_ok = |result|
match result {
Ok(_) => True
Err(_) => False
}The compiler also tells you about branches that can never match, because every value they'd
match was already handled by earlier branches. Here, the Ok(_) branch can never be reached:
match result {
_ => "handled"
Ok(_) => "unreachable" # This gives a warning.
}Catch-all Patterns (`_`) {#underscore}
A catch-all pattern matches every value. _ is the most common one. A plain name, like other,
is also a catch-all; the difference is that other gives the value a name you can use in the
branch's body.
Since a catch-all matches everything, any branch after it can never match, which is why catch-all
branches go last.
Destructuring
Patterns outside match (in assignments, function arguments, and for loops) don't have other
branches to fall back on. So they have to match every possible value of their type. If one
doesn't, you get a compile-time error rather than a runtime crash.
Destructuring Assignments (with `=`)
The left side of an = can be a pattern:
coordinates = |point| {
{ x, y } = point
(x, y)
}This is useful for records, tuples, nominal types, and tag unions that only have one tag. It
doesn't work with patterns that might not match. For example, Ok(value) = result gives an error
if result could be an Err; use a match instead, so you can say what to do in that case.
(If the error type is the empty tag union, as in Try(U64, []), then result
can't be an Err, so Ok(value) = result is allowed.)
Function arguments are patterns too:
sum_point = |{ x, y }| x + ySo is the part of a for loop between for and in:
var $total = 0
for (key, value) in [(1, 2), (3, 4)] {
$total = $total + key + value
}If different elements need different handling, use a match inside the loop.
Performance
Matching on a value never allocates memory or copies the value. The compiler turns a whole match
into a single decision tree that checks each part of
the value at most once, so having lots of branches doesn't mean checking the same thing over and
over. Some details for specific kinds of patterns:
- Tags compile to a check of the tag union's discriminant, which is
a small number. Matching on many tags can compile to a single jump to the right branch, like aswitchstatement in C. - Records and tuples cost nothing to destructure. Their fields are at known offsets, so naming
a field is just reading it. - Lists check the list's length first, then the elements the pattern needs. Naming the rest of
a list with.. as restdoesn't copy any elements;restrefers to part of the original list's
memory (and keeps that memory alive untilrestis no longer used). - Strings compare bytes. A string pattern with captures has to search for the text after each
capture, which takes time proportional to the length of the string being matched.
Langref Platforms (`docs/langref/platforms.md`)
Platforms
Something that sets Roc apart from other programming languages is its platforms and applications architecture. Every Roc application is built on exactly one platform, and that platform (not Roc's standard library) provides all of the application's I/O primitives.
Applications
Here is a Roc application that prints "Hello, World!" to the command line:
app [main!] { pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/1.0.0/AnZoxzoGPtSGQ15EQh6pBeeaHJ7aizP9MQhK81dES3Uq.tar.zst" }
import pf.Stdout
main! = |_args| {
Stdout.line!("Hello, World!")?
Ok({})
}This application is built on a platform called roc-platform-template-zig, a starter platform written in Zig and Roc. Packages can also declare this exact platform and build reusable functionality on its exposed API. The final application still provides everything in the platform's requires section. See Application Modules, Package Modules, and Platform Modules for the corresponding headers.
Domain-specific functionality
Roc platforms provide domain-specific functionality that multiple applications can use as a foundation to build on, much like game engines and web frameworks do.
Also like many game engines and web frameworks, a Roc platform has a high-level Roc API which presents a nice interface to a lower-level implementation (written in a different language). That lower-level implementation provides the foundational primitives the platform needs to operate, such as a C++ 3D rendering system in a game engine, or a Rust HTTP networking system in a web framework.
Some example platforms, and functionality they might provide:
- A game engine platform might provide functionality for rendering and sound.
- A web server platform probably would provide functionality for responding to incoming HTTP requests, which a game engine platform likely would not.
- A native GUI platform might provide functionality for defining native operating system UI elements, whereas a game engine platform might focus more on rendering with shaders, and a web server platform would not have GUI functionality at all.
Platforms can be much more specific than these broad domains. For example, anyone could make a platform for writing Vim plugins, or Postgres extensions, or robots (which has already happened), or even implementing servo logic for a clock that physically turns panels to simulate an LCD. You really can get as specific as you like.
Platforms can also be designed to have a single, specific application run on them. For example, you can make a platform that is essentially "your entire existing code base in another language," and then use Roc as an embedded language within that code base. Vendr used this strategy to call Roc functions from their Node.js backend using roc-esbuild, as a way to incrementally transition code from Node to Roc.
Platform scope
Roc platforms have a broader scope of responsibility than game engines or web frameworks. In addition to providing a nice domain-specific interface, platforms are also responsible for:
- Tailoring memory management to that domain (see Memory management)
- Providing all I/O primitives
In most languages, I/O primitives come with the standard library. In Roc, the standard library contains only functions and data structures; an application gets all of its I/O primitives from its platform. For example, in the "Hello, World" application above, the Stdout.line! function comes from the roc-platform-template-zig platform itself, not from Roc's standard library.
This design has a few benefits.
Ecosystem benefits
Some I/O operations make sense in some use cases but not others.
For example, suppose you are building an application on a command-line platform, and you use a third-party package which sometimes blocks the program while it waits for standard input. This might be fine for a command-line application, but it would probably be a poor fit for a web server. Similarly, a package which does some occasional file I/O for caching might work fine on either of those platforms, but might break in surprising ways when used on a platform designed to run in a browser on WebAssembly, since browsers do not offer arbitrary file I/O access.
Because Roc's I/O primitives come from platforms, these mismatches can be prevented at build time. A browser-based platform would not expose file I/O primitives, a web server would not expose a way to block on reading from standard input, and so on.
Security benefits
Since platforms have exclusive control over all I/O primitives, one of the things they can do is create domain-specific security guarantees around them. For example, a platform for writing text editor plugins might display a prompt to the end user before performing any file I/O operations outside the directory that is currently open in the editor.
This talk shows an example of taking this idea a step further with a "safe scripting" platform for writing command-line scripts. The idea is that you could download a script from the Internet and run it on this platform without worrying that the script would do bad things to your computer, because the platform would (much like a web browser) show you specific prompts before allowing the script to do potentially harmful I/O, such as filesystem operations.
These security guarantees can be relied on because platforms have exclusive control over all I/O primitives, including how they are implemented. There are no escape hatches that a malicious program could use to get around them. For example, Roc programs that want to call functions in other languages must do so using primitives provided by the platform, which the platform can disallow (or sandbox with end-user prompts) in the same way.
Performance benefits
Many I/O operations can benefit from being run concurrently. Since platforms are in charge of how those I/O operations are implemented, they can also determine how they are scheduled. This means that both applications and packages can describe which operations they want to run concurrently, and then the platform can optimize the scheduling of these operations using its domain-specific knowledge.
For example, a command-line platform might schedule concurrent operations across all available cores (or some lower number specified by a command-line argument). In contrast, a web server platform might try to balance available cores across multiple request handlers, to prevent undesirable scenarios like one handler getting all the cores (meaning none of the others can progress).
Note that although platform-implemented scheduling of concurrent operations is possible in principle, some pieces needed to make it practical for platform authors to implement haven't been built yet.
How platforms are implemented
To understand how platforms can tailor automatic memory management to their particular domain, it helps to understand how platforms are implemented.
Host {#host}
Each platform consists of two parts:
- The Roc API is the part that application authors see. For example,
Stdout.line!is part of the Roc API of roc-platform-template-zig. It is defined by the platform's platform module and the type modules it exposes. - The host is the under-the-hood implementation written in a language other than Roc. For example, the host for roc-platform-template-zig is written in Zig. It has a Zig function which implements the behavior of the
Stdout.line!operation, and all the other I/O operations it supports.
This design means application and package authors do not necessarily need to know (or care) about the non-Roc language being used to implement the platform's host. That can be a behind-the-scenes implementation detail that only the platform's authors are concerned with. Applications and platform-specific packages interact only with the public-facing Roc API.
The platform module's provides section is what maps the symbol names Roc links against in the host to the Roc functions exposed under those symbols.
Memory management
Host authors implement not only the platform's I/O primitives, but also functions for memory allocation and deallocation. In C terms, the host provides malloc and free implementations which the compiled Roc application will automatically call whenever it needs to allocate or deallocate memory.
The same talk mentioned earlier demonstrates some benefits of this, such as being able to get accurate diagnostics on how much memory the Roc part (or even specific Roc parts) of a running program are using.
The bigger benefit is tailoring memory management itself based on the domain. For example, nea is a work-in-progress web server which performs arena allocation on each request handler. In Roc terms, this means the host's implementation of malloc can allocate into the current handler's arena, and free can be a no-op. Instead, the arena is reset when the response has been sent.
In this design, heap allocations in a web server running on nea are about as cheap as stack allocations, and deallocations are essentially free. For the server's throughput, latency, and predictability, this is much better than (for example) having to pay for periodic garbage collection.
Program start
When a compiled Roc program runs, it is actually the host, not the Roc application, which starts running first. In C terms, the host implements main(), and then at some point it calls a function exposed by the compiled Roc application.
Knowing this, a useful mental model for how Roc platforms and applications interact at the implementation level is: the Roc application compiles down to a C library which the platform can choose to call (or not).
This is essentially what happens behind the scenes when you run roc build. Specifically:
- The Roc compiler builds the Roc application into a binary object file.
- Since that application specified its platform, the compiler then looks up the platform's host implementation (which the platform will have provided as an already-compiled binary).
- Now that it has a binary for the Roc application and a binary for the host, it links them together into one combined binary in which the host portion calls the application portion as many times as it likes.
This process works for small platforms and large applications (for example, a very large web server application) as well as for large platforms and small applications (for example, a very large C++ game which serves as a platform for a small amount of Roc application code that the game uses for scripting).
Summary
Every Roc application has exactly one platform. Packages in its dependency graph may use that platform when they declare the identical platform version and content hash. The platform provides all the I/O primitives the application and those packages can use; Roc's standard library provides no I/O operations, and the only way for Roc code to execute functions in other languages is if the selected platform offers a way to do that.
This I/O design has security benefits, ecosystem benefits, and performance benefits. The domain-specific memory management that platforms can implement can offer additional benefits as well.
Applications interact only with the Roc API portion of a platform, but there is also a host portion (written in a different language) that works behind the scenes. The host determines how the program starts, how memory is allocated and deallocated, and how I/O primitives are implemented.
Anyone can implement their own platform. There is not yet an official guide about how to do this, but there are some useful examples:
Langref Records (`docs/langref/records.md`)
Records
A record is a group of values where each value has a name. For example:
person = { name: "Sam", age: 32 }Here, person is a record with two fields: name (whose value is the string "Sam")
and age (whose value is the number 32).
Records are a good fit when you know in advance exactly which values you'll have, and
each one means something different. If you don't know the names until runtime, you
want a dictionary instead.
Fields
Record Literals
A record literal is a list of fields between { and }, separated by commas. Each
field is a lowercase name, a :, and an expression:
book = {
title: "The Left Hand of Darkness",
pages: 304,
available: True,
}Fields can hold any expression, including other records:
user = {
name: "Kim",
address: { city: "Oslo", postal_code: "0150" },
}The trailing comma is optional, but it affects formatting: if there's a trailing comma,roc fmt puts each field on its own line. If there isn't one, it puts the whole record
on one line.
If you already have a name in scope that matches the field name, you can write just
the name. { name, age } is syntax sugar for { name: name, age: age }:
make_person = |name, age| { name, age }There's one exception. { name } is a block expression
that evaluates to name, not a record. To make a single-field record this way, add a
trailing comma: { name, }.
Note that this exception exists because
{ x }comes up all the time as a block,
for example inelse { x }, whereas single-field records written with the
shorthand are rare. Making the common case the convenient one seemed like the
better tradeoff.
Accessing Fields
You can access a field with . followed by the field's name:
city = user.address.cityIf the record doesn't have that field, you get an error at compile time. There's no
such thing as a runtime "field not found" error, because the compiler always knows
exactly which fields a record has.
You can also destructure a record to name several of
its fields at once:
full_name = |person| {
{ first, last } = person
"${first} ${last}"
}A record pattern like { first, last } only matches records with exactly those fields. To
match a record that might have other fields too, put .. at the end:
first_name = |person| {
{ first, .. } = person
first
}Record Types
A record type looks like a record literal, except with types instead of values, and: with spaces around it:
Person : { name : Str, age : U64 }
alice : Person
alice = { name: "Alice", age: 30 }Here, Person is a type alias. It's just another name for{ name : Str, age : U64 }, so any record with those fields (and those field types)
is a Person.
The order of fields doesn't matter. These are the same type:
PersonA : { name : Str, age : U64 }
PersonB : { age : U64, name : Str }Record Equality
Two records are equal (according to ==) if all of their fields are equal. Field order
doesn't matter here either, so { a: 1, b: "x" } == { b: "x", a: 1 } is True.
Nominal records are different. They don't get == automatically;
you either define an is_eq method yourself or ask the compiler to
derive one.
Updating Records
Records are values, so you can't change one. What you can do is
make a new record that's the same as an old one, except for some fields. The syntax
for this is .. followed by the original record:
have_birthday = |person| {
..person,
age: person.age + 1,
}This returns a new record with all the fields of person, except with age replaced.
The original person is unaffected. (Behind the scenes, the compiler can often reuse
the original's memory; see Performance.)
Record updates can only replace fields that already exist, and the new value has to
have the same type as the old one. Both of these give errors at compile time:
renamed = { ..person, nickname: "Sam" } # ERROR! person has no nickname field
aged = { ..person, age: "old" } # ERROR! age is a number, not a StrIf you want a record with different fields, write it out:
with_display_name = |person| {
name: person.name,
age: person.age,
display_name: "${person.name} (${person.age.to_str()})",
}This restriction means that when you see { ..record, field: value }, you always know
the result has the same type as record.
Compared to Dictionaries
Records and dictionaries both associate names (or keys) with values, but
they're for different situations:
- A record's fields are decided at compile time. A dictionary can gain and lose entries at runtime.
- A record's fields can each have a different type. All the values in a dictionary have the same type.
- A record's field names are always lowercase names written in the source code. A dictionary's keys
are values, and they can be any type that supports hashing. - Accessing a record's field always succeeds, because the compiler has already checked that the
field is there. Looking up a key in a dictionary returns aTry, because the key might not be
there. - Accessing a record's field is just reading memory at a known location. Looking up a key in a
dictionary means hashing the key and comparing it to the keys the dictionary has.
So if you know in advance exactly which fields you'll have, and each one means something different,
use a record. If the keys are only known at runtime, use a dictionary.
Structural Records
Records are structural by default, which means:
- You don't need to declare a record type before using it.
{ x: 0, y: 0 }works on its own. - Two record types are the same if they have the same field names and field types.
So a function that takes { x : F64, y : F64 } accepts any record with exactly
those two fields, including a record literal written right there in the call:
distance_squared : { x : F64, y : F64 } -> F64
distance_squared = |point| point.x * point.x + point.y * point.y
answer = distance_squared({ x: 3, y: 4 })Open Record Types
The record type { name : Str } means "a record with a name field, and no other
fields." If you want to say "a record with a name field, and possibly others," put.. at the end:
get_name : { name : Str, .. } -> Str
get_name = |record| record.name
name = get_name({ name: "Ari", age: 41 }) # This is fine, even though it has an age field.{ name : Str } is a closed record type, and { name : Str, .. } is an open
record type.
If you need to refer to "the other fields" elsewhere in the type, give them a name
after the ..:
set_name : { name : Str, ..others }, Str -> { name : Str, ..others }
set_name = |record, name| { ..record, name }This says that whatever other fields came in also go out. (A plain .. is the same
thing, except there's no name to refer to.)
Note that open record types describe what a function accepts. They don't make records
themselves flexible at runtime. Every record that actually gets created still has a
specific set of fields known at compile time, and the compiler makes a separate copy ofget_name for each of those sets of fields.
Optional Fields
An optional field is a field that might not be there at runtime. You write it with?: instead of : in the record type:
Attributes : { count : U64, label ?: Str }
labeled : Attributes
labeled = { count: 3, label: "new" }
unlabeled : Attributes
unlabeled = { count: 3 } # This is fine, because label is optional.Since the field might be missing, you can't access it with . like a normal field.
Instead, you use .?, which gives you a Try:
read_label : Attributes -> Try(Str, [MissingField])
read_label = |attributes| attributes.?labelIf the field is there, you get Ok with its value. If it isn't, you getErr(MissingField). The ?? operator is a
convenient way to provide a value for the missing case:
display_label = |attributes| attributes.?label ?? "untitled"You can keep accessing fields after a .?. If any optional field along the way is
missing, the whole thing is Err(MissingField):
city : { address ?: { city : Str } } -> Try(Str, [MissingField])
city = |person| person.?address.cityOptional fields are for when it matters at runtime whether the field was provided,
such as when a field might or might not appear in some JSON you're parsing.
If you just want a field to have a default value when someone doesn't provide it,
use a defaulted field instead.
Nominal Records
A nominal record type has a name, and no other type is considered the same as it,
even a record type with exactly the same fields. You declare one with :=:
Point := { x : F64, y : F64 }To create a Point, you can write the type's name before the record:
origin = Point.{ x: 0, y: 0 }If the compiler already knows a Point is expected (for example, because of a type
annotation), you can leave off the name:
unit_x : Point
unit_x = { x: 1, y: 0 }Since a nominal record has a name, it can also have methods:
Point := { x : F64, y : F64 }.{
origin : Point
origin = Point.{ x: 0, y: 0 }
translate : Point, F64, F64 -> Point
translate = |point, dx, dy| {
..point,
x: point.x + dx,
y: point.y + dy,
}
}Code outside the declaration can use these as Point.origin andpoint.translate(dx, dy). There's more about nominal types in general on the
types page.
Opaque Nominal Records
If you declare a nominal record with :: instead of :=, it's opaque. Outside the
module where it's declared, nobody can see its fields. That means they can't access
them, create the record with a record literal, or destructure it. All they can do is
use the functions you expose:
Account :: { balance : U64 }.{
new : U64 -> Account
new = |balance| Account.{ balance }
balance : Account -> U64
balance = |Account.{ balance }| balance
}This is useful when you want to enforce rules about what's in a record. For example,
if an Account should never be created with a balance above some limit, new is the
only way to create one, so new is the only place that needs to check the limit.
(The types page covers opaque types in general.)
Defaulted Fields
A defaulted field is a field in a nominal record that you can leave out when creating
the record, in which case it gets a default value. You write the default after ??:
RequestOptions := {
retries : U8 ?? 3,
timeout_ms : U64 ?? 5000,
}Now you can leave out either field (or both):
standard = RequestOptions.{} # retries is 3, timeout_ms is 5000
patient = RequestOptions.{ timeout_ms: 30000 } # retries is 3, timeout_ms is 30000Once the record has been created, a defaulted field is just a normal field. It's always
there, so you access it with . like any other field:
attempts = standard.retriesThe default can be any expression, as long as it doesn't call any
effectful functions:
CacheOptions := {
capacity : U64 ?? {
kibibytes = 1024
8 * kibibytes
},
}The default gets evaluated each time a record is created without that field.
Defaults can't depend on each other in a cycle, and they can't affect what the
nominal type's type parameters are (if it has any).
Note that defaulted fields and optional fields answer different
questions. A defaulted field asks "what should this be if nobody says?" and the
answer is always a value. An optional field asks "did anybody say?" and the answer
might be no, which is why accessing it gives you aTry.
The Empty Record (`{}`) {#empty-record}
{} is a record with no fields. It's also the name of its own type:
empty : {}
empty = {}There's exactly one value of type {}, which is {}. That makes it useful when you
need to provide a value, but there's no information to provide.
Note that {} is a closed record type with no fields, whereas { .. } is an open
record type that accepts any record at all.
If you want a module that's just a namespace for some functions, and you never want
anyone to create a value of the module's type, use the empty tag union [] as the
backing type instead of {}. There are no values of type [], so there can be no
values of your type either:
Math :: [].{
double : U64 -> U64
double = |n| n * 2
}
answer = Math.double(21)Performance
Memory Layout
A record's fields are stored next to each other in memory, much like a
struct in C. The record
doesn't get its own heap allocation and doesn't have a
reference count. It takes up exactly as much space
as its fields do, plus any padding needed for alignment.
None of the record's field names exist at runtime. When you write user.name, the
compiler already knows exactly how many bytes from the start of the record name is,
so accessing a field is as fast as reading memory at a known offset. (This is one of
the big performance differences between records and dictionaries. Looking something
up in a dictionary means hashing the key and comparing it
against the keys stored in the dictionary.)
Fields that are themselves heap-allocated, like strings and lists, work the same way
they do anywhere else. For example, on a 64-bit target, a Str field takes up 24
bytes in the record. If the string is 23 bytes or shorter, its contents fit right
there in those 24 bytes; otherwise, its contents live in a separate heap allocation. The record holds the string, but the
string manages its own memory.
Here are the sizes of some common field types:
| Type | 32-bit size and alignment | 64-bit size and alignment |
|---|---|---|
U8 |
1 byte, aligned to 1 | 1 byte, aligned to 1 |
U32 |
4 bytes, aligned to 4 | 4 bytes, aligned to 4 |
U64 |
8 bytes, aligned to 8 | 8 bytes, aligned to 8 |
Str |
12 bytes, aligned to 4 | 24 bytes, aligned to 8 |
List(a) |
12 bytes, aligned to 4 | 24 bytes, aligned to 8 |
Box(a) |
4 bytes, aligned to 4 | 8 bytes, aligned to 8 |
Field Order and Padding
The order you write fields in has no effect on how they're laid out in memory. The
compiler sorts them by alignment, from most strictly aligned to least, and sorts fields
with the same alignment alphabetically by name. This puts as little padding between
fields as possible.
For example, { flag : Bool, count : U64, id : U32 } is laid out as count, thenid, then flag. That's 16 bytes total (8 + 4 + 1, rounded up to a multiple of 8).
If the fields were stored in the order they were written, it would be 24 bytes, becausecount would need 7 bytes of padding before it.
This means you never need to reorder a record's fields to make it smaller. The
compiler has already done it.
Optional Fields Take Up More Space
At runtime, an optional field is stored like a tag union with two tags:
one for when the field is present (holding its value) and one for when it's missing.
That takes the space of the value, plus one byte to say which tag it is, plus padding
to keep the next value aligned.
For example, on a 64-bit target, label : Str takes 24 bytes, but label ?: Str takes
32. If you're storing a lot of records in a list and you don't
actually need to know whether a field was provided, a defaulted field
takes no extra space.
Copying and Updating
Since records are stored inline, passing a record around means its bytes get copied
around. For small records, that's cheap. It's also cheap for records whose fields are
strings or lists, because only the small fixed-size part of each string or list gets
copied, not its contents.
When a record contains heap-allocated fields and you use the record somewhere it's
still needed later, those fields' reference counts get incremented. This works the
same way as using a string or list directly.
Updating a record with { ..record, field: value } creates a new record. If the
original record isn't used again afterward, the fields that didn't change (including
any strings and lists in them) get moved into the new record without touching their
reference counts.
Records at the Host Boundary
Most of the time, the compiler's field order is exactly what you want. The exception
is when a platform needs a record's memory to match a struct in some other
language, which expects fields in the order they were written.
For this, a nominal record can include a field named _. This makes the compiler lay
out the record's fields in the order you wrote them, with padding inserted the way C
would insert it:
Header := {
tag : U8,
_ : {},
value : U32,
}The _ : {} field takes up no space; it's only there to say "use the order I wrote."
So tag is at offset 0, followed by three bytes of padding, and then value is at
offset 4. (Without the _ field, value would come first, because it has the stricter
alignment.)
A _ field with a type other than {} reserves that many bytes of padding. These
bytes don't hold a value and can't be accessed:
Padded := {
tag : U32,
_ : U32,
value : U32,
}Here, tag is at offset 0, four reserved bytes are at offset 4, and value is at
offset 8. Note that a reserved field is always aligned to 1 byte, regardless of its
type. You can also give reserved fields names that start with _, like _reserved.
Only nominal records can have _ fields.
Note that platform authors shouldn't compute these offsets by hand.
roc glue
generates host code using the exact layout the compiler chose for each target, on
both 32-bit and 64-bit targets.
Langref Statements (`docs/langref/statements.md`)
Statements
Statements are run as soon as they are encountered at runtime.
They do not evaluate to a value.
[`=` (assignment)](#assignment) {#assignment}
An assignment statement gives a name to a value inside the current scope.
answer = 42The name an assignment gives must be a valid Roc lowercase name.
[Type Annotations](#type-annotations) {#type-annotations}
You can put a type annotation on the line before an assignment,
using the same name followed by : and a type:
answer : U64
answer = 42This works the same way at the top level of a module and inside a function body. The
annotation is optional; without it, the compiler infers the type. With it, you get a
compile-time error if the value doesn't have that type, which is useful both as
documentation and for getting errors reported where you meant something to be a certain type,
rather than wherever the mismatch happens to show up later.
[Pattern matching in assignments](#assignment-patterns) {#assignment-patterns}
You can use pattern matching in assignments to do things like destructuring:
(x, y) = (1.1, 2.2)The pattern you use here must be exhaustive. For example, the following would give an exhaustiveness error because it doesn't specify what to do if list.first() returned an Err instead of Ok:
Ok(item) = list.first()If you can't write an exhaustive pattern-match, you can use match instead of an assignment.
[Assignment Order](#assignment-order) {#assignment-order}
Assignments inside expressions can only reference names that were assigned earlier in scope.
For example, this would be an error:
foo({
y = z + 1
z = 5
z + 1
})However, at the top level of a module, assignments can reference each other
regardless of declaration order:
x = y + 1
y = 5[Assignment Cycles](#assignment-cycles) {#assignment-cycles}
Top-level assignments can only mutually reference each other if they are all assigning to functions.
This gives an error at compile time:
x = y + 1
y = x + 1(If it did not give an error at compile time, it would either crash or loop infinitely at runtime.)
In contrast, this gives no error because all the assignments in the cycle are assigning to functions:
x = |arg| if arg >= 1 { y(arg + 1) } else { 0 }
y = |arg| if arg <= 9 { x(arg + 1) } else { 0 }[Reassignment with `var`](#reassignment) {#reassignment}
Reassigning to an existing name is only allowed when the name was declared withvar. This is allowed:
var $foo = 0
$foo = 1However, this gives a shadowing warning:
foo = 0
foo = 1Calling a method on a var doesn't change it. A method like List.append returns
an updated copy, so to update the var, you reassign it to that copy:
var $names = []
$names = $names.append("Sam")Calling the method without reassigning, as in $names.append("Sam") on a line by
itself, gives a warning, because the updated copy is thrown away and $names stays
the same.
An expect cannot reassign a var declared outside of it. (See
control flow and variables inside expect.)
[`import`](#import) {#import}
The import statement imports a type into scope from a type module.
import Color
import json.ParserSee import statements for details on how imports work.
[`import` with `exposing`](#import-exposing) {#import-exposing}
Adding exposing and a list of names to an import brings those associated items of the
imported type into scope directly, so they can be used without the type's name in front:
import Color exposing [to_str, all]
red_str = to_str(Red) # instead of Color.to_str(Red)The type itself is still imported as usual, so Color.to_str also continues to work.
[Renaming Imports with `as`](#renaming-imports) {#renaming-imports}
Adding as and an uppercase name to an import makes the imported type
available under that name instead of its original name:
import json.Parser as JsonParser
parser : JsonParserThis is useful when two imported types would otherwise have the same name, or when a name is
long and used often.
[Importing non-Roc files](#importing-non-roc-files) {#importing-non-roc-files}
An import can also bring the contents of an arbitrary file into scope, by giving the file's path
as a string, then as, then a name and a type:
import "config.json" as config_text : Str
import "logo.png" as logo_bytes : List(U8)The path is relative to the .roc file containing the import. The file is read at compile time,
and its contents are embedded in the compiled program as a constant, so the program doesn't need
to read the file at runtime (and the file doesn't need to exist when the program runs).
The type must be either Str or List(U8):
Strgives the file's contents as a string. It's a compile-time error if the file isn't valid UTF-8.List(U8)gives the file's raw bytes, which works for any file, including binary files such as images.
It's a compile-time error if the file can't be found.
[`dbg`](#dbg) {#dbg}
The dbg statement prints the value of an expression, for debugging purposes:
total = price * quantity
dbg totalWhen this runs, it prints something like [dbg] 42 to the program's standard error. The value is
printed using Str.inspect, so dbg works on values of any type which has an
inspect representation (including the to_inspect method described in
static dispatch).
dbg can be used anywhere statements can appear, including inside pure functions.
This is allowed because dbg output is only for the programmer, so program behavior never depends on it.
dbg statements that run during compile-time evaluation print their output when
the program is compiled (for example, during roc check or roc build), rather than when the compiled
program runs. dbg statements that run at runtime print their output each time they run.
dbg is intended for temporary debugging, so building an optimized program (which is what roc build
does by default) gives a warning for each dbg statement it contains.
[`expect`](#expect) {#expect}
The expect statement states that a Bool expression should evaluate to True.
It can be used for tests, and for checking assumptions at runtime.
Top-level `expect`
An expect at the top level of a module is a test:
double = |n| n * 2
expect double(21) == 42Top-level expects are run by the roc test command, which reports how many passed and failed.
When one fails, the report shows the failing expression, along with the values of any top-level
names it referenced.
roc test runs the top-level expects in the given module and in every module it imports (including
modules in packages that are imported through a filesystem path, but not packages downloaded
from a URL, since those expects are the responsibility of the package's author).
Inside a top-level expect, the ? operator causes
the expect to fail if its expression evaluates to an Err, rather than returning early from a function.
If running a top-level expect reaches code that has a compilation error (such as a type
mismatch), roc test counts that expect as a compiler error rather than as passed or failed. This
applies even when the error is only reached indirectly, for example inside a function the expect
calls, or inside a method that == or a parser calls. An expect that calls a function
containing an error, but never reaches the part with the error, runs as usual. The error itself is
reported once. See Code With Errors.
Inline `expect`
An expect inside a function body checks an assumption each time that code runs:
withdraw = |balance, amount| {
expect amount <= balance
balance - amount
}If an inline expect's condition evaluates to False, the failure is reported, and then
the program continues running as if the expect had not been there. (Unlikecrash, a failed expect does not stop the program.) Exactly how the failure is
reported is up to the platform.
Inline expects are for catching bugs during development, so they are omitted from optimized
builds (such as the default for roc build). As such, programs should never depend on
an expect running.
[Control flow and variables inside `expect`](#expect-control-flow) {#expect-control-flow}
Since programs should never depend on an expect running, an expect cannot change where
the program goes next. These give a compile error when used directly inside an expect:
return, in both top-level and inlineexpectsbreakto exit a loop that's outside theexpect, in both top-level and inlineexpects- the
?operator, in inlineexpects
(in top-levelexpects, it makes theexpectfail instead, as described above)
These rules don't apply inside a function defined within the expect, since leaving that
function doesn't leave the expect. Similarly, break is allowed inside a loop that's
written within the expect:
expect {
parse = |str| {
n = U64.from_str(str)? # Fine: returns early from `parse`, not from the `expect`
Ok(n * 2)
}
parse("21") == Ok(42)
}For the same reason, an expect cannot change a variable declared outside of it. Reassigning
one inside an expect gives a compile error, but variables declared inside the expect can be
reassigned freely:
withdraw = |balance, amount| {
var $remaining = balance
expect {
$remaining = $remaining - amount # Error: `$remaining` was declared outside this `expect`
$remaining >= 0
}
expect {
var $left = balance
$left = $left - amount # Fine: `$left` was declared inside this `expect`
$left >= 0
}
$remaining - amount
}[`return`](#return) {#return}
The return statement immediately exits a function, returning the given value.
my_func = |arg| {
if arg == 0 {
return 0
# This line will never be reached.
}
arg - 1
}[`break`](#break) {#break}
The break statement immediately exits the innermost for or while loop. See loops for details.
[`continue`](#continue) {#continue}
(This has not been implemented yet. It will continue to the next iteration of a for or while loop.)
[`crash`](#crash) {#crash}
A crash statement crashes the running application. All code following the crash
becomes unreachable and will not be executed.
if some_condition {
crash "There is no way this program could possibly continue."
}
# This line will never be reached if `some_condition` was `True`What happens after a crash is determined by the platform. Some may gracefully recover
and have some way of continuing the process, but others may terminate the process immediately.
[Block Statements](#block-statements) {#block-statements}
A block statement is a group of statements which has its own scope, so
anything assigned in it can't be accessed outside the block.
It's different from a block expression in that
a block statement does not have an expression at the end. A common block
statement is one that does an early return in a conditional branch:
if foo {
…
} else {
bar = …
return bar
}Having a single statement in a block statement is allowed:
if foo {
…
} else {
return bar
}Langref Static Dispatch (`docs/langref/static-dispatch.md`)
Static Dispatch
Dispatch is where the same call expression can result in a different function being run,
depending on the types of its arguments and/or return value. It's a form of ad hoc polymorphism.
Static dispatch is where only types known
at compile time affect which function gets run. This is in contrast to dynamic dispatch,
which uses runtime information to decide which function gets run.
Roc's only ad hoc polymorphism system is static dispatch, and dynamic dispatch is unsupported
by design. A major reason for this is that Roc's static dispatch has no runtime overhead;
after compilation, it's exactly as if you had called the function directly. (In contrast,
it's impossible to avoid runtime overhead in dynamic dispatch, because it has to process
information at runtime to do the dispatch.)
Methods
A method is a function that's associated with a type. You define methods for a
nominal type in the .{ … } block after its declaration:
Counter := { value : I64 }.{
new : () -> Counter
new = || { value: 0 }
increment : Counter -> Counter
increment = |{ value }| { value: value + 1 }
}Calling Methods
There are two ways to call a method. One is to write the type's name, then ., then the method's
name, like any other qualified function call:
counter = Counter.increment(Counter.new())The other is to write a value, then ., then the method's name, and then the rest of the
arguments:
counter = Counter.new().increment()Here, .increment() looks at the type of the value before the ., sees that it's a Counter,
and calls Counter.increment with that value as the first argument. So value.method(a, b) is
the same as Type.method(value, a, b), where Type is the type of value.
This is static dispatch: the compiler decides which increment to call based on the type ofcounter, which it knows at compile time. If you call .increment() on a value whose type has
no increment method, you get a compile-time error.
The builtin types work the same way. For example, list.len() calls List.len,
and name.is_empty() calls Str.is_empty.
Well-Known Methods
Most method names don't mean anything special to the compiler. However, some of them are used by
syntax or by builtin functions. For example, a + b calls a.plus(b), so defining a plus
method on your type means you can use + with it.
Here are all the method names that the language or the builtins use:
| Method | Used by | Define it when |
|---|---|---|
to_inspect : T -> Str |
Str.inspect(value), dbg |
You want a custom debug representation. |
is_eq : T, T -> Bool |
==, != |
You want values of the type to be comparable for equality. |
to_hash : T, Hasher -> Hasher |
Dict, Set, and other hash-based APIs |
You want values of the type to be usable as dictionary keys or set elements. |
plus, minus, times, div_by, div_trunc_by, rem_by |
+, -, *, /, //, % |
The type has arithmetic-like operations. |
is_lt, is_lte, is_gt, is_gte |
<, <=, >, >= |
The type has an ordering. |
range_exclusive_to : T, T -> Range(T), range_inclusive_to : T, T -> Range(T) |
..<, ..= |
You want range syntax to work with the type. |
range_exclusive_from : T, T -> Range(T), range_inclusive_from : T, T -> Range(T) |
Range.iter_rev |
The type's ranges can be reversed exactly. |
range_iter |
Range.iter, Range.iter_rev |
The type's ranges can be iterated. |
range_len_if_known |
Numeric range constructors, Range.step_by |
The type can say how many numbers are in a range (when that fits in a U64). |
negate, not |
Unary -, unary ! |
The type has a negation or complement operation. |
from_numeral : Numeral -> Try(T, [InvalidNumeral(Str)]) |
Number literals | You want number literals to work as values of the type. |
from_quote : Str -> Try(T, [BadQuotedBytes(Str)]) |
String literals | You want string literals to work as values of the type. |
from_interpolation : List(Str) -> Try((List(item) -> T), [InvalidInterpolation(Str)]) |
String literals with interpolation | You want interpolated string literals to work as values of the type. |
iter : T -> Iter(item) |
for item in value |
You want for loops to work on the type. |
next |
Each step of a for loop |
Usually only Iter needs this; collections define iter instead. |
parser_for : encoding -> (state -> Try({ value : T, rest : state }, err)) |
Parsers, such as JSON parsing | You want the type to be parseable from formats like JSON. |
encoder_for : encoding -> (T, state -> Try(state, err)) |
Encoders, such as JSON encoding | You want the type to be encodable into formats like JSON. |
map |
Transforming a tag union's payload | You want to transform a payload with a pure function. |
map! |
Transforming a tag union's payload | You want to transform a payload with an effectful function. |
These aren't interfaces or traits that a type "implements." A type just has a method with the
right name and type, and then code that calls that method works with it. Packages can use any
method names they like in the same way, using where clauses.
Compiler-Derived Methods
For some of these methods, the compiler can write the implementation for you, based on the shape
of the type: is_eq, to_hash, parser_for, encoder_for, map, and map!.
Structural types (records, tuples, and structural tag unions) get these
automatically, whenever all the types inside them support the method too. That's why you can
compare two records with ==, or use a tuple as a dictionary key, without doing anything.
Nominal types don't get them automatically, because for a nominal type, the right answer isn't
always the structural one. (For example, two values of a Fraction type might be equal even if
their numerators are different, like 1/2 and 2/4.) Instead, a nominal type opts in by listing the
method with _ as its type and no implementation:
Model := { value : Str }.{
is_eq : _
to_hash : _
parser_for : _
encoder_for : _
}This only works for those six method names. Writing a method with _ and no implementation is a
compile-time error for any other name (except in platform modules, where it declares a
host-provided function).
If you write an implementation instead, that's what gets used. So you can, for example, write your
own is_eq while letting the compiler derive to_hash and encoder_for.
A derived method has to cover every field or tag a value could have, including the ones an
open type leaves unspecified. So if a function compares values whose type
ends in ..others, the other tags have to support equality too, and the function's annotation has
to say so in a where clause:
is_nope : [Nope, ..others] -> Bool where [others.is_eq : others, others -> Bool]
is_nope = |value| value == NopeWithout the where clause, someone could call is_nope with a value whose other tags have payloads
that can't be compared (like functions), so leaving it out is a compile-time error. (If you leave
off the annotation entirely, the compiler infers the where clause for you.)
The derived map and map! methods work on tag unions that have one type parameter appearing
directly in a payload. map takes a pure function, while map! takes an effectful one:
Maybe(a) := [Just(a), Nothing].{
map : _
map! : _
}`to_inspect`
The to_inspect method lets you choose how values of a type look when they're passed toStr.inspect, which is what dbg and test failure reports use:
Color := [Red, Green, Blue].{
to_inspect : Color -> Str
to_inspect = |color| match color {
Red => "Color.Red"
Green => "Color.Green"
Blue => "Color.Blue"
}
}Now Str.inspect uses Color.to_inspect:
red : Color
red = Red
Str.inspect(red) # "Color.Red"Without a to_inspect method, Str.inspect shows the value's structure.
Str.inspect only uses a to_inspect method whose type is exactly T -> Str, where any type
parameters of T are plain type variables. For example, Wrap(a) := [W(a)] can useto_inspect : Wrap(a) -> Str, but not to_inspect : Wrap(I64) -> Str, and not one with a where
clause. A to_inspect method with any other type is still a method you can call yourself; it's just
that Str.inspect won't use it.
To show a value's contents from inside to_inspect, call Str.inspect on them, which works on any
type:
Wrap(a) := [W(a)].{
to_inspect : Wrap(a) -> Str
to_inspect = |Wrap.W(value)| "Wrap(${Str.inspect(value)})"
}Equality and Hashing
The is_eq method decides what == and != do:
Point := { x : I64, y : I64 }.{
is_eq : Point, Point -> Bool
is_eq = |a, b| a.x == b.x and a.y == b.y
}
expect Point.{ x: 1, y: 2 } == Point.{ x: 1, y: 2 } # calls Point.is_eqa != b calls a.is_eq(b) and then flips the answer.
The to_hash method feeds a value's data into a Hasher:
to_hash : T, Hasher -> HasherDictionaries and sets use to_hash and is_eq together, so if you write your own is_eq, make
sure your to_hash agrees with it: whenever two values are equal, they must feed exactly the same
data into the hasher. Otherwise, a dictionary might not find a key that's equal to one it has.
Operators
Operators call methods on their left operand, so any type can support
them by defining the right methods:
Vec := { x : I64, y : I64 }.{
plus : Vec, Vec -> Vec
plus = |a, b| { x: a.x + b.x, y: a.y + b.y }
}
sum = Vec.{ x: 1, y: 2 } + Vec.{ x: 3, y: 4 } # calls Vec.plusThe result of an arithmetic operator has the left operand's type. The right operand usually has
the same type, but it doesn't have to; that's up to the method. For example, it can make sense to
multiply a Duration by a plain number:
Duration := { millis : I64 }.{
times : Duration, I64 -> Duration
times = |duration, scale| { millis: duration.millis * scale }
}
longer = Duration.{ millis: 10 } * 3The comparison operators (<, <=, >, >=) call methods that return a Bool, and both
operands must have the same type.
The range operators (..< and ..=) call methods that return a Range of the operands' type.
All the builtin number types have them, and you can define them for your own types:
PageNum := { num : U32 }.{
range_exclusive_to : PageNum, PageNum -> Range(PageNum)
range_exclusive_to = |start, end|
Range.custom({
lower: start,
upper: end,
step: PageNum.{ num: 1 },
upper_bound: Exclusive,
direction: To,
len_if_known: Unknown,
})
}
pages : Range(PageNum)
pages = first_page..<last_pageTo make that range iterable, PageNum would also define range_iter. Defining the two _from
methods makes Range.iter_rev work too; types whose steps can't be reversed exactly (like
floating-point numbers) leave those out.
Literal Conversion
A number literal can become a value of any type that has a from_numeral method, whenever that
type is what's expected:
Celsius := { degrees : I64 }.{
from_numeral : Numeral -> Try(Celsius, [InvalidNumeral(Str)])
from_numeral = |n| match I64.from_numeral(n) {
Ok(degrees) => Ok(Celsius.{ degrees })
Err(err) => Err(err)
}
}
temp : Celsius
temp = 21 # calls Celsius.from_numeralThe Numeral contains the literal's exact digits, so the type can decide which literals it
accepts. Since this happens at compile time, returning Err(InvalidNumeral(message)) gives a
compile-time error with that message. Custom Number Types goes into
more detail.
String literals work the same way, with from_quote:
HttpMethod := [Get, Post, Put, Delete].{
from_quote : Str -> Try(HttpMethod, [BadQuotedBytes(Str)])
from_quote = |raw| match raw {
"GET" => Ok(Get)
"POST" => Ok(Post)
"PUT" => Ok(Put)
"DELETE" => Ok(Delete)
_ => Err(BadQuotedBytes("expected GET, POST, PUT, or DELETE"))
}
}
method : HttpMethod
method = "POST" # calls HttpMethod.from_quoteHere, writing method = "PATCH" would give a compile-time error sayingexpected GET, POST, PUT, or DELETE.
A string literal with interpolations in it, like "Hello, ${name}!", usesfrom_interpolation. This happens in two stages:
- At compile time,
from_interpolationgets called with the literal's segments, which are the
pieces of text around the interpolations. Here, those are["Hello, ", "!"]. - It returns a function, which gets called each time the program evaluates the string literal.
That function receives the interpolated values (here, justname) as a list, and returns the
finished value.
Html := [Html(Str)].{
from_interpolation : List(Str) -> Try((List(Str) -> Html), [InvalidInterpolation(Str)])
from_interpolation = |segments|
if segments.any(|segment| segment.contains("<script")) {
Err(InvalidInterpolation("Html literals can't contain script tags"))
} else {
Str.from_interpolation(segments).map_ok(|assemble|
|values| Html(assemble(values.map(|value| value.replace_each("<", "<")))))
}
}
page : Str -> Html
page = |name| "<p>Hello, ${name}!</p>"The reason for having two stages is that the segments are written in the source code, so they
can be checked at compile time, just like a from_quote literal can. If from_interpolation
returns Err(InvalidInterpolation(message)), you get a compile-time error with that message. The
interpolated values, on the other hand, aren't known until the program runs, so the function that
handles them can't fail. Instead, it decides how those values get into the result. In this
example, Html rejects literals with <script written in them, and escapes any < in the
interpolated values.
A literal with n interpolations always has n + 1 segments (some of which may be empty
strings). The segments are always Str values, whereas the interpolated values can be whatever
type the returned function accepts.
Iteration
A for loop calls the iter method on the value after in. So to make your own type work infor loops, give it an iter method that returns an Iter:
Rows := { items : List(Row) }.{
iter : Rows -> Iter(Row)
iter = |rows| rows.items.iter()
}
for row in rows {
process(row)
}The loop then repeatedly calls next on the iterator, as described in
How Iteration Works. Collection types usually build their iterator
using the Iter functions rather than defining next themselves.
Parsing and Encoding
Parsers and encoders call parser_for and encoder_for to find out how to read or
write a value of a particular type in a particular format (like JSON).
Structural records, tag unions, lists, sets, dictionaries, and the builtin types all have these
already (as long as the format supports them), and nominal types can opt into the
derived versions with parser_for : _ and encoder_for : _. You'd
write them yourself when you want a type to be represented differently from its structure, or
when you want its backing type to stay hidden:
Token := { raw : Str }.{
parser_for : encoding -> (state -> Try({ value : Token, rest : state }, err))
where [
encoding.parse_str : encoding, state -> Try({ value : Str, rest : state }, err),
]
parser_for = |encoding| {
Encoding : encoding
|state| {
parsed = Encoding.parse_str(encoding, state)?
Ok({ value: Token.{ raw: parsed.value }, rest: parsed.rest })
}
}
encoder_for : encoding -> (Token, state -> Try(state, err))
where [
encoding.encode_str : encoding, Str, state -> Try(state, err),
]
encoder_for = |encoding| {
Encoding : encoding
|token, state| Encoding.encode_str(encoding, token.raw, state)
}
}This Token is parsed from (and encoded as) a plain string, rather than a record with a raw
field. (Encoding : encoding is explained in
Calling Methods on Type Variables.)
Number Literal Defaulting
When nothing says what type a number literal should be, the compiler picks the first type in this
list that works with everything the literal is used for:
Dec, I64, U64, I128, U128, I32, U32, I16, U16, I8, U8, F64, F32
So a plain 5 becomes a Dec (see Defaulting to Dec), but a 5
that's used in a way Dec doesn't support moves on down the list to the first type that does
support it.
If picking a default this way makes a function's inferred type more specific than it would
otherwise be, the compiler gives a LITERAL DEFAULTED warning. To choose a type yourself, add a
type annotation or a suffix (like 5.U64).
Where Clauses
A function that calls a method on a value whose type is a type variable needs to say which methods
that type must have. This is what where clauses are for:
show_all : List(a) -> Str where [a.to_str : a -> Str]
show_all = |items| {
var $out = ""
for item in items {
$out = $out.concat(item.to_str()).concat(" ")
}
$out
}The where [a.to_str : a -> Str] clause says that show_all accepts a list of any type a,
as long as a has a to_str method with the type a -> Str. Inside the function, that's what
makes it possible to call item.to_str(), and at each call site, the compiler checks that the
list's element type actually has that method. For example, show_all([1.U8, 2, 3]) works becauseU8 has a to_str method, whereas show_all([{ x: 1 }]) gives an error because records don't.
Since this is static dispatch, each call site knows exactly which to_str implementation it's using,
and the compiled program calls it directly.
If a function calls a method on a type variable, but its annotation doesn't have a where clause
listing that method, the compiler reports an error. If the function doesn't have an annotation,
the compiler infers the where clause automatically.
A where clause can list multiple constraints, separated by commas, and they can involve different
type variables:
convert_all : List(a) -> List(b) where [a.to_b : a -> b, b.is_valid : b -> Bool]See Where Clauses in the types page for more on the syntax.
Calling Methods on Type Variables
Some methods don't take a value of the type as an argument; for example, a method which creates a
new value of the type from scratch. To call one of those on a type variable, first give the type
variable an uppercase name by writing UppercaseName : lowercase_type_variable inside the function
body. After that, the uppercase name can be used to call the type variable's methods:
make_default : {} -> thing where [thing.default : () -> thing]
make_default = |_| {
Thing : thing
Thing.default()
}Here, Thing.default() calls whichever default method belongs to the type that thing turns out
to be at the call site. The parser_for example above uses this technique
to call Encoding.parse_str(…).
Aliases
When the same group of where constraints is needed in many places, you can give the group a name
with a where alias:
a.Showable : where [a.to_str : a -> Str]This declares a where alias named Showable. Now, instead of repeating the constraint, you can
write a.Showable in a where clause:
show_one : a -> Str where [a.Showable]
show_one = |value| value.to_str()This means exactly the same thing as where [a.to_str : a -> Str]. Any type that has the required
methods satisfies the alias; there's no need to declare that a type "implements" Showable.
A where alias can combine other where aliases, as well as individual method constraints:
a.Comparable : where [a.is_lt : a, a -> Bool]
a.Sortable : where [a.Showable, a.Comparable]Where aliases can also take parameters, which their constraints can mention:
a.Encodable(fmt) : where [a.encode : a, fmt -> fmt]
encode_twice : a, fmt -> fmt where [a.Encodable(fmt)]
encode_twice = |value, fmt| value.encode(value.encode(fmt))A where alias describes constraints on a type, not a type itself, so it can only be used inside awhere clause. Writing something like describe : Showable -> Str is an error.
Performance
Static dispatch has no runtime cost. By the time your program is compiled, value.method() has
been replaced by a direct call to the specific method for value's type, exactly as if you had
written Type.method(value) yourself. (And like any other direct call, the compiler can
inline it.)
That's also true of functions with where clauses. A function like show_all above gets
compiled separately for each type it's used with, and in each version,item.to_str() is a direct call to that type's to_str. There's no table of methods passed
around at runtime, which is how some other languages implement this kind of feature.
Derived methods are compiled the same way. A derived is_eq on a record compiles to code that
compares each field in turn, much like what you'd write by hand.
Langref Strings (`docs/langref/strings.md`)
Strings
Roc strings are designed to represent text. For example, "Hi!" is a string.
If you're interested in the low-level details of how they're stored in memory, you can skip ahead
to UTF-8 and Performance, but most readers will benefit more from
starting at a high level.
String Literals
A string literal is text between double quotes, like "Hello!". Inside the quotes, a backslash
starts an escape sequence, which is a way to write a character that would otherwise be
difficult or impossible to put there:
| Escape | Meaning |
|---|---|
\n |
newline |
\r |
carriage return |
\t |
tab |
\" |
" |
\' |
' |
\\ |
\ |
\$ |
$ (so you can write \${ without starting an interpolation) |
\u(e9) |
the Unicode code point with the given hexadecimal number (here, é) |
Any other character after a backslash gives a compile-time error.
Interpolation
Writing ${…} inside a string literal inserts the value of the expression between the braces:
greeting = "Hello, ${name}!"The value has to be a Str. To insert something else, like a number, convert it to a string
first: "You have ${count.to_str()} messages."
Multiline Strings
For text that spans several lines, start each line with \\:
poem =
\\Roses are red,
\\Violets are blue,
\\${name} wrote this
\\Just for you.Each \\ line becomes one line of the string, with newlines in between (but no newline at the
end). Everything after the \\ is part of the string, including any leading spaces, so the
indentation before the \\ doesn't affect the string's contents. Interpolation works in
multiline strings too.
Unicode
Roc strings represent text using Unicode, which
is a deep topic. (The Unicode glossary has over 500 entries in it!)
This guide will provide a basic overview of Unicode, including the relevant differences between these concepts:
- Code points
- Graphemes
- UTF-8
It will also explain why some operations are included in Roc's builtin Str
module, and why others are in separate packages like roc-lang/unicode.
Graphemes
Let's start with the following string:
"👩👩👦👦"
Some might call this a "character" because that's what they perceive it to be. And in a monospace font, it looks to be about the same width as the letter "A" or the punctuation mark "!"—both of which could also reasonably be called a "character." Unfortunately, the term "character" in programming has changed meanings many times across the years and across programming languages, and today it's become more confusing than helpful.
Unicode uses the less ambiguous term grapheme, which it defines as a "user-perceived character" (as opposed to one of the several historical ways the term "character" has been used in programming) or, alternatively, "A minimally distinctive unit of writing in the context of a particular writing system." By Unicode's definition, each of the following is an individual grapheme:
a鹏👩👩👦👦
Note that although grapheme is less ambiguous than character, its definition is still somewhat open to interpretation. To address this, Unicode has formally specified text segmentation rules which define grapheme boundaries in precise technical terms. We won't get into those rules here.
Code Points
Every Unicode text value can be broken down into Unicode code points, which are integers that describe different components of the text. In memory, every Roc string is a sequence of these integers stored in a format called UTF-8, which will be discussed later.
The string "👩👩👦👦" happens to be made up of these code points:
[128105, 8205, 128105, 8205, 128102, 8205, 128102]From this we can see that:
- One grapheme can be made up of multiple code points. In fact, there is no upper limit on how many code points can go into a single grapheme!
- Sometimes code points repeat within an individual grapheme. Here, 128105 repeats twice, as does 128102, and there's an 8205 in between each of the other code points.
Combining Code Points
The reason every other code point in this string is 8205 is that 8205 is a code point which joins together other code points. This emoji, "Family: Woman, Woman, Boy, Boy" is made by combining several emoji using zero-width joiners like code point 8205.
Here are those code points again, this time with the strings they correspond to:
[128105] # "👩"
[8205] # (joiner)
[128105] # "👩"
[8205] # (joiner)
[128102] # "👦"
[8205] # (joiner)
[128102] # "👦"One way to read this is "woman emoji joined to woman emoji joined to boy emoji joined to boy emoji." Without the joins, it would be:
"👩👩👦👦"
With the joins, however, it is instead:
"👩👩👦👦"
Even though it looks smaller when rendered, the second string takes up almost twice as much memory as the first one! That's because it has all the same code points as the first one…except with the addition of the zero-width joiners in between them.
Single quote syntax
Try putting '👩' into roc repl. You should see this:
» '👩'
128105.0The single-quote ' syntax lets you put a grapheme directly into your source code (so you can see what it looks like) as long as that grapheme contains only one code point, like 👩 does (namely, the code point 128105). At runtime, the single-quoted value will be treated the same as an ordinary number literal—in other words, '👩' is syntax sugar for writing 128105. (The repl displays it as 128105.0 because number literals default to Dec when nothing pins them to a particular number type.)
You can verify this in roc repl:
» '👩' == 128105
TrueDouble quotes ("), on the other hand, are not type-compatible with integers—not only because strings can be empty ("" is valid, but '' is not) but also because there may be more than one code point involved in any given string!
String Literals for Other Types
String literals are usually Str values, but other types can opt into being written as string
literals, the same way custom number types can opt into number
literals. A type that has a from_quote method can be
written as a plain string literal (like "/users") anywhere that type is expected, and a type
with a from_interpolation method can be written as a
string literal with interpolations in it (like "/users/${id}").
String equality and normalization
Besides emoji like 👩👩👦👦, another classic example of code points combining to render as one grapheme has to do with accent marks. Try putting these two different strings into roc repl:
"caf\u(e9)"
"cafe\u(301)"The \u(e9) syntax is a way of inserting code points into string literals. In this case, it's the same as inserting the hexadecimal number 0xe9 as a code point onto the end of the string "caf". Since Unicode code point 0xe9 happens to be é, the string "caf\u(e9)" ends up being identical in memory to the string "café".
We can verify this by putting the following into roc repl:
» "caf\u(e9)" == "café"
TrueAs it turns out, "cafe\u(301)" is another way to represent the same word. The Unicode code point 0x301 represents a ["combin