OxigenLang Angle Forms
Angle forms are one of Oxigen's core syntax families. The same <...> bracket shape is reused throughout the language for several different purposes:
- type annotations in declarations
- zero-initialization targets
- typed walrus conversion
- value constructors (creating error and success values)
- explicit error and result handling
- postfix effects on expressions (recovery with
guard) - standalone logging with
<log> - entry point marking with
main
The syntax stays the same, but the meaning depends on where the angle form appears in a statement.
This guide explains every angle form from scratch. If you have never written Oxigen before, start here.
#Mental Model: How to Read Angle Forms
Whenever you see <...> in Oxigen, look at what comes before the opening < bracket. That tells you what the angle form is doing:
What comes before < | What <...> means | Example |
|---|---|---|
| A variable name | Type annotation — declares what type this variable holds | name <str> = "hello" |
The keyword as or just <type> after a name | Zero-initialize — create an empty value of this type | count <int> |
| Nothing (start of expression) | Constructor or utility — build a value, transform, or log | <Error>("failed"), <log>("msg") |
| A completed expression | Effect — recover from an error on that expression | expr <guard>("fallback") |
One bracket family, multiple roles. Position is everything.
#Understanding Oxigen's Two-Tier Error Model
Before diving into syntax, you need to understand how Oxigen thinks about errors. This is the foundation for everything that follows.
Oxigen has two fundamentally different kinds of error objects. Confusing them is the most common mistake when learning angle forms.
#Tier 1: Propagating Errors (the "halt everything" kind)
A propagating error is a runtime failure that immediately stops your program. When a propagating error occurs, it bubbles up through every function call and halts evaluation — nothing after it runs unless something explicitly catches it.
You create propagating errors with <fail> or fail:
<fail>("something went wrong")
fail "something went wrong"Think of these like unhandled exceptions in Python or Java. If nothing catches them, your program stops.
Example — propagating error halts everything:
<fail>("broken")
println("this line never runs")
// The program stopped at <fail>. Nothing below it executes.#Tier 2: Error Values (the "data" kind)
An error value is a piece of data that describes a failure but does not halt anything. It sits in a variable like a string or integer. You can inspect it, pass it to functions, store it in arrays, or ignore it entirely.
You create error values with <Error>:
err := <Error>("something went wrong")This does not stop your program. It creates a value you can hold onto.
Example — error value does NOT halt anything:
err := <Error>("broken")
println("this line runs just fine")
println(err.msg) // prints: broken
// The program keeps running. err is just a value sitting in a variable.#Why Two Tiers?
Most languages force you into one model: either everything is an exception that you try/catch (Python, Java, JavaScript) or everything is a result type that you must unwrap (Rust, Go). Oxigen gives you both and lets you choose:
- Use propagating errors (
<fail>) when something is genuinely broken and the caller should not silently continue. The program will stop unless the caller explicitly handles it. - Use error values (
<Error>) when you want to represent failure as data — to inspect it, branch on it, collect multiple errors, or pass it around between functions.
The angle form system lets you move between these two tiers explicitly. You can take a propagating error and turn it into a value (with <type<Error || Value>>), or take an error value and turn it into a propagating error (with <fail>).
#Key Behavioral Differences
Propagating Error (<fail>) | Error Value (<Error>) | |
|---|---|---|
| Halts program? | Yes — stops evaluation immediately | No — just a value |
| Created with | <fail>("msg") or fail "msg" | <Error>("msg") |
| Is truthy? | No (falsy) | Yes (truthy — it is a real value) |
| Can be stored in variable? | No — it propagates before assignment | Yes — sits in a variable like any value |
| How to inspect | Must catch it first (with guard or option <Error>) | Access .msg and .tag directly |
#1. Type Annotations
In declaration position (after a variable name, before = or :=), angle forms describe the type of a binding.
#What the Syntax Means
name <str> = "Oxigen"Reading this left to right:
name— the variable name<str>— the angle form says "this variable holds a string"=— strict assignment (the value must already be a string)"Oxigen"— the value being assigned
#More Examples
count <int> := 10 // count holds an integer, := means mutable
payload <map> := {"ok": True} // payload holds a map
active <bool> = True // active holds a boolean, = means immutable#Why Type Annotations Exist
Without a type annotation, Oxigen infers the type from the value. But explicit types serve two purposes:
- Documentation — reading
count <int> := 10tells you the variable is meant to hold integers. - Type safety — Oxigen will reject assignments of the wrong type:
count <int> := 10
count = "hello" // error: type mismatch — count is locked to intThis is the same type system used across variables, struct fields, and function parameters.
#2. Zero Initialization
Angle forms create an empty value of the specified type when used with just a variable name and no assignment operator.
#What the Syntax Means
count <int>Reading this left to right:
count— the variable name<int>— the type to create a zero value for
After this line, count exists and holds 0 (the zero value for integers). No = or := is needed — the type alone is enough.
#The as Form
The as keyword is an optional alternative that does the same thing:
count as <int>Both count <int> and count as <int> produce identical results. The convention in Oxigen is to use count <int> (without as) because it is shorter and consistent with how types appear everywhere else in the language.
#All Zero Values
Each type has a specific zero value:
| Type | Zero Value | What it looks like |
|---|---|---|
<int> | 0 | The integer zero |
<float> | 0.0 | The float zero |
<str> | "" | An empty string |
<bool> | False | The boolean false |
<array> | [] | An empty array |
<map> | {} | An empty map |
<None> | None | The None value |
#When to Use Zero Initialization
Use zero initialization when you need a typed variable before you have a value for it. This is common when the value will be set inside a conditional or loop:
result <str>
option {
ready -> result = fetch_data(),
result = "not ready"
}
println(result)Without result <str>, the variable result would not exist before the option block, and the assignments inside would fail.
#Why Not Just Write result <str> := ""?
You can. result <str> (zero initialization) is a shorthand that means the same thing as result <str> := "" for strings, result <int> := 0 for ints, etc. Use whichever reads more clearly. Zero initialization is especially useful when you do not have a meaningful initial value.
#3. Typed Walrus Conversion
With := (the walrus operator), angle forms can force a value into a different type when the conversion is supported.
#What the Syntax Means
num <int> := "10"Reading this left to right:
num— the variable name<int>— the target type:=— the walrus operator, which means "convert if needed and allow mutation""10"— the value (a string)
Oxigen sees that "10" is a string but the target type is <int>. Because := was used (not =), Oxigen attempts to convert the string "10" to the integer 10. This succeeds, and num holds 10.
#More Conversion Examples
label <str> := 42 // integer 42 becomes string "42"
chars <array> := "hello" // string "hello" becomes ["h", "e", "l", "l", "o"]
flag <bool> := 1 // integer 1 becomes True#Strict vs Walrus Assignment
The difference between = and := is critical:
num <int> = "10" // ERROR: type mismatch — "10" is a string, not an int
num <int> := "10" // OK: converts "10" to 10= (strict):
- The value must already be the correct type
- No conversion is attempted
- The binding is immutable (cannot be reassigned)
:= (walrus):
- Oxigen will attempt type conversion
- If conversion is impossible, you get an error
- The binding is mutable (can be reassigned later)
#When Conversion Fails
If the conversion is not possible, you get a propagating error:
num <int> := "hello" // error: cannot convert "hello" to int#4. Error Construction with <Error>
<Error>(...) creates an error value (tier 2). This is a value-level operation — it does not halt your program.
#What the Syntax Means
<Error>("file not found")Breaking this down:
<Error>— the angle form that says "construct an error value"(— open parenthesis, start of the argument"file not found"— the error message (any expression that produces a value))— close parenthesis
The result is an error value object. You typically assign it to a variable:
err := <Error>("file not found")Now err holds an error value. Your program is still running. You can inspect it:
println(err.msg) // prints: file not found
println(type(err)) // prints: ERROR_VALUE#Tagged Errors
Add a tag inside nested angle brackets to categorize the error:
<Error<network>>("connection timed out")Breaking this down:
<Error— start of the error constructor<network>— the tag, nested inside the outer angle brackets>— close the outer angle bracket("connection timed out")— the error message
The tag is a free-form string identifier. It does not need to be declared anywhere — just pick a name that describes the category of failure.
err := <Error<network>>("connection timed out")
println(err.msg) // "connection timed out"
println(err.tag) // "network"#Why Tags Exist
Tags give you a way to group related errors without defining custom error types or classes. They are lightweight labels:
<Error<file>>("missing config") // file-related error
<Error<auth>>("invalid token") // authentication error
<Error<parse>>("unexpected character") // parsing error
<Error<retry_error>>("max retries hit") // retryable errorLater, when you catch errors with guard, you can filter by tag — only handling specific categories of errors and letting others propagate.
#Public Fields on Error Values
Every error value has two fields you can access with dot notation:
| Field | Type | Description |
|---|---|---|
.msg | str | The error message you passed to <Error> |
.tag | str or None | The tag if you used <Error<tag>>, otherwise None |
tagged := <Error<network>>("offline")
println(tagged.msg) // "offline"
println(tagged.tag) // "network"
plain := <Error>("something broke")
println(plain.msg) // "something broke"
println(plain.tag) // None#When to Use <Error>
Use <Error> when you want to represent a failure as data that you will inspect, store, or make decisions about later:
// Collect multiple validation errors instead of stopping at the first one
issues <array> := []
push(issues, <Error<validation>>("name is required"))
push(issues, <Error<validation>>("email is invalid"))
each issue in issues {
println("{issue.tag}: {issue.msg}")
}
// prints:
// validation: name is required
// validation: email is invalidThis would not work with <fail> because the program would stop at the first error.
#5. Success Wrapping with <Value>
<Value>(...) wraps any value as an explicit success. It is the counterpart to <Error>.
#What the Syntax Means
<Value>("ok")Breaking this down:
<Value>— the angle form that says "wrap this as a success value"("ok")— the value to wrap
The result is a Value object. You access the inner value with .value:
wrapped := <Value>("loaded successfully")
println(wrapped.value) // "loaded successfully"
println(type(wrapped)) // "VALUE"#Why <Value> Exists
On its own, <Value> might seem pointless — why wrap a value that is already fine? The answer is that <Value> pairs with <Error> to form the expected-result pattern.
When a function might succeed or fail, you want a uniform way to represent both outcomes as values. <Value> and <Error> give you that:
fun safe_divide(a <int>, b <int>) {
option {
b == 0 -> <Error<math>>("division by zero"),
<Value>(a / b)
}
}
result := safe_divide(10, 2)
println(result.value) // 5
result := safe_divide(10, 0)
println(result.msg) // "division by zero"
println(result.tag) // "math"Both paths return a value — one is a Value, the other is an ErrorValue. Neither halts the program. The caller decides what to do.
#6. Expected-Result Normalization with <type<Error || Value>>
This is one of the most powerful angle forms. It takes any expression — even one that might produce a propagating error — and converts the result into either a Value or an ErrorValue. The error is caught and turned into data instead of halting your program.
#The Problem It Solves
Normally, if a function fails with a propagating error, your program stops:
data := read_file("missing.txt") // propagating error — program halts here
println("this never runs")But sometimes you expect a call might fail and you want to handle both outcomes. You do not want the error to halt your program — you want to inspect it and decide what to do.
#What the Syntax Means
<type<Error || Value>>(read_file("config.txt"))Breaking this down:
<type— starts a type-conversion angle form<Error || Value>— the target type is "either an Error or a Value">— closes the outer angle bracket(read_file("config.txt"))— the expression to evaluate
#How It Works, Step by Step
result := <type<Error || Value>>(read_file("config.txt"))Here is what happens:
- Oxigen evaluates
read_file("config.txt")inside the angle form. - If the call succeeds: The return value is wrapped as
Value(...). You can access it withresult.value. - If the call fails (produces a propagating error): The error is caught and converted to an
ErrorValue. The propagation stops. You can access it withresult.msgandresult.tag. - Either way, your program continues. The error is now a value, not a propagation.
#Tagged Variant: <type<Error<tag> || Value>>
You can include a tag in the Error part of the union type. This tag acts as a default — it is applied to any error that does not already have its own tag.
<type<Error<non_integer> || Value>>(int(value))Breaking this down:
<type— starts a type-conversion angle form<Error<non_integer>— the error side of the union, with a default tag ofnon_integer|| Value>— the success side(int(value))— the expression to evaluate
What happens step by step:
int(value)is evaluated.- If it succeeds: The result is wrapped as
Value(...), same as the untagged form. The tag is ignored. - If it fails with an untagged error: The error is caught and the tag
non_integeris applied. The result is anErrorValuewith.tag == "non_integer"and.msgset to the error message. - If it fails with an already-tagged error: The existing tag is preserved. The default tag from the union type is not used. This means tagged errors from deeper in the call stack keep their identity.
Example — default tag applied to untagged error:
result := <type<Error<non_integer> || Value>>(int("hello"))
println(result.tag) // "non_integer" — applied because int() errors are untagged
println(result.msg) // "int: cannot parse 'hello'"Example — existing tag preserved:
result := <type<Error<fallback> || Value>>(<fail>(<Error<original>>("boom")))
println(result.tag) // "original" — the existing tag wins over "fallback"
println(result.msg) // "boom"Either order works — Value || Error<tag> is the same as Error<tag> || Value:
result := <type<Value || Error<parse>>>(parse_json(input))#When to Use the Tagged Variant
Use <type<Error<tag> || Value>> when you want every error from an expression to carry a category tag, even if the underlying function does not tag its errors:
fun convert_to_int(value <generic>) {
converted <Error || Value> := <type<Error<non_integer> || Value>>(int(value))
option {
is_value(converted) -> converted.value,
converted
}
}
result := convert_to_int("hello")
println(result.tag) // "non_integer"
println(result.msg) // "int: cannot parse 'hello'"Without the tag, result.tag would be None because int() does not tag its errors. The tagged union form adds the category for you.
#Checking Which Outcome You Got
After normalization, you have a value that is either a Value or an ErrorValue. Use the is_value() and is_error() builtins to check:
result := <type<Error || Value>>(read_file("settings.oxi"))
option {
is_value(result) -> {
// Success path — use result.value
println("File contents: {result.value}")
},
{
// Failure path — use result.msg
println("Could not read file: {result.msg}")
}
}You can also check from the error side:
option {
is_error(result) -> println("Failed: {result.msg}"),
println("Success: {result.value}")
}#With Typed Declarations
You can declare the variable with the Error || Value type annotation to document what it holds:
config <Error || Value> := <type<Error || Value>>(read_file("config"))This reads as: "config holds either an Error or a Value, and we got it by normalizing read_file("config")."
#Quick Examples
// Success path — wraps "loaded" as a Value
ok := <type<Error || Value>>("loaded")
println(ok.value) // "loaded"
// Failure path — catches the propagating error and wraps it as ErrorValue
failed := <type<Error || Value>>(<fail>("missing config"))
println(failed.msg) // "missing config"
// Note: <fail> would normally halt the program, but <type<Error || Value>> caught it#When to Use <type<Error || Value>>
Use it whenever you are calling something that might fail and you want to:
- Keep the program running regardless of the outcome
- Inspect the error as data instead of crashing
- Make decisions based on success or failure
#7. Propagating Failures with <fail>
<fail>(...) creates a propagating runtime error (tier 1). This is a control-flow operation — it halts normal evaluation immediately.
#What the Syntax Means
<fail>("invalid input")Breaking this down:
<fail>— the angle form that says "produce a propagating error"("invalid input")— the error message
After this line, nothing else in the current scope runs. The error propagates up through every caller until something catches it (a guard, an option <Error> arm, or a <type<Error || Value>> wrapper).
#From Plain Values
Any value passed to <fail> is converted to an error message string:
<fail>("bad input") // message: "bad input"
<fail>(42) // message: "42"
<fail>(True) // message: "True"#From Error Values
If you pass an ErrorValue to <fail>, it preserves the message and tag. This is how you upgrade a tier-2 error value into a tier-1 propagating error:
err := <Error<network>>("connection lost")
// err is just a value — the program is still running
// Now convert it to a propagating error:
<fail>(err)
// This propagating error has tag "network" and message "connection lost"
// The program halts here#Keyword Form
The keyword form fail does the same thing as <fail>:
fail "bad input"Both forms are supported. <fail> is the preferred modern form because it is visually consistent with other angle forms.
#When to Use <fail>
Use <fail> inside a function when you have encountered a condition where the function cannot continue and needs to signal failure to its caller:
fun parse_age(input <str>) {
age <int> := input
option {
age < 0 -> <fail>(<Error<validation>>("age cannot be negative")),
age > 150 -> <fail>(<Error<validation>>("age seems unrealistic")),
age
}
}Here <fail> is used with <Error<tag>> together: <Error<validation>>(...) creates a tagged error value, and <fail>(...) converts it into a propagating error. The caller of parse_age will see a propagating error with tag "validation".
#8. error(...) vs <Error>(...) vs <fail>(...)
These three forms are related but serve different purposes. This section clarifies exactly when to use each one.
#error(...) — Legacy Form
error("missing file")This is the old compatibility form. It creates a propagating error (like <fail>). Use it only if you are working with older Oxigen code. Prefer <fail> for new code.
#<Error>(...) — Create Error Value (Does NOT Halt)
err := <Error>("missing file")Creates an error value. The program keeps running. You get an object with .msg and .tag fields. Use this when you want failure represented as data.
#<fail>(...) — Create Propagating Error (Halts Program)
<fail>("missing file")Creates a propagating error. The program stops. Use this when the current function cannot continue and needs to tell its caller about the failure.
#Decision Guide
Ask yourself:
- Do I want the program to keep running? Use
<Error>("msg")— it creates a value, not a halt. - Do I want the program to stop and signal failure? Use
<fail>("msg")— it propagates. - Do I need a tag on the error? Use
<Error<tag>>("msg")for error values, or<fail>(<Error<tag>>("msg"))to propagate a tagged error. - Am I working with old code?
error("msg")still works, but prefer<fail>in new code.
#Quick Comparison
| Form | What it produces | Halts program? | Best for |
|---|---|---|---|
error("msg") | Propagating error | Yes | Legacy code |
<Error>("msg") | Error value | No | Representing failure as data |
<Error<tag>>("msg") | Tagged error value | No | Categorized failure as data |
<fail>("msg") | Propagating error | Yes | Signaling failure to callers |
<fail>(<Error<tag>>("msg")) | Tagged propagating error | Yes | Signaling categorized failure |
#9. Error Recovery with guard
guard catches propagating errors and replaces them with a fallback value. It converts a tier-1 propagating error back into normal program flow.
Think of guard as a safety net: "try this expression, but if it fails, use this fallback instead."
#Angle Form (Preferred)
name := read_name() <guard>("Guest")What happens step by step:
read_name()is evaluated.- If it succeeds: Its return value is used.
namegets that value. The<guard>part does nothing. - If it fails (propagating error): The error is caught, discarded, and
"Guest"is used instead.namegets"Guest".
Breaking down the syntax:
read_name()— the expression that might fail<guard>— the angle effect that says "catch errors from the left expression"("Guest")— the fallback value to use if the left expression fails
#With a Binding (Keyword Form)
When you need to access the caught error to decide the fallback, use the keyword form with guard err ->:
name := read_name() guard err -> "Unknown (error: {err.msg})"What happens step by step:
read_name()is evaluated.- If it succeeds: Its return value is used. The guard does nothing.
- If it fails: The error is caught and bound to the variable
err. Theerrvariable is an error value with.msgand.tagfields. The expression after->is evaluated and its result becomes the value.
The err binding is only visible inside the fallback expression (after ->). You cannot use it outside.
#Tagged Filtering
Add a tag filter to only catch specific categories of errors:
profile := fetch_profile() <guard<Error<retry_error>>>("Guest")Breaking down the syntax:
fetch_profile()— the expression that might fail<guard— start of the guard effect<Error<retry_error>>— the tag filter: only catch errors taggedretry_error>— close the guard angle bracket("Guest")— the fallback
What happens step by step:
fetch_profile()is evaluated.- If it succeeds: The value passes through. Guard does nothing.
- If it fails with a
retry_errortag: The error is caught."Guest"is used. - If it fails with a different tag (like
networkorauth) or no tag: The error is not caught. It keeps propagating. The guard only handles the specific tag you specified.
This is powerful because it lets you handle expected failures while allowing unexpected ones to propagate normally.
#What guard Does NOT Catch
guard only catches errors (both propagating errors and error values). It does not catch other values:
result := None guard err -> "fallback"
// result is None, NOT "fallback"
// guard did not fire because None is not an errorNone is a value, not an error. Guard ignores it completely.
#Keyword Form at Statement Level
The keyword form can also be used at the statement level, without assignment:
println(fail "boom") guard err -> err.msgHere:
println(fail "boom")—fail "boom"creates a propagating error beforeprintlncan run.guard err -> err.msg— catches the error, binds it toerr, and evaluateserr.msgwhich returns the string"boom".
#10. Logging with <log>
<log> is a standalone logging utility that writes timestamped messages to stdout. It is a prefix-position angle form — you call it on its own, not after an expression.
Think of <log> as a simple, structured way to record what your program is doing.
#Basic Form
<log>("server started")This prints the following to stderr:
2026-03-28 14:30:00: server startedBreaking this down:
<log>— the angle form that says "write a log message"("server started")— the message to log
The timestamp is automatically added in YYYY-MM-DD HH:MM:SS format. You do not need to format it yourself.
#Tagged Form
Add a tag inside nested angle brackets to categorize the message:
<log<info>>("request received")This prints:
2026-03-28 14:30:00: [INFO] request receivedBreaking this down:
<log— start of the log angle form<info>— the tag, nested inside the outer angle brackets>— close the outer angle bracket("request received")— the message
The tag is uppercased in the output. You write <log<info>> in your code, but the output shows [INFO].
#Common Tags
You can use any identifier as a tag. Here are some common choices:
<log<info>>("user logged in") // [INFO] user logged in
<log<warn>>("disk space low") // [WARN] disk space low
<log<Error>>("file not found") // [ERROR] file not found
<log<debug>>("cache hit ratio: 0.95") // [DEBUG] cache hit ratio: 0.95Tags are free-form — there is no fixed list. Pick names that make sense for your application.
#Nested Tags
For more specific categorization, nest tags inside tags:
<log<Error<network>>>("connection lost")This prints:
2026-03-28 14:30:00: [ERROR:NETWORK] connection lostBoth the outer and inner tags are uppercased and joined with a colon. This is useful when you want to distinguish between different subcategories of errors or events.
#Tag-Only Form (No Message)
Sometimes you just want to mark that something happened, without a detailed message. You can omit the parentheses entirely:
<log<Error>>This prints:
2026-03-28 14:30:00: [ERROR] genericYou can also use empty parentheses — the result is the same:
<log<custom_tag>>()This prints:
2026-03-28 14:30:00: [CUSTOM_TAG]Both forms (no parens and empty parens) are equivalent when there is no message.
If the tag refers to an in-scope error value, <log> uses that error's message automatically:
same_version_error <Error> = <Error<same_version_error>>("already installed")
<log<same_version_error>>
<log<Error<same_version_error>>>Both print:
2026-03-28 14:30:00: [SAME_VERSION_ERROR] already installed#Return Value
<log> always returns None. It exists purely for its side effect (writing to stderr). You would not normally assign its result to a variable, but if you do:
x := <log>("hello")
// x is None#Key Facts
| Property | Details |
|---|---|
| Output destination | stdout |
| Timestamp format | YYYY-MM-DD HH:MM:SS |
| Tag formatting | Uppercased in output |
| Return value | None |
| Parens required? | No — optional when there is no message |
| Allowed tags | Any identifier (info, Error, debug, my_tag, etc.) |
#When to Use <log>
Use <log> whenever you want to record what your program is doing without affecting its output:
- Debugging: Add
<log<debug>>("entering parse loop")to trace execution flow. - Error reporting: After catching an error with
guardoroption, log the details with<log<Error>>(err.msg). - Monitoring: Log important events like
<log<info>>("user {user_id} logged in")for later review. - Diagnostics: Use nested tags like
<log<perf<slow>>>("query took 3.2s")to categorize performance observations.
Because <log> writes to stderr, you can redirect logs to a file while keeping your program's stdout output separate:
oxigen my_program.oxi 2> app.log#11. Angle Forms Inside option
option is Oxigen's multi-arm conditional. It evaluates conditions top to bottom and runs the first truthy arm. Angle forms extend option with explicit error handling through the <Error> arm.
#How option Works (Quick Review)
status := option {
age >= 18 -> "adult",
"minor"
}- Each arm has a condition (
age >= 18) and a body ("adult"), separated by->. - The last arm without
->is the default. - The first truthy condition wins. Its body is evaluated and becomes the result.
#The <Error> Arm
You can add an <Error> -> body arm to option. This arm catches any propagating error that occurs anywhere during the option evaluation:
result := option {
risky_call() -> "success",
<Error> -> "something went wrong"
}Breaking down the syntax:
option { ... }— the conditional blockrisky_call() -> "success"— a normal arm: ifrisky_call()is truthy, return"success"<Error> -> "something went wrong"— the error arm: if any propagating error occurs, return"something went wrong"
#When Does the <Error> Arm Fire?
The <Error> arm fires in three situations. Understanding all three is important.
Situation 1: A condition evaluation produces an error.
If evaluating the condition on the left side of -> produces a propagating error, the <Error> arm catches it:
result := option {
missing_variable == 1 -> "found it",
<Error> -> "condition failed"
}
// result is "condition failed"Why this works: missing_variable is not defined. Evaluating missing_variable == 1 produces a propagating error ("identifier not found"). Normally this would halt the program, but the <Error> arm catches it and returns "condition failed".
Situation 2: A matched body produces an error.
If a condition is truthy but evaluating its body (the right side of ->) produces an error, the <Error> arm catches it:
result := option {
True -> nonexistent_function(),
<Error> -> "body failed"
}
// result is "body failed"Why this works: The condition True is truthy, so Oxigen tries to evaluate the body nonexistent_function(). That produces a propagating error. The <Error> arm catches it.
Situation 3: The default body produces an error.
If no condition matches and the default arm (the last arm without ->) itself produces an error:
result := option {
False -> "nope",
nonexistent_function(),
<Error> -> "default failed"
}
// result is "default failed"Why this works: False does not match, so Oxigen falls through to the default arm nonexistent_function(). That errors. The <Error> arm catches it.
#Without an <Error> Arm
If there is no <Error> arm, errors propagate normally and can halt your program:
result := option {
missing == 1 -> "found"
}
// This propagates the "identifier not found" error — program haltsThe <Error> arm is optional. Only add it when you want option to handle errors internally.
#Rethrowing from the <Error> Arm
You can use <fail> inside the <Error> arm to propagate a different error with a clearer message:
result := option {
connected -> fetch_data(),
<Error> -> <fail>("request failed")
}This catches whatever error occurred (maybe "connection refused" or "DNS lookup failed") and replaces it with a single clear message: "request failed". The new error propagates to the caller.
#Using Error Values in option Conditions
Because error values (created with <Error>) are truthy, they work as option conditions. This lets you branch on error properties:
err := <Error<auth>>("token expired")
message := option {
err.tag == "auth" -> "please log in again",
err.tag == "network" -> "check your connection",
"unknown error: {err.msg}"
}
println(message) // "please log in again"This works because err is a value (tier 2), not a propagating error. It sits in a variable and you can inspect its .tag field in conditions.
#Complete option + Error Example
Here is a realistic example: a function that tries to read a config file and falls back to defaults if anything fails.
fun load_config() {
// Wrap the file read so it doesn't propagate
file_result <Error || Value> := <type<Error || Value>>(read_file("config.oxi"))
config := option {
is_value(file_result) -> file_result.value,
{
// File read failed — try environment variable
env_result <Error || Value> := <type<Error || Value>>(get_env("APP_CONFIG"))
option {
is_value(env_result) -> env_result.value,
<fail>(<Error<config>>("no config source available"))
}
}
}
config
}Step by step:
read_file("config.oxi")is wrapped with<type<Error || Value>>so it does not propagate.- If the file read succeeded (
type == "VALUE"), use its contents. - If the file read failed, try the environment variable (also wrapped).
- If both failed, propagate a tagged error with
<fail>.
#12. Angle Forms and choose Patterns
choose evaluates a subject value against named patterns. It is different from option — option checks boolean conditions, while choose matches a value against named patterns.
#How choose Works (Quick Review)
pattern is_even(n) when n % 2 == 0
pattern is_odd(n) when n % 2 != 0
choose 42 {
is_even -> "even",
is_odd -> "odd"
}The subject (42) is passed to each pattern. The first pattern whose condition is truthy wins.
#Error Flow in choose
Errors can occur at three points during a choose. Unlike option, choose does not have a built-in <Error> arm. Errors propagate immediately.
Point 1: Subject evaluation errors.
If the subject expression itself errors, the error propagates and the choose stops:
choose nonexistent_function() {
is_even -> "even",
else -> "other"
}
// propagating error — program haltsPoint 2: Pattern condition errors.
If evaluating a pattern's when condition produces an error, it propagates:
pattern bad_pattern(n) when n / 0 == 1
choose 42 {
bad_pattern -> "matched",
else -> "other"
}
// propagating error from the division by zeroPoint 3: Arm body errors.
If the matched arm's body expression errors, it propagates:
choose 42 {
is_even -> nonexistent_function(),
else -> "other"
}
// propagating error from the body#Protecting choose with guard
Since choose has no built-in error arm, use guard to recover from errors:
result := (choose some_value {
is_valid -> process(some_value),
else -> "unknown"
}) guard err -> "error during classification: {err.msg}"The parentheses (...) around the choose let the guard apply to the whole choose expression. Without them, guard would only apply to the last arm's body.
#Protecting choose with option + <Error>
Wrap the choose inside an option to use the <Error> arm:
result := option {
True -> {
choose user_input {
is_number -> parse_number(user_input),
is_date -> parse_date(user_input),
else -> user_input
}
},
<Error> -> "failed to classify input"
}Here option acts as a safety net around choose. If anything inside the choose errors, the <Error> arm catches it.
#Using Error Values as choose Subjects
Because error values are regular values, you can match on them with patterns:
pattern is_network_error(e) when e.tag == "network"
pattern is_auth_error(e) when e.tag == "auth"
pattern is_timeout(e) when e.tag == "timeout"
err := <Error<network>>("connection refused")
message := choose err {
is_network_error -> "check your connection",
is_auth_error -> "please log in again",
is_timeout -> "try again later",
else -> "unknown error: {err.msg}"
}
println(message) // "check your connection"This works because:
erris an error value (tier 2), not a propagating error. It is a regular value.- Each pattern receives
erras its parameter and checkse.tag. - The first matching pattern wins.
#Combining choose with Expected Results
A practical pattern — classify the outcome of a fallible operation using is_value() and is_error() inside option:
result <Error || Value> := <type<Error || Value>>(fetch_user(user_id))
option {
is_value(result) -> println("Got user: {result.value}"),
is_error(result) -> println("Failed: {result.msg}"),
println("unexpected result type")
}Or use patterns with choose for more complex routing:
pattern is_success(r) when is_value(r)
pattern is_failure(r) when is_error(r)
result <Error || Value> := <type<Error || Value>>(fetch_user(user_id))
choose result {
is_success -> println("Got user: {result.value}"),
is_failure -> println("Failed: {result.msg}"),
else -> println("unexpected result type")
}#13. Entry Point with main
Note: Although
mainis documented alongside angle forms for completeness (since it relates to module vs script behavior), it uses keyword syntax, not angle bracket syntax.mainis a keyword, not an angle form.
main marks the entry point of a script. Code inside main only runs when the file is executed directly — it is skipped when the file is imported as a module via introduce.
Everything outside main (function definitions, struct definitions, patterns, etc.) runs in both contexts. This lets you write files that work as both libraries and scripts.
#What the Syntax Means
main {
println("running as script")
}Breaking this down:
main— the keyword that says "this block is the script entry point"{ ... }— the block of statements to execute only when run directly
#Library + Script Example
// math_utils.oxi
fun square(n <int>) { n * n }
fun cube(n <int>) { n * n * n }
main {
println(square(5))
println(cube(3))
}When run directly (oxigen math_utils.oxi): prints 25 and 27.
When imported via introduce "math_utils": the functions square and cube are available, but nothing is printed.
#Return Value
main returns the value of the last expression in its body, or None if the body is empty. This is consistent with how all blocks work in Oxigen.
#Indent Mode
In #[indent] mode, use main: to start the block:
#[indent]
fun square(n <int>):
n * n
main:
println(square(5))#When to Use main
Use main when you want a file to serve double duty — reusable definitions at the top, and a script entry point at the bottom. This is similar to Python's if __name__ == "__main__": pattern.
#14. Complete Error Handling Recipes
These recipes show how to combine angle forms for common scenarios. Each one explains what is happening and why it works.
#Recipe: Simple Try-Catch
The closest thing to try/catch in other languages. Try an expression, use a fallback if it fails:
result := risky_operation() <guard>("safe default")Why it works: <guard> catches the propagating error from risky_operation() and replaces it with "safe default". The program continues normally.
#Recipe: Try-Catch with Error Inspection
Same idea, but examine the error to decide what fallback to use:
result := risky_operation() guard err -> {
option {
err.tag == "network" -> "offline mode",
err.tag == "auth" -> "please log in",
"unknown error"
}
}Why it works: The keyword guard err -> binds the caught error to err. The option block inspects err.tag to pick the right fallback message. The err variable is an error value with .msg and .tag fields available inside the fallback.
#Recipe: Log-Then-Recover
Log what you are about to do, then guard the operation with a fallback:
<log<info>>("fetching data...")
result := fetch_data() <guard>("cached fallback")Why it works: <log> writes a timestamped message to stderr. Then fetch_data() runs. If it fails, <guard> catches the error and replaces it with "cached fallback". The log line is always written, regardless of whether fetch_data() succeeds or fails.
#Recipe: Log After Catching an Error
Normalize the operation, inspect the outcome, and log errors before deciding what to do:
result := <type<Error || Value>>(fetch_data())
option {
is_error(result) -> {
<log<Error>>(result.msg)
"cached fallback"
},
result.value
}Why it works: <type<Error || Value>> catches any propagating error and turns it into an error value. The option block checks whether the result is an error. If so, it logs the error message to stderr with <log<Error>> and returns a fallback. If the call succeeded, it unwraps the value.
#Recipe: Normalize and Branch
Wrap a fallible call into an expected-result, then branch on success or failure:
outcome <Error || Value> := <type<Error || Value>>(connect_to_database())
option {
is_value(outcome) -> {
db := outcome.value
println("Connected to database")
},
{
println("Database unavailable: {outcome.msg}")
println("Running in offline mode")
}
}Why it works: <type<Error || Value>> catches the propagating error and converts it to an error value. The option block checks type(outcome) to decide which path to take. Both paths are normal program flow — no errors propagate.
#Recipe: Multiple Fallible Steps
When several steps might fail, normalize each one independently:
config <Error || Value> := <type<Error || Value>>(read_file("config.oxi"))
db <Error || Value> := <type<Error || Value>>(connect_db())
option {
is_error(config) -> <fail>(<Error<startup>>("config: {config.msg}")),
is_error(db) -> <fail>(<Error<startup>>("database: {db.msg}"))
}
// If we reach here, both succeeded
println("Config: {config.value}")
println("Database: {db.value}")Why it works: Each call is individually normalized. If either fails, the option block detects the failure type and propagates a clear tagged error. If both succeed, the program continues to use .value on each.
#Recipe: Error Arm as Safety Net
Use option's <Error> arm as a catch-all when multiple branches might fail:
result := option {
input_valid(data) -> process(data),
needs_retry(data) -> retry(data),
<fail>(<Error<validation>>("unrecognized input")),
<Error> -> <Error<internal>>("unexpected failure during processing")
}Why it works: If input_valid(data), process(data), needs_retry(data), or retry(data) produce a propagating error, the <Error> arm catches it and returns an error value (not a propagating error). The default arm uses <fail> intentionally — if no condition matches, it propagates a validation error.
#Recipe: Tagged Error Pipeline
Create errors with tags, propagate them, and catch only specific tags downstream:
fun validate(input <str>) {
option {
len(input) == 0 -> <fail>(<Error<validation>>("input is empty")),
len(input) > 100 -> <fail>(<Error<validation>>("input too long")),
input
}
}
// Only catch validation errors — let all other errors propagate normally
cleaned := validate(user_input) <guard<Error<validation>>>("(invalid)")Why it works: validate uses <fail> with tagged error values. The <guard<Error<validation>>> at the call site only catches errors tagged validation. If validate encountered a different kind of error (like a system-level failure), it would propagate past the guard.
#Recipe: Classify Errors with choose
Use patterns to route different categories of errors to different handlers:
pattern is_network(e) when e.tag == "network"
pattern is_auth(e) when e.tag == "auth"
pattern is_retryable(e) when e.tag == "retry_error"
err := <Error<network>>("connection refused")
action := choose err {
is_retryable -> "retrying...",
is_network -> "switching to offline mode",
is_auth -> "redirecting to login",
else -> "unhandled error: {err.msg}"
}Why it works: The error value is a regular value, so choose can match on it using patterns. Each pattern checks .tag to classify the error. The matched arm's body becomes the result.
#15. Current Built-In Angle Forms Reference
#Type-side (declaration position)
Used after a variable name to declare its type:
<int> // integer
<float> // floating point
<str> // string
<bool> // boolean
<char> // character
<byte> // byte (0-255)
<uint> // unsigned integer
<array> // array
<map> // map (dictionary)
<set> // set
<tuple> // tuple
<Error> // error value type
<Error<tag>> // tagged error value type
<Value> // success value type
<Error || Value> // expected-result union type#Expression-side (prefix position)
Used at the start of an expression to construct or transform:
<Error>("message") // create error value (does NOT halt)
<Error<tag>>("message") // create tagged error value (does NOT halt)
<Value>(expr) // wrap value as explicit success
<type<Error || Value>>(expr) // normalize expression to expected-result
<type<Error<tag> || Value>>(expr) // normalize with default tag on errors
<fail>("message") // propagate as runtime error (HALTS)
<fail>(<Error<tag>>("message")) // propagate tagged error (HALTS)#Logging (standalone)
Used as a standalone statement to write timestamped messages to stderr:
<log>("message") // log message to stderr
<log<tag>>("message") // log with tag to stderr
<log<Error>>("message") // log with Error tag
<log<Error<sub_tag>>>("message") // log with nested tags
<log<tag>> // log tag only (no message)#Effect-side (postfix position)
Used after a completed expression to apply recovery:
expr <guard>("fallback") // recover with fallback value
expr <guard<Error<tag>>>("fallback") // recover only if tag matches
expr guard err -> fallback_expr // recover with error binding (keyword form)#Block-level (keyword syntax)
Used at the top level of a file to control execution context. Note that main uses keyword syntax, not angle bracket syntax:
main { ... } // entry point — runs only when file is executed directly#16. Design Rules
When reading or writing Oxigen code, these rules keep angle forms understandable:
- Declaration position = types. After a variable name,
<...>is always a type annotation. - Prefix position = construction. At the start of an expression,
<...>constructs or transforms a value. - Postfix position = effects. After a completed expression,
<...>applies recovery (withguard). <Error>creates values,<fail>creates propagation. Never confuse the two.<Error>is data,<fail>is control flow.<Error<tag>>for categories. Use tags for lightweight error grouping without custom types.<type<Error || Value>>for expected failure. When you know a call might fail and you want to handle both paths explicitly without the program halting.guardfor recovery. When you have a fallback and want to keep the program running.<log>for logging. Use<log>to write timestamped messages to stderr with optional tags.option+<Error>arm for structured recovery. When multiple conditions or bodies inside anoptionmight fail and you want one safety net.choosedoes not have<Error>. Wrapchooseinguardoroptionif you need error recovery.mainfor entry points. Usemain(keyword syntax, not an angle form) to mark code that should only run when the file is executed directly, not when imported as a module.
#17. Error Messages
All runtime errors in Oxigen include helpful context to make debugging easier. When something goes wrong, the error output tells you where the problem is, what the source code looks like at that point, and how you might fix it.
#What You See
Every runtime error includes three pieces of information:
- Source position — the line number and column where the error occurred.
- Source code excerpt — the actual line of code, with a caret (
^) pointing to the exact position of the problem. - Hint — a suggestion for how to fix the issue.
#Example
If your code divides by zero:
result := 10 / 0Oxigen prints:
error: division by zero
--> line 3:10
|
3 | result := 10 / 0
| ^
= hint: ensure the divisor is not zero before dividingReading this output:
error: division by zero— what went wrong.--> line 3:10— the error is on line 3, column 10.3 | result := 10 / 0— the source code line.^— points to the exact position where the error was detected.= hint: ensure the divisor is not zero before dividing— a suggestion for fixing it.
#Why This Matters
Helpful error messages reduce the time you spend debugging. Instead of seeing a cryptic "runtime error" with no context, you get a clear pointer to the problem and a nudge toward the solution. This is especially useful when errors occur deep inside nested expressions or function calls — the source position and caret show you exactly where to look.