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:
?Trepresents a value that might be absent -- the equivalent of Rust'sOption<T>or TypeScript'sT | null. It has two variants:Some(T)andNone.!Trepresents an operation that might fail -- the equivalent of Rust'sResult<T, E>. It has two variants:Ok(T)andErr(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
?Tvs!T, combining them
Handling the Unexpected
Most programming languages handle errors through one of three mechanisms:
Exceptions (Java, Python, C#): Code throws exceptions, and the runtime walks the call stack looking for a matching
catchblock. This is powerful but invisible -- you can't tell from a function signature whether it might throw.Error codes (C, Go): Functions return a special value (like
-1ornull) to indicate failure. This is explicit but easy to forget to check.Type-level error handling (Rust, Haskell): Errors are encoded in the type system. A function that might fail returns
Result<T, E>instead ofT. 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 presentNone(ornone) -- 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:
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>withSome(...)andNone. The transpiler generates.unwrap()when Auto accesses a field on an optional directly. - Python uses
Optional[User]withNone. The transpiler generatesif ... is not Noneguards before accessing fields. - C uses a
_someflag in the struct. If_someis 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:
This prints:
Shanghai
NoneThe ?. 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:
// 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_someflag. - 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 succeededErr(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
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>withOk(...)andErr(...). 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
_okflag, avaluefield, and anerrorfield. - 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.
The ! operator does two things:
- If
read_file(path)returnsOk(content), it unwraps and gives youcontent. - If
read_file(path)returnsErr(msg), it immediately returnsErr(msg)fromread_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.
?Tvs!TQuick Reference
Auto Rust When 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:
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
ParseErrorstruct and formats it into theErrstring withformat!. - Python raises a
ValueErrorwith the formatted error message. - C populates the error string in the result struct using
snprintf. - TypeScript throws an
Errorwith 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.
This prints:
Got: 42The 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 errortry risky()-- capture bothOkandErrfor 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
matchon theResultvalue directly. Thetrykeyword is unnecessary because Rust's?operator already does propagation, and you canmatchon the result without it. - Python uses
try/except, which is the natural Python pattern for catching errors. - C checks the
_okflag 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
?Twhen 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
!Twhen 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):
This prints:
Some(3.333...)
NoneThe 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
!Tto?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.
Summary
Auto's error handling is built on two type-level constructs that make errors visible and force you to handle them:
| Concept | Auto | Rust | TypeScript |
|---|---|---|---|
| Optional value | ?T | Option<T> | T | null |
| Error result | !T | Result<T, String> | throws |
| No value | none | None | null |
| Success | bare value | Ok(value) | return value |
| Error | string literal | Err(msg) | throw Error |
| Propagate error | expr! | expr? | (rethrow) |
| Optional chaining | x?.field | x.as_ref()?.field | x?.field |
| Explicit handling | try + is | match | try/catch |
The key improvements Auto makes over Rust:
- Shorter type syntax:
?Userinstead ofOption<User>,!intinstead ofResult<i32, String>. - Automatic wrapping: Bare values become
Ok(...), string literals becomeErr(...). No need to write constructors explicitly. - Consistent error type:
!Talways usesStringfor errors, eliminating the complexity of generic error types. - Optional chaining:
x?.fieldfor 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.