Intermediate

I/O Operations in Roc

Learn how to read input, write output, and work with files in Roc using its explicit effect model and Result-based error handling

Input and output are where a purely functional language has to be honest about the real world. In Roc, every function is pure by default — given the same arguments, it always returns the same result and changes nothing outside itself. Reading from a keyboard or writing to a file breaks that promise, so Roc makes those operations impossible to hide.

Roc’s answer is the platform/application split. The application code you write stays pure, while a platform (here, basic-cli) supplies the effectful building blocks: printing, reading input, and touching the filesystem. Any function that performs an effect is marked with a ! suffix, so Stdout.line!, Stdin.line!, and File.read_utf8! all announce their effects right in their names. When you call one, the ! shows up at the call site too — you can scan a block of Roc and see exactly where the outside world gets involved.

The second half of the story is error handling. Roc has no exceptions and no null. An operation that might fail returns a Result — either Ok with a value or Err with a reason. To keep effectful code readable, Roc provides the ? operator, which unwraps an Ok and short-circuits out of the function on an Err. This tutorial covers formatted console output, reading interactive input, writing and reading files, and handling I/O failures with Result.

Formatted Console Output

The Hello World page introduced Stdout.line!. Here we add Stdout.write! (which prints without a trailing newline) and use string interpolation to format values. Note how each intermediate effect ends with ? to sequence it and discard its {} result.

Create a file named console_output.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|
    # write! prints without a trailing newline, so these join on one line
    Stdout.write!("Loading")?
    Stdout.write!("...")?
    Stdout.line!(" done!")?

    # String interpolation with ${...} formats values into text
    name = "Roc"
    version = 4
    Stdout.line!("Language: ${name}")?
    Stdout.line!("Alpha release: ${Num.to_str(version)}")?

    # Fractional literals default to Dec, so this prints exactly
    pi = 3.14159
    Stdout.line!("Pi is about ${Num.to_str(pi)}")

The final expression in main! is the function’s return value, so it needs no ?. Every line before it uses ? to run the effect and continue.

Reading Input from the Console

Stdin.line! reads a single line from standard input and returns a Result Str _. The ? operator unwraps the Ok string; if the input stream ends unexpectedly, the Err propagates out of main! and the platform reports it.

Create a file named read_input.roc:

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

import cli.Stdout
import cli.Stdin

main! = |_args|
    Stdout.line!("What is your name?")?
    name = Stdin.line!({})?

    Stdout.line!("What is your favorite language?")?
    language = Stdin.line!({})?

    Stdout.line!("Nice to meet you, ${name}!")?
    Stdout.line!("${language} is a great choice!")

Because Stdin.line! returns a Result, there is no way to accidentally use unread or failed input — the type system forces you to deal with it, either by unwrapping with ? or by pattern matching.

Writing and Reading Files

The File module provides File.write_utf8! (which takes the path first, then the contents) and File.read_utf8! (which takes a path and returns the file’s contents). Both return a Result, so both compose cleanly with ?.

Create a file named file_io.roc:

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

import cli.Stdout
import cli.File

main! = |_args|
    contents = "Roc makes I/O effects explicit.\nEvery effectful function ends with !\n"

    # Write the string to a file, creating or overwriting it
    File.write_utf8!("notes.txt", contents)?
    Stdout.line!("Wrote notes.txt")?

    # Read the file back into a string
    loaded = File.read_utf8!("notes.txt")?
    Stdout.line!("File contents:")?

    # loaded already ends in a newline, so use write! to avoid a blank line
    Stdout.write!(loaded)

Handling I/O Errors

Not every file exists, and not every read succeeds. Instead of unwrapping with ?, you can pattern match on the Result with when ... is to handle both outcomes explicitly. The compiler enforces that you cover every case, so a forgotten error branch is a compile error, not a runtime crash.

Create a file named io_errors.roc:

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

import cli.Stdout
import cli.File

main! = |_args|
    # read_utf8! returns a Result rather than throwing — we handle both cases
    result = File.read_utf8!("missing.txt")

    when result is
        Ok(contents) ->
            Stdout.line!("File says: ${contents}")

        Err(_) ->
            Stdout.line!("Could not read missing.txt — using a default value")

Since missing.txt does not exist, the read fails and control flows into the Err branch. The underscore _ in Err(_) discards the specific error reason; you could bind it to a name instead to report exactly what went wrong.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the official Roc nightly image
docker pull roclang/nightly-ubuntu-2204:latest

# Formatted console output
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc console_output.roc

# Interactive input (type your answers when prompted)
docker run --rm -it -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc read_input.roc

# File writing and reading
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc file_io.roc

# Error handling
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc io_errors.roc

Note: The read_input.roc example needs the -it flags so Docker keeps an interactive terminal open for typed input. On the first run, Roc downloads the basic-cli platform, which may take a few seconds.

Expected Output

Running console_output.roc:

Loading... done!
Language: Roc
Alpha release: 4
Pi is about 3.14159

Running read_input.roc (lines you type are shown after each prompt):

What is your name?
Ada
What is your favorite language?
Roc
Nice to meet you, Ada!
Roc is a great choice!

Running file_io.roc:

Wrote notes.txt
File contents:
Roc makes I/O effects explicit.
Every effectful function ends with !

Running io_errors.roc:

Could not read missing.txt — using a default value

Key Concepts

  • Effects are marked with !Stdout.line!, Stdin.line!, File.read_utf8!, and every other effectful call ends in !, so I/O is visible everywhere it happens.
  • The platform provides I/O — your application stays pure; the basic-cli platform supplies the actual reading, writing, and printing.
  • write! vs line!Stdout.write! prints text as-is, while Stdout.line! appends a newline.
  • The ? operator sequences effects — it unwraps an Ok value and short-circuits out of the function on an Err, keeping effectful code flat and readable.
  • I/O returns Result, never throws — Roc has no exceptions; failures are Err values you must handle, either with ? or by pattern matching.
  • when ... is is exhaustive — the compiler requires you to cover both Ok and Err, so unhandled error cases are caught at compile time.
  • String interpolation formats output${...} embeds values into strings, with Num.to_str converting numbers to text.
  • File writes take the path firstFile.write_utf8!(path, contents) creates or overwrites the file at path.

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