Skip to content

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.

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.

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.

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.

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 switch with no default is 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. FontError and FileError are different types, and no conversion happens on its own — the return 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.

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.

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);
}
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.

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.

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.
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.
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.