Skip to content
📖 The Auto Programming LanguageBook contents

Error Handling ​

Errors are a fact of life in software. A file might not exist. A network request might time out. A user might type "abc" when you expected a number. Every program must decide what to do when things go wrong.

Auto provides two type-level mechanisms for handling these situations, inspired by Rust's approach but with a more streamlined syntax:

  • ?T represents a value that might be absent -- the equivalent of Rust's Option<T> or TypeScript's T | null. It has two variants: Some(T) and None.
  • !T represents an operation that might fail -- the equivalent of Rust's Result<T, E>. It has two variants: Ok(T) and Err(String).

Like Rust, Auto requires you to acknowledge the possibility of an error and take action before your code will compile. This requirement makes your program more robust by ensuring that you handle errors rather than silently ignoring them.

In this chapter, we'll cover:

  • Handling the Unexpected -- why error handling matters
  • ?T -- Optional Values -- null safety and optional chaining
  • !T -- Error Type -- the error propagation model and the ! operator
  • Custom Errors -- defining your own error types
  • try / catch -- explicit error handling
  • Best Practices -- when to use ?T vs !T, combining them

Handling the Unexpected ​

Most programming languages handle errors through one of three mechanisms:

  1. Exceptions (Java, Python, C#): Code throws exceptions, and the runtime walks the call stack looking for a matching catch block. This is powerful but invisible -- you can't tell from a function signature whether it might throw.

  2. Error codes (C, Go): Functions return a special value (like -1 or null) to indicate failure. This is explicit but easy to forget to check.

  3. Type-level error handling (Rust, Haskell): Errors are encoded in the type system. A function that might fail returns Result<T, E> instead of T. The compiler forces you to handle both the success and failure cases.

Auto takes the third approach, with simpler syntax than Rust. Instead of writing Result<i32, String>, you write !int. Instead of writing Option<User>, you write ?User.

The benefit is enormous: errors become visible in function signatures, the compiler catches unhandled errors at compile time, and you never accidentally use a null value or an error result as if it were a valid value.

?T -- Optional Values ​

The ?T type represents a value that might or might not exist. It has two variants:

  • Some(value) -- a value is present
  • None (or none) -- no value is present

This is the same concept as Rust's Option<T>, TypeScript's T | null, or Python's Optional[T].

Returning ?T from Functions ​

When a function might not have a value to return, declare its return type as ?T:

A function that returns `?User`

The find_user function returns ?User. When id == 1, it returns a User value. When id is anything else, it returns none -- the absence of a value.

In Auto, when a function returns ?T and the last expression is a bare value (not wrapped in Some), the compiler automatically wraps it. So User { ... } becomes Some(User { ... }). You only need to write none explicitly for the absent case.

How this maps across languages:

  • Rust uses Option<User> with Some(...) and None. The transpiler generates .unwrap() when Auto accesses a field on an optional directly.
  • Python uses Optional[User] with None. The transpiler generates if ... is not None guards before accessing fields.
  • C uses a _some flag in the struct. If _some is 0, the value is absent.
  • TypeScript uses User | null. The transpiler generates null checks before field access.

Optional Chaining ​

When you have a chain of optional values -- for example, a user with an optional address -- you can use the ?. operator to safely access nested fields without manually checking each level:

Optional chaining with `?.`

This prints:

Shanghai
None

The ?. operator is Auto's optional chaining syntax. alice.address?.city means: if address is Some, access city; if address is none, short-circuit and return none. This is the same concept as TypeScript's ?., Kotlin's ?., and Swift's ?..

Without optional chaining, you'd need nested checks:

auto
// Without optional chaining -- verbose
if alice.address is {
    Some(addr) => print(addr.city),
    None => print("None")
}

With ?., the intent is clear in a single expression. The transpiler generates the appropriate null checks in every target language:

  • Rust uses if let Some(ref addr) = alice.address.
  • Python uses if alice.address is not None.
  • C checks the _address_some flag.
  • TypeScript checks !== null.

!T -- Error Type ​

While ?T says "this value might be absent," !T says "this operation might fail with an error message." It has two variants:

  • Ok(value) -- the operation succeeded
  • Err(message) -- the operation failed with a string error message

This is the equivalent of Rust's Result<T, E> where E is always String. The difference from ?T is that when there's no value, there's also a reason -- an error message that explains what went wrong.

Returning !T from Functions ​

A function that returns `!int`

When a function returns !T, bare values are automatically wrapped in Ok. String values are automatically wrapped in Err. So in a function that returns !int, the expression 42 becomes Ok(42), and the expression "bad input" becomes Err("bad input").

How this maps across languages:

  • Rust uses Result<i64, String> with Ok(...) and Err(...). The transpiler generates .unwrap() when Auto uses the value directly.
  • Python doesn't have Result types natively. Errors are typically raised as exceptions.
  • C uses a result struct with a _ok flag, a value field, and an error field.
  • TypeScript can use the value directly, since TypeScript doesn't enforce Result types at the language level.

The ! Operator -- Error Propagation ​

The ! operator (postfix) propagates errors. When you call a function that returns !T and append !, you're saying: "if this succeeds, give me the value; if it fails, return the error from my function immediately."

This is the equivalent of Rust's ? operator, but using ! instead.

Error propagation with the `!` operator

The ! operator does two things:

  1. If read_file(path) returns Ok(content), it unwraps and gives you content.
  2. If read_file(path) returns Err(msg), it immediately returns Err(msg) from read_config.

This is called error propagation -- errors bubble up through the call stack until someone handles them. It's the same pattern as Rust's ? operator, and it eliminates the boilerplate of writing match on every intermediate call.

In main(), the ! on read_config("config.txt")! unwraps the result. If it's an Err, the program panics with the error message.

?T vs !T Quick Reference ​

AutoRustWhen to Use
?UserOption<User>Value might be absent (lookup, search)
!UserResult<User, String>Operation might fail with an error
noneNoneNo value
"error msg" (in !T)Err("msg".to_string())Error with message
expr!expr?Propagate error
x?.fieldx.as_ref()?.fieldOptional chaining

Custom Errors ​

When you need to provide more context about what went wrong, you can define custom error types. Auto's !T always carries a String error message, but you can structure that message from a custom type:

Custom error types with `!T`

When a function returns !T and the last expression is a struct value (like ParseError { ... }), Auto treats it as an error. The struct is formatted into a string error message. In this example, ParseError { message: "empty input", line: 0 } becomes Err("empty input at line 0").

This gives you the best of both worlds: structured error data for debugging, combined with Auto's simple !T string-based error model.

How this maps across languages:

  • Rust creates the ParseError struct and formats it into the Err string with format!.
  • Python raises a ValueError with the formatted error message.
  • C populates the error string in the result struct using snprintf.
  • TypeScript throws an Error with the formatted message.

try / catch ​

While the ! operator is the idiomatic way to propagate errors, sometimes you need to handle errors explicitly -- to log them, convert them, or take different actions based on the error. Auto provides try and is for this purpose.

Explicit error handling with `try` and `is`

This prints:

Got: 42

The try keyword captures the result of a !T expression without propagating it. Instead of bubbling the error up, it assigns the full Ok/Err value to the variable. You can then use is to inspect the result and decide what to do.

This is different from the ! operator:

  • risky()! -- unwrap the value or panic/propagate the error
  • try risky() -- capture both Ok and Err for manual inspection

Use ! when you want errors to propagate. Use try when you need to handle the error yourself.

How this maps across languages:

  • Rust uses match on the Result value directly. The try keyword is unnecessary because Rust's ? operator already does propagation, and you can match on the result without it.
  • Python uses try/except, which is the natural Python pattern for catching errors.
  • C checks the _ok flag in the result struct.
  • TypeScript uses try/catch, mirroring the exception-based approach.

Best Practices ​

When to Use ?T vs !T ​

The choice between ?T and !T comes down to whether you need to explain why something failed:

  • Use ?T when a value might naturally be absent -- looking up a key in a map, searching for an item in a list, accessing an optional field. The absence of a value is not an error; it's just information.

  • Use !T when an operation can fail for a reason that the caller needs to know -- parsing a string, reading a file, making a network request. The caller needs the error message to decide what to do.

Combining ?T and !T ​

Sometimes you need to bridge between the two worlds. For example, you might have a function that returns !T (because it can fail with an error), but you want to convert it to ?T (because the caller only cares about whether there's a value, not why it failed):

Combining `?T` and `!T` for flexible error handling

This prints:

Some(3.333...)
None

The divide function returns !float because division by zero is an error that the caller should know about. The safe_divide function converts this to ?float -- it catches the error and turns it into none. The caller of safe_divide doesn't need to know why the division failed; they just need to know whether it succeeded.

This pattern -- converting !T to ?T by discarding the error message -- is common when you're building a higher-level API on top of a lower-level one. The low-level API needs rich error information; the high-level API just needs to know success or failure.

Error Handling Guidelines ​

Use ?T when:

  • A value might naturally be absent (searching, looking up a key)
  • The caller should decide what to do when there's no value
  • Missing values are expected, not exceptional

Use !T when:

  • An operation can fail with a meaningful error message (parsing, I/O)
  • The caller needs to know why something failed
  • The error might be recoverable at a higher level

Use the ! operator when:

  • You want errors to propagate up the call stack
  • You don't need to inspect the error at this level
  • Your function also returns !T

Use try + is when:

  • You need to inspect the error and take specific action
  • You want to convert !T to ?T
  • You need to log or transform errors

Practical Error Patterns ​

Two common patterns you will encounter: propagating errors with the ? operator, and handling environment variables that may be missing.

Error propagation with the ? operator
Environment variables with safe defaults

Summary ​

Auto's error handling is built on two type-level constructs that make errors visible and force you to handle them:

ConceptAutoRustTypeScript
Optional value?TOption<T>T | null
Error result!TResult<T, String>throws
No valuenoneNonenull
Successbare valueOk(value)return value
Errorstring literalErr(msg)throw Error
Propagate errorexpr!expr?(rethrow)
Optional chainingx?.fieldx.as_ref()?.fieldx?.field
Explicit handlingtry + ismatchtry/catch

The key improvements Auto makes over Rust:

  1. Shorter type syntax: ?User instead of Option<User>, !int instead of Result<i32, String>.
  2. Automatic wrapping: Bare values become Ok(...), string literals become Err(...). No need to write constructors explicitly.
  3. Consistent error type: !T always uses String for errors, eliminating the complexity of generic error types.
  4. Optional chaining: x?.field for safe access to nested optional values.

In the next chapter, we'll discuss how Auto handles modules and project organization -- how to structure large programs using the features you've learned.

Released under the MIT License.