Every .roc file is a module. Modules have two purposes:
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.ParseErr
Now 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 like
Util.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 capitalized
Url 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 doing
import ____ exposing ____ as a matter of course).
Roc optimizes for the common case at the expense of these less-common ones. You can
import 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 and
Url -> 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 and
ffi::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.Stdout
Import 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 JP
Modules 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/Api
Note 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 Widget
Modules 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 PE
Importing 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.Parser
Importing 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.Bar
This 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 not
FooBar, 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.
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 same
platform 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.Http
A 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.
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_dir is 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 an output. - These paths have to stay inside the platform's directory. An absolute path (like
/usr/lib/foo.a or C:\foo.lib), or a path with a .. in it, gives an "Invalid Target Path" error. Otherwise, a platform you downloaded could make roc build link in any file on your computer. - WebAssembly targets that get linked must list the functions the final module exports to the outside world, using
exports. (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 the provides entrypoints 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 tell
roc 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.
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
hosted section; 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. A Box is 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 why
roc 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.
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 an
app 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.