Beginner

Variables and Types in Roc

Learn about immutable bindings, type inference, primitive types, records, and tagged unions in Roc with practical Docker-ready examples

Roc is a purely functional language, and like its cousins Elm and Haskell, it has no mutable variables. Every binding is a permanent name for a value — once name = "Alice" is written, name will refer to that string for the rest of its scope. If you need a changed value, you create a new binding with a new name.

Roc also has 100% type inference. The compiler can always determine the most general type for every expression, so type annotations are strictly optional. They exist as documentation and as a way to constrain a value to a more specific type than the one the compiler would otherwise pick. Roc also takes an unusual stance on absence: there is no null, no Maybe, and no Option. Instead, the language uses tagged unions — lightweight sum types that anyone can define inline — along with the built-in Result type for operations that may fail.

In this tutorial you’ll see Roc’s primitive types, how type annotations attach to bindings, how records group named fields, and how tagged unions express “this value is one of these alternatives.”

Immutable Bindings and Primitive Types

Every binding in Roc is immutable. The name on the left of = is locked to the value on the right for the rest of its scope. Roc’s primitive types include Str for text, a family of sized integer types (I8, I16, I32, I64, I128 and their unsigned U* variants), floating-point types (F32, F64), the fixed-point Dec type, and Bool.

Create a file named variables.roc:

app [main!] { cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }

import cli.Stdout

main! = |_args|
    # Bindings with type inference - the compiler figures out each type
    language = "Roc"
    version = 4
    pi = 3.14159

    # Explicit type annotations are optional; they go on a separate line
    creator : Str
    creator = "Richard Feldman"

    year_created : I32
    year_created = 2019

    # Sized integer types - pick the smallest type that fits your range
    small : I8
    small = 42

    big : I64
    big = 9_000_000_000

    # Floating-point and fixed-point numbers
    temperature : F64
    temperature = 72.5

    # String interpolation uses ${} - works with any expression
    Stdout.line!("Language: ${language}")?
    Stdout.line!("Creator: ${creator}")?
    Stdout.line!("Year created: ${Num.to_str(year_created)}")?
    Stdout.line!("Version: alpha${Num.to_str(version)}")?
    Stdout.line!("Pi: ${Num.to_str(pi)}")?
    Stdout.line!("Small (I8): ${Num.to_str(small)}")?
    Stdout.line!("Big (I64): ${Num.to_str(big)}")?
    Stdout.line!("Temperature (F64): ${Num.to_str(temperature)}")

A few things to notice. Type annotations in Roc use a name : Type line immediately followed by a name = value line — there is no let keyword. Numeric literals like 42 have a polymorphic type and will unify with whatever integer type is expected, which is why small = 42 works for an I8 even though the literal isn’t specially marked.

Underscores in numeric literals (9_000_000_000) are allowed for readability and are ignored by the compiler. The Num.to_str function converts any numeric value to a Str, and the ${...} syntax inside a string literal interpolates the expression directly. The ? at the end of each Stdout.line! call is the try operator: it unwraps a successful Result, or returns early with the error. The final call has no ? because its result becomes the return value of main!.

Records and Tagged Unions

Records group named fields into a single value, and tagged unions express “one of several shapes.” Together they replace most uses of classes, structs, enums, and null in other languages. Roc’s type system infers record shapes structurally, and tagged unions can be defined inline or given names with type aliases.

Create a file named variables_types.roc:

app [main!] { cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }

import cli.Stdout

# Type alias for a record shape
Person : { name : Str, age : U32, email : Str }

# Tagged union type alias - a value is exactly one of these variants
AccountStatus : [Active, Suspended, Closed]

