Skip to main content From Return Codes to Result | IoT Worker

From Return Codes to Result

C developers know this pattern well:

int read_config(const char *path, struct Config *out);

The function returns 0 on success and a non-zero value on failure; the real result is written through an output parameter. System calls often use -1 plus errno. With more resources, the code usually grows a goto cleanup path.

This works, but it has long-term costs:

  • callers may forget to check the return value
  • success values and error codes live in different places
  • output parameters need rules for failure cases
  • integer error codes often lose context
  • cleanup paths get tangled with error propagation

Rust Result is not an exception mechanism. It is closer to putting “success or failure” directly into the function return type.

C Return Codes Rely on Convention

Consider a small C function:

#include <errno.h>
#include <stdio.h>

int read_first_byte(const char *path, unsigned char *out)
{
    FILE *fp = fopen(path, "rb");
    if (fp == NULL) {
        return errno;
    }

    int ch = fgetc(fp);
    if (ch == EOF) {
        fclose(fp);
        return EIO;
    }

    *out = (unsigned char)ch;
    fclose(fp);
    return 0;
}

The caller must remember to check:

unsigned char byte;
int ret = read_first_byte("config.bin", &byte);
if (ret != 0) {
    fprintf(stderr, "read failed: %d\n", ret);
    return ret;
}

Several issues are typical:

  • byte is only valid on success
  • the meaning of ret is a convention
  • if the caller forgets to check ret, the failure may spread
  • if the function grows more paths, each failure path must handle resources

Rust does not make errors disappear. It turns this convention into a return type.

Result Contains Success and Failure

The Rust version can be:

use std::fs;
use std::io;

fn read_first_byte(path: &str) -> Result<u8, io::Error> {
    let data = fs::read(path)?;

    data.first()
        .copied()
        .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "empty file"))
}

The important part is:

Result<u8, io::Error>

It means:

success: return a u8
failure: return an io::Error

Compared with C “return code plus output parameter”, the boundary is clearer. The success value and error value are in one return type, and callers cannot pretend the function always succeeds.

Callers often use:

match read_first_byte("config.bin") {
    Ok(byte) => println!("byte = {byte}"),
    Err(err) => eprintln!("read failed: {err}"),
}

Ok and Err are the two branches. If errors are ignored, the compiler and lint tools have a better chance to point it out.

? Means Return on Failure

This line:

let data = fs::read(path)?;

Can be read with a C mental model:

ret = read_file(path, &data);
if (ret != 0) {
    return ret;
}

? means:

if Ok(value), extract value and continue
if Err(error), return that error from the current function

It is not an exception. There is no hidden catch block, and it does not jump through arbitrary stack frames. It propagates errors through return values in functions that return Result.

This function:

fn load_config(path: &str) -> Result<String, io::Error> {
    let text = fs::read_to_string(path)?;
    Ok(text)
}

Is the short form of:

fn load_config(path: &str) -> Result<String, io::Error> {
    match fs::read_to_string(path) {
        Ok(text) => Ok(text),
        Err(err) => Err(err),
    }
}

? removes repetitive error forwarding, not error handling itself.

main Turns Errors into Process Results

In a command-line tool, main is the process boundary. Internal functions can return Result, while main decides how to print errors and exit.

use std::{fs, process};

fn main() {
    if let Err(err) = run() {
        eprintln!("tool: {err}");
        process::exit(1);
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let text = fs::read_to_string("config.toml")?;
    println!("{}", text.len());
    Ok(())
}

This is close to C code where low-level functions return errors and the top layer prints and exits.

The difference is that Rust signatures show which functions may fail; ? carries errors to the boundary; main converts them into stderr and an exit code.

Libraries Should Not Only Return String Errors

For small tools, it can be fine to use Box<dyn std::error::Error> or crates such as anyhow to carry errors upward. In a library, the error type is part of the API.

For example:

#[derive(Debug)]
enum ParseError {
    TooShort,
    BadVersion(u8),
    LengthMismatch,
}

fn parse_packet(input: &[u8]) -> Result<(), ParseError> {
    if input.len() < 2 {
        return Err(ParseError::TooShort);
    }

    let version = input[0];
    if version != 1 {
        return Err(ParseError::BadVersion(version));
    }

    Ok(())
}

This carries more information than -1 or "parse failed". The caller can decide whether to retry, drop the packet, alert, or count the failure.

Library API design will get a separate article. For now: CLIs can turn errors into human-readable messages; libraries should preserve structured errors when callers need decisions.

errno and Result Are Not One-to-One

errno is global or thread-local error state, usually paired with system call return values:

fd = open(path, O_RDONLY);
if (fd < 0) {
    perror("open");
}

Rust’s standard library usually wraps these errors in io::Error:

let data = std::fs::read(path)?;

If you need the underlying OS error code, you can get it:

if let Some(code) = err.raw_os_error() {
    eprintln!("os error = {code}");
}

But most Rust APIs should not compress all errors into integers. Integer error codes are useful at ABI and C boundaries; inside Rust, structured error types are usually better.

What to Keep from This Article

The first mapping looks like this:

0 / non-zero return          -> Result<T, E>
output parameter out         -> Ok(value)
errno / error code           -> Err(error)
manual if (ret != 0) return  -> ?
top-level fprintf + retcode  -> main prints error and sets exit code

Result is not an exception. It is an ordinary return value that includes both success and failure.

? is not hidden control flow. It returns from the current function when it sees Err, letting the error move up the call chain.

The next article is about resource release. C goto cleanup, free, fclose, and multiple return paths connect to Rust ownership and Drop.