Skip to content

Error Handling

Things go wrong in scripts - users provide invalid input, files don't exist, network requests fail. Generally, for smaller CLI scripts, it's okay if we just exit on the spot, and that's also Rad's default behavior. However, if you wish to more gracefully handle these errors or attempt recovery, Rad gives you the tools to do so.

In Rad, errors are values. Functions that might fail return either a result or an error, and you can decide how to handle them. This makes error handling explicit, predictable, and easy to reason about.

In this section, we'll explore:

  • Error propagation - how errors bubble up by default (and why scripts exit)
  • The catch: block - handle errors with full control (logging, reassignment, exit)
  • The ?? operator - shorthand for simple fallback values
  • Creating errors - using error() in your own functions
  • Error type unions - making fallible operations explicit with T|error type annotations

Error Propagation

Let's start with a simple script that takes a user's age as input:

File: age
1
2
3
4
5
args:
    age_str str  # User's age as a string

age = parse_int(age_str)
print("You are {age} years old")

In reality, you'd instead declare the arg as an int and let Rad handle input validation, but for the purposes of this guide, we write it as a str.

If we run this with a valid number, everything works:

rad age 25
You are 25 years old

But what happens with invalid input?

rad age "not-a-number"
Error at L4:7

age = parse_int(age_str)
      ^^^^^^^^^^^^^^^^^^ parse_int() failed to parse "not-a-number" (RAD20001)

The script exits immediately with an error code of 1 when parse_int encounters invalid input. What's happening is that parse_int returned an error value, and since we're not handling it, it immediately gets propagated up. Since we're at the root of the script and not nested within any other function call, this triggers a script exit on the spot.

Nested Calls

Errors don't just propagate from built-in functions - they bubble up through your own function calls too. Here's an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn calculate_discount(price_str: str) -> float|error:
    price = parse_float(price_str)  // Error starts here...
    return price * 0.1

fn process_order(item_price: str) -> str|error:
    discount = calculate_discount(item_price)  // ...propagates through here...
    return "Discount: {discount:.2} USD"

result = process_order("invalid")  // ...and exits the script here
print(result)
Error at L2:13

      price = parse_float(price_str)  // Error starts here...
              ^^^^^^^^^^^^^^^^^^^^^^
              parse_float() failed to parse "invalid" (RAD20002)

The error originates in parse_float, propagates through calculate_discount, then through process_order, and finally exits at the top level. At any point in this chain, we could choose to handle the error instead of letting it propagate.

This sets up the question: how do we handle errors gracefully instead of crashing?

Catch Blocks

The catch: block gives you full control over error handling. Attach it as a suffix to any expression that might error, and you can inspect the error, log it, provide a fallback value, or decide whether to exit.

Basic Error Handling

Here's how to handle our age parsing example gracefully:

File: age
1
2
3
4
5
6
7
8
args:
    age_str str

age = parse_int(age_str) catch:
    print_err("Invalid age, falling back to 0: {age}")  // 'age' contains the error value
    age = 0  // Provide fallback

print("Age: {age}")

Now when we run it with invalid input:

rad age "not-a-number"
Invalid age, falling back to 0: parse_int() failed to parse "not-a-number"
Age: 0

The script continues running with our fallback value. Inside the catch: block, the age variable contains the error string, as returned by parse_int, which we can log or inspect. We then reassign age to a sensible default value of 0.

To summarize:

  • Suffix form: write ... catch: directly after the error-able expression.
  • Binding: the target variable is first bound to the error value; inside the block, interpolating it (e.g. {age}) prints the error’s message.
  • Control: you can log, reassign a fallback, or exit(code).
  • Flow: execution continues after the block unless you exit.

Exiting on Errors

Sometimes you want to fail fast - handle the error just enough to log a helpful message, then exit:

File: readconfig
1
2
3
4
5
6
7
8
9
args:
    config_file str

config = read_file(config_file) catch:
    print_err("Failed to read config: {config}")
    exit(1)

print("Config loaded successfully")
// Continue processing config...

Running this with a non-existent file:

rad readconfig "missing.txt"
Failed to read config: open missing.txt: no such file or directory

This example is not much better than the default error propagation and exit, but you can imagine providing more useful guidance to users in a more detailed error message.

Ignoring Errors with pass

Sometimes you want to ignore errors entirely - the operation might fail, but that's perfectly fine and requires no action:

1
2
3
4
5
// Custom fn to clean up temp file if it exists
delete_path(temp_file) catch:
    pass  // File already doesn't exist, that's fine

// Continue with the rest of the script...

Here, pass does nothing - it's a way to explicitly say "I know this might error, but I don't care." This is useful for cleanup operations where the failure itself is harmless.

The ?? Operator

For simple cases where you just want a default value without any logging or conditional logic, the ?? operator provides a concise shorthand:

age = parse_int(age_str) ?? 0
timeout = parse_int(get_env("TIMEOUT")) ?? 30
max_retries = parse_int(config["retries"]) ?? 5

The ?? operator uses lazy evaluation - the right side is only evaluated if the left side returns an error. This means you can even call functions on the right:

config = read_file(config_path) ?? get_default_config()

Comparing ?? and catch

These two are roughly equivalent:

// Using ??
age = parse_int(age_str) ?? 0

// Using catch:
age = parse_int(age_str) catch:
    age = 0

But catch: lets you log errors or do conditional handling:

age = parse_int(age_str) catch:
    print_err("Invalid age '{age_str}': {age}")
    age = 0

When to use which

Use ?? when you just need a default value and don't care about logging or inspecting the error. Use catch: when you need to log the error, perform conditional logic, including whether to exit.

Creating Your Own Errors

When writing your own functions, you can return errors using the error(str) function. If you're using type annotations, then functions that may return errors should reflect that in its return type: T|error.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
fn validate_port(port: int) -> int|error:
    if port < 1 or port > 65535:
        return error("Port must be between 1-65535, got {port}")
    return port

fn start_server(port_str: str) -> void:
    port = parse_int(port_str) ?? 8080

    validated_port = validate_port(port) catch:
        print_err("Invalid port: {validated_port}")
        exit(1)

    print("Starting server on port {validated_port}")

start_server("99999")
Invalid port: Port must be between 1-65535, got 99999

Our custom error message provides clear feedback about what went wrong. By returning int|error, the type signature tells you three things:

  1. This function normally returns an int
  2. It might return an error instead
  3. Callers should consider handling the error case (otherwise let it propagate)

This pattern is used throughout Rad's built-in functions:

  • parse_int(str) -> int|error
  • parse_float(str) -> float|error
  • read_file(path) -> error|{ "size_bytes": int, "content": str }
  • round(num, decimals) -> error|int|float

The error union makes your code self-documenting - anyone reading your function signature knows immediately that it can fail.

More on Union Types

We covered union types in detail in an earlier section: Type Annotations. Error unions are just one application of Rad's union type system.

Summary

Rad's error handling model gives you the tools to write robust scripts that handle failures gracefully:

  • Errors are values that propagate by default, unless handled
  • Scripts exit if errors propagate up to the root of the script
  • catch: blocks provide full error handling control:
    • Variable contains the error string inside the block
    • You can log errors, provide fallbacks, or call exit()
    • Execution continues unless you explicitly exit
  • ?? operator provides concise fallbacks with lazy evaluation
    • Use for simple cases without logging
    • Right side only evaluated if left side errors
  • Create errors with error("message") in your own functions
  • Type unions (T|error) make fallible operations explicit in function signatures

Next

CLI scripts and the shell go hand in hand, and Rad offers first-class support for invoking shell commands and handling its output. We explore this in the next section: Shell Commands.