main! = |_args|
    # A record literal
    alice : Person
    alice = { name: "Alice", age: 30, email: "[email protected]" }

    # Field access uses dot notation
    greeting = "Hello, ${alice.name}!"

    # Record update syntax creates a NEW record with some fields changed
    # (the original is untouched - everything is immutable)
    alice_older = { alice & age: 31 }

    # A tagged union value
    status : AccountStatus
    status = Active

    # Pattern matching with `when ... is` extracts the variant
    status_text =
        when status is
            Active -> "active"
            Suspended -> "suspended"
            Closed -> "closed"

    # Tagged unions can carry data - here's how Roc handles absence without null
    nickname : [Some Str, None]
    nickname = Some("Roci")

    nickname_text =
        when nickname is
            Some(n) -> n
            None -> "(no nickname set)"

    # Result is a built-in tagged union for operations that may fail
    parsed : Result I64 [InvalidNumber]
    parsed = Ok(42)

    parsed_text =
        when parsed is
            Ok(value) -> "parsed ${Num.to_str(value)}"
            Err(InvalidNumber) -> "could not parse"

    Stdout.line!(greeting)?
    Stdout.line!("Age today: ${Num.to_str(alice.age)}")?
    Stdout.line!("Age next year: ${Num.to_str(alice_older.age)}")?
    Stdout.line!("Email: ${alice.email}")?
    Stdout.line!("Status: ${status_text}")?
    Stdout.line!("Nickname: ${nickname_text}")?
    Stdout.line!("Parsed: ${parsed_text}")

A type alias like Person : { name : Str, age : U32, email : Str } gives a name to a record shape. The tagged union AccountStatus : [Active, Suspended, Closed] lists the variants a value of that type can take. Roc tagged unions can also carry data: [Some Str, None] defines a union where Some carries a string payload and None carries nothing. This is how Roc models “maybe there is a value” without introducing null.

The { alice & age: 31 } syntax is record update. It produces a brand-new record identical to alice except with the given fields changed. The original alice binding is still there, still unchanged — that is immutability in action.

The when ... is expression is pattern matching. The compiler checks that every variant is handled; leaving out Closed in the status_text match would be a compile-time error. This is what Roc means by “no runtime exceptions” — the kinds of mistakes that would be a NullPointerException or missing-case bug in other languages become compile-time errors here.

Running with Docker

1
2
3
4
5
6
7
8
# Pull the Roc nightly image
docker pull roclang/nightly-ubuntu-2204:latest

# Run the primitive types example
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc variables.roc

# Run the records and tagged unions example
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc variables_types.roc

Note: On the first run, Roc will download the basic-cli platform referenced in each file’s app header. Subsequent runs use the cached platform.

Expected Output

Output from variables.roc:

Language: Roc
Creator: Richard Feldman
Year created: 2019
Version: alpha4
Pi: 3.14159
Small (I8): 42
Big (I64): 9000000000
Temperature (F64): 72.5

Output from variables_types.roc:

Hello, Alice!
Age today: 30
Age next year: 31
Email: [email protected]
Status: active
Nickname: Roci
Parsed: parsed 42

Key Concepts

  • All bindings are immutable — there are no mutable variables. A name always refers to the same value within its scope; use a new binding (or record update) when you need a different value.
  • Type inference is total — the compiler can always determine the most general type for any expression, so type annotations are optional everywhere.
  • Annotations go on a separate line — write name : Type on one line and name = value on the next, with no let, var, or const keyword.
  • Sized numeric types are explicitI8, I16, I32, I64, I128, their unsigned U* counterparts, plus F32, F64, and Dec let you pick exactly the precision you want.
  • String interpolation uses ${} — any expression can be embedded in a string literal, and Num.to_str is the standard way to turn a number into a string.
  • Records group named fields — access with dot notation (person.name), update with { person & field: new_value } to produce a new record.
  • Tagged unions replace null — instead of null, Maybe, or Option, Roc uses inline sum types like [Some Str, None], and the compiler enforces that every variant is handled when you pattern match with when ... is.
  • Result is built in for fallible operationsResult ok_type err_type expresses “this returned a value OR an error,” and the ? try operator propagates errors through effectful code.

Running Today

All examples can be run using Docker:

docker pull roclang/nightly-ubuntu-2204:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining