Result
An operation that was supposed to succeed and did not returns a Result<T, E>: a value, a typed error, and a bool saying which of the two is live. It is a plain struct returned by value, it allocates nothing, and the error type names what this particular call can go wrong with.
[[nodiscard]] stops you dropping a Result. Nothing stops you reading value when ok is false — that check is a discipline you write, exactly like a null check.
Most unwanted outcomes are not failures
Section titled “Most unwanted outcomes are not failures”Basalt sorts every unwanted outcome into one of four shapes, and only one of them is a Result.
| The outcome | What the call returns | Where you meet it |
|---|---|---|
| Nothing is there, and that is normal | A plain value, a sentinel, or a nullable T* |
Str8Find gives STR8_NOT_FOUND; HashMap::Find gives nullptr |
| The call was meant to succeed and did not | Result<T, E> |
FileOpen, Arena::Create, VirtualArray<T>::Reserve |
| The caller broke the contract | BS_ASSERT, then abort |
Array::Append on a full array, an out-of-range index |
| The failure is not actionable | void, unreported |
ConsoleWrite, Decommit, Release |
“Not found”, “not clicked”, “end of iteration” are results, not errors. So a null T* coming back from a Basalt call means absence, and never failure — the two have different shapes so you can tell them apart from the signature alone.
The last row is a small, named set rather than a habit. A write to a broken stdout and a decommit during teardown fail in ways a caller cannot do anything with, so the return type says nothing rather than offering you an error you would discard.
Reading one
Section titled “Reading one”ok is the discriminant. Branch on it, then read the side it selects.
const Result<String8, FileError> read = ReadEntireFile(arena, Str8FromCString("assets/level.json"));if (!read.ok){ ConsoleWriteLine(ConsoleStream::Stderr, ErrorToString(read.error)); return;}
ParseLevel(read.value);Both fields exist in the object at once — there is no union — so a Result stays trivially copyable and memcpy-able, and sizeof(Result<T, E>) is roughly sizeof(T) plus sizeof(E) plus the flag. Whether that comes back in registers or through memory follows the size and your ABI; Basalt makes no claim about it.
Reading value on the failure path compiles, runs, and produces nothing you can act on. There is no trap and no diagnostic. Treat .ok the way you treat a null check on a pointer you were just handed.
Returning one
Section titled “Returning one”Three spellings cover every case, and none of them writes the fields by hand.
Result<Font, FontError> LoadFont(Arena& arena, const String8 path){ const Result<String8, FileError> read = ReadEntireFile(arena, path); if (!read.ok) { return Error(FontError::SourceUnreadable); // an explicit conversion, see below }
Font font = {}; // ... decode into `font` ... return font; // implicit success}| You want to say | You write | Available on |
|---|---|---|
| It worked, here is the value | return value; |
Result<T, E> |
| It worked, there is nothing to hand back | return Ok; |
Result<void, E> |
| It failed | return Error(e); |
both |
Error(e) deduces only the error type. The value type comes from the function’s return type, which deduction could never recover from the error value alone.
Result<void, E> is the shape for an operation performed for its effect: Commit, FileWrite, WriteEntireFile. It carries the flag and the error and nothing else.
The error type belongs to the operation
Section titled “The error type belongs to the operation”There is no framework-wide error enum, and no default E. Each operation names an enum whose every variant that operation can actually produce.
enum class MemoryError : u32 { OutOfMemory };A one-reason failure still gets an enum rather than a bool, because a bool return is indistinguishable from a predicate and gives ErrorToString nowhere to live. Adding a variant later needs no signature change.
Three things follow at your call sites:
- An exhaustive
switchwith nodefaultis honest. Every variant is reachable, so a handler you write is a handler that runs, and the compiler tells you when a new variant appears. - A new variant is a source break. Basalt is 0.x and accepts that: a failure mode you have never considered should stop your build rather than fall through a
default. - Crossing error domains is explicit.
FontErrorandFileErrorare different types, and no conversion happens on its own — thereturn Error(FontError::SourceUnreadable);above is the whole mechanism.
An operation that composes several fallible calls has a second option: carry the callee’s error rather than remap it, with a tag saying which callee failed.
enum class LoadStage : u8 { Read, Decode };
struct LoadFontError{ LoadStage stage; union { FileError read; // valid when stage == Read DecodeError decode; // valid when stage == Decode };};A variant added to FileError then reaches your caller without your enum changing at all.
Reporting one
Section titled “Reporting one”ErrorToString is an overload set, not a method and not a virtual call. One overload per enum, defined next to the enum.
[[nodiscard]] inline String8 ErrorToString(const FontError error){ switch (error) { case FontError::SourceUnreadable: return Str8FromCString("font source unreadable"); case FontError::BadMagic: return Str8FromCString("not a font file"); } return Str8FromCString("unknown font error");}Overload resolution finds yours the moment it is visible, with nothing to register and no base class to inherit. An overload that returns fixed strings takes no arena and allocates nothing, which is what lets ErrorToString(MemoryError::OutOfMemory) work at the moment the machine has no memory left. An overload that must compose text takes an Arena& as its first parameter.
There are no combinators
Section titled “There are no combinators”Result has no map, no and_then, no ? operator, and no automatic conversion between error types. A chain of three fallible calls is three if statements.
Err conversions are automatic only where a common error type exists, and Basalt deliberately has none; a mapping combinator would need one, or a remap function per pair of enums written at the call site. The chain stays visible instead, and the nesting shape in the previous section is what keeps it from growing linearly with depth.
Where the repetition does bite, the guard reads short:
const Result<void, MemoryError> reserved = buffer.Reserve();if (!reserved.ok){ return Error(reserved.error);}When a Result is the wrong return type
Section titled “When a Result is the wrong return type”| Situation | What the API does instead |
|---|---|
| Absence is an expected answer | A sentinel or a nullable T* — Str8Find, HashMap::Find |
| The caller violated a documented precondition | BS_ASSERT; a bug is not a runtime condition |
| Allocation on the hot path | Arena::Push<T> returns an empty Span<T> and asserts in debug; the allocation path is too hot to pay for a Result per call |
| Failure nothing can act on | void — console writes, Decommit, Release |
| “Did I get the lock?” | Mutex::TryLock returns bool; not acquiring a lock is a normal outcome |
A Result stored in a long-lived structure is also out of shape. It is a return value: check it, take what you need, and let it die at the end of the statement that produced it.
API summary
Section titled “API summary”The invariant, the construction helpers and the layout are documented in basalt/core/Result.h. The normative contract is in docs/specs/error-handling.md.
The type
Section titled “The type”| Member | Purpose |
|---|---|
Result<T, E>::ok |
Discriminant. True selects value, false selects error. |
Result<T, E>::value |
The success value. Valid only when ok. |
Result<T, E>::error |
The failure. Valid only when ok is false. |
Result<void, E> |
Specialisation for an operation with no success value: ok and error only. |
Construction
Section titled “Construction”| Spelling | Meaning |
|---|---|
return value; |
Success carrying a value. Implicit; there is no Ok(v) call. |
return Ok; |
Success with no value, for Result<void, E>. |
return Error(e); |
Failure carrying e. Deduces the error type only. |
Error enums in basalt_core
Section titled “Error enums in basalt_core”| Enum | Produced by |
|---|---|
MemoryError |
Reserve, Commit, Arena::Create, VirtualArray<T>::Reserve, HashMap and DenseHashMap reservation |
FileError |
The whole file layer — see File I/O |
ErrorToString(error) |
One overload per enum. Fixed-string overloads take no arena. |