error handling kickoff

This commit is contained in:
2026-06-29 22:55:56 +02:00
parent 98c303d22c
commit ae5af37b85
14 changed files with 1229 additions and 173 deletions
+105 -88
View File
@@ -552,11 +552,46 @@
inside arm bodies. As a side effect the rvalue pointer-capture guard from 22.5 is now reachable
(`match make_box() { .v |@p|: … }` correctly errors "requires an addressable subject")
- checker-only; no parser/AST/HIR/IR/lowering/codegen change
- deferred: payload-carrying contextual construction (`.variant{…}`, milestone 23); a
`yield call()` inside a value-block/value-match arm still misses its spec (`infer_statements`
has no `.Yield` case either — a separate pre-existing gap)
- deferred: payload-carrying contextual construction (`.variant{…}`) remains outside milestone
23 v1. The separate `yield call()` inference gap is fixed in milestone 23.
23. error types (see below)
23. sum-type composition, fallible channels, and yield inference (implemented; v1)
- `.Yield` is now visited by specialization-demand inference, so `yield call()` inside value
blocks, value-if/value-match branches, and match arms requests the needed call specialization
- native unbacked enums and native `union(enum)` / `union(SomeEnum)` variants are registered in a
program-global variant table keyed by `(name, payload-type)`. Runtime tags are fixed `u16`
global ids; `0` is reserved for fallible success/no-error, and overflow is diagnosed
- tagged-union construction, tag reads, matching, and LLVM layout use those global ids rather
than per-type dense tags. Component-to-composite widening preserves the tag and copies only the
active payload carrier bytes from the source carrier offset to the destination carrier offset
- `A | B` composes native unbacked enums and native tagged unions: matching `(name, payload-type)`
variants merge, same-name/different-payload conflicts are diagnosed, and backed enums, C enums,
and untagged unions are rejected for v1
- `T ! E` is represented as a real synthetic fallible channel type in `types.Store`. A fallible
function returns success with code `0` or an error variant's global id; `return expr` dispatches
based on whether `expr` coerces to the success type `T` or the error type `E`
- `try expr` unwraps success and propagates the enclosing function's exact error channel;
`expr catch fallback` unwraps success or evaluates the fallback value
- runtime coverage lives in `examples/programs/errors`; compiler coverage includes global-id sum
merge/conflict/widening/rejections and the `.Yield` inference regression
- deferred follow-ups are split below; 23.5 keeps the next user-visible slice small
23.5. fallible ergonomics: catch blocks + composable try widening
- implement `expr catch |e| { ... }` with `e : E`
- allow `try` to widen `T ! E1` into an enclosing `T ! (E1 | E2)` channel
- add focused tests/examples for both
- leave ABI/layout/lint/design polish for later milestones
23.6. contextual payload construction + inline error types
- implement contextual payload `.variant{...}` construction
- allow named fallible signatures to use inline enum / tagged-union error types
- settle qualified same-name disambiguation only if contextual construction needs it
23.7. sum-type ABI/layout polish
- consider dynamic tag-width shrinking after the fixed-`u16` ABI has real pressure
- consider all-void channel collapse after fallible channels are otherwise stable
- define cross-module/global-id ABI determinism before multi-module builds depend on it
- keep backed/C enum composition and must-consume fallible linting as later policy work
24. dynamic heap allocation
- see below for direction
@@ -572,7 +607,7 @@ name: ?[]u8 = get_name()
age: ?u8 = get_age()
if name and age |n, a| {
# both n and a are guaranteed non-none here
print("{s} is {d} years old", {n, a})
print("{s} is {d} years old", n, a)
}
```
@@ -580,7 +615,7 @@ if name and age |n, a| {
```
if name and hat |n, h : n == "Huginn" and h.brand == .gucci| {
print("{s}'s got that drip\n", {n})
print("{s}'s got that drip\n", n)
}
```
@@ -765,7 +800,7 @@ message = "Header:\t" ++
The `yield` keyword provides a value from a block to its enclosing expression and **exits the block immediately** — just as `return` exits a function, `yield` exits the enclosing scope. Code after a `yield` is unreachable, and the compiler flags it. This makes `yield` part of a consistent set of scope-exiting control flow: `return` exits a function, `yield` exits a block, `break` exits a loop, and `continue` skips to the next iteration.
It is used in scoped blocks, match arms, and catch handlers.
It is used in scoped blocks and match arms today; catch-handler blocks are planned.
**General rule:** When a block needs to produce a value, single expressions yield implicitly while multi-statement blocks require explicit `yield`. This rule applies uniformly across the language:
@@ -785,8 +820,7 @@ label []u8 = match p {
},
}
# catch handlers
data []u8 = read(path) catch default_data # single expression: implicit
# catch handlers (planned; block form deferred in milestone 23 v1)
data []u8 = read(path) catch |e| {
log(e)
yield fallback_data # block: explicit yield
@@ -895,7 +929,7 @@ match result {
process(data)
}
.failure |info|: {
print("error {d}: {s}", { info.code, info.msg })
print("error {d}: {s}", info.code, info.msg)
}
.pending: {
# void payload - no capture needed
@@ -938,14 +972,16 @@ message :: match code {
## A word on error handling
Honey handles errors as values. There is no hidden control flow — a function that can fail declares this in its signature, and the caller must explicitly handle the possibility of failure.
Brolang handles errors as values. There is no hidden control flow — a function that can fail declares this in its signature, and the caller must explicitly handle the possibility of failure.
Milestone 23 v1 implements named error channels, native sum composition, `return`-based error dispatch, exact-channel `try`, and fallback `catch`. It intentionally defers the `error` keyword shorthand, inline error types, `catch |e|` blocks, and `try` widening across different-but-composable error channels.
### Fallible Functions
Functions that can fail declare their error type after `!`:
```honey
read_file :: func(path: []u8) []u8 ! IoError { ... }
```
read_file :: func(path []u8) []u8 ! IoError { ... }
```
This reads as: "returns `[]u8` or fails with `IoError`." The space around `!` is idiomatic but not required.
@@ -956,7 +992,7 @@ Errors can be enums (when you only need to identify what went wrong) or tagged u
**Simple errors (enum):**
```honey
```
SimpleError :: enum {
OutOfMemory,
InvalidSize,
@@ -966,12 +1002,12 @@ SimpleError :: enum {
**Rich errors (tagged union):**
```honey
```
IoError :: union(enum) {
NotFound: struct { path: []u8 },
PermissionDenied: struct { path: []u8, operation: []u8 },
Timeout: struct { after_ms: u64 },
ConnectionReset: void, # no additional data needed
NotFound struct { path: []u8 },
PermissionDenied struct { path []u8, operation []u8 },
Timeout struct { after_ms u64 },
ConnectionReset void, # no additional data needed
}
```
@@ -979,7 +1015,7 @@ IoError :: union(enum) {
When you have a predefined set of error kinds, you can constrain the union:
```honey
```
IoErrorKind :: enum {
not_found,
permission_denied,
@@ -987,64 +1023,43 @@ IoErrorKind :: enum {
}
IoError :: union(IoErrorKind) {
not_found: struct { path: []u8 },
permission_denied: struct { path: []u8 },
timeout: struct { after_ms: u64 },
not_found struct { path []u8 },
permission_denied struct { path []u8 },
timeout struct { after_ms u64 },
}
```
### Error Composition
Functions that can fail with multiple error types use `|` to compose them:
Functions that can fail with multiple error types use `|` to compose a named error type:
```honey
process :: func(path: []u8) Ast ! IoError | ParseError { ... }
```
ProcessError :: alias IoError | ParseError
process :: func(path []u8) Ast ! ProcessError { ... }
```
Parentheses are optional but can aid readability:
Parentheses are optional in the composed type and can aid readability:
```honey
process :: func(path: []u8) Ast ! (IoError | ParseError) { ... }
```
Composed error types can also be defined standalone:
```honey
ProcessError :: IoError | ParseError | ValidationError
process :: func(path: []u8) Ast ! ProcessError { ... }
ProcessError :: alias (IoError | ParseError)
```
### Inline Error Types
For simple one-off cases, you can inline the error type:
```honey
# Inline enum
simple :: func() void ! enum{Failed, Timeout} { ... }
# Inline tagged union (when payloads are needed)
complex :: func() Data ! union(enum) {
OutOfMemory: struct { requested: usize },
InvalidInput: void,
} { ... }
```
For anything non-trivial, prefer named error types for clarity.
Inline error types are planned, but not part of milestone 23 v1. Use named enums, named tagged unions, or named aliases for now.
### Returning Errors
The `error` keyword is a control flow statement that exits the function via the error channel — symmetric to return for success values:
Fallible functions use ordinary `return` for both channels. If the returned expression coerces to the success type `T`, the function returns success with channel code `0`. If it coerces to the error type `E`, the function returns the error with that variant's global tag id:
```honey
```
parse_section :: func(p: @mut Parser) void ! ParseError {
start_line := p.line
start_line Line = p.line
p.advance()
# ... parsing logic ...
if p.pos >= p.input.len or p.input[p.pos] != ']' {
error .unclosed_section{.line = start_line}
}
if p.pos >= p.input.len or p.input[p.pos] != ']' return ParseError{ unclosed_section = start_line }
# ... continue on success ...
}
@@ -1052,48 +1067,49 @@ parse_section :: func(p: @mut Parser) void ! ParseError {
Since errors are just union values, you can also construct them separately:
```honey
```
# Construct error value (it's just a union)
e: ParseError = .timeout{.ms = 500}
e ParseError = ParseError{ timeout = 500 }
# Return it via error channel later
error e
return e
```
The symmetry:
* `return x` — exit function with success value
* `error e` — exit function with error value
* `return x` — exits with success when `x` is the success type
* `return e` — exits with error when `e` is the error type
### Propagation with `try`
The `try` keyword unwraps a successful result or returns early with the error:
```honey
process :: func(path: []u8) Ast ! IoError | ParseError {
data := try read_file(path) # returns early if IoError
ast := try parse(data) # returns early if ParseError
```
ProcessError :: alias IoError | ParseError
process :: func(path []u8) Ast ! ProcessError {
data :: try read_file(path) # read_file also returns []u8 ! ProcessError in v1
ast :: try parse(data) # parse also returns Ast ! ProcessError in v1
return ast
}
```
When the function's error type is a composition, `try` automatically widens narrower error types to match the return type.
In v1, `try` propagates only when the callee's error channel exactly matches the enclosing function's error channel. Widening narrower error types into a composed return channel is a planned follow-up.
### Handling with `catch`
The `catch` keyword handles errors and provides a value to continue with.
The `catch` keyword handles errors and provides a value to continue with. Milestone 23 v1 implements the fallback-value form.
**Provide a fallback value:**
```honey
data := read_file(path) catch default_data
```
data :: read_file(path) catch default_data
```
**Handle with a block using** `yield`:
**Planned block form using** `yield` (deferred in v1):
```honey
data := read_file(path) catch |e| {
log("read failed: {}", {e})
```
data :: read_file(path) catch |e| {
log("read failed: {}", e)
yield empty_data
}
use(data) # continues with data = empty_data
@@ -1101,11 +1117,11 @@ use(data) # continues with data = empty_data
The `yield` keyword provides a value from a block to the enclosing expression. Execution continues after the statement. For single expressions, yield is implicit (e.g., `catch default_data`). For blocks, explicit `yield` is required. See the Yield section under Control Flow for the full rule.
**Return from the function instead:**
**Planned return-from-handler form** (deferred in v1):
```honey
data := read_file(path) catch |e| {
log("read failed: {}", {e})
```
data :: read_file(path) catch |e| {
log("read failed: {}", e)
return # exits the enclosing function
}
use(data) # never reached if error occurred
@@ -1113,16 +1129,16 @@ use(data) # never reached if error occurred
Use `return` when the error is unrecoverable at this level.
**Match on specific error variants:**
**Planned match-on-error form** (deferred in v1):
```honey
data := read_file(path) catch |e| match e {
```
data :: read_file(path) catch |e| match e {
.NotFound |info|: {
print("file not found: {s}", {info.path})
print("file not found: {s}", info.path)
yield create_default(info.path)
},
.Timeout |info|: {
print("timed out after {d}ms", {info.after_ms})
print("timed out after {d}ms", info.after_ms)
yield retry(path)
},
.PermissionDenied: panic("cannot recover from permission error"),
@@ -1135,16 +1151,17 @@ data := read_file(path) catch |e| match e {
| Syntax | Meaning |
| -- | -- |
| `T ! E` | Function returns `T` or fails with `E` |
| `T ! E1 | E2` | Function can fail with either error type |
| `T ! (E1 | E2)` | Same, with optional parentheses |
| `error e` | Exit function via error channel |
| `error .variant{...}` | Construct and return error in one step |
| `try expr` | Unwrap success or propagate error |
| `T ! E1 | E2` | Planned direct spelling; use a named alias in v1 |
| `T ! (E1 | E2)` | Planned direct spelling with parentheses; use a named alias in v1 |
| `return e` | Exit function via error channel when `e : E` |
| `error e` | Planned shorthand, not v1 |
| `error .variant{...}` | Planned shorthand, not v1 |
| `try expr` | Unwrap success or propagate an exact matching error channel |
| `expr catch fallback` | Provide fallback value on error |
| `expr catch |e| { ... }` | Handle error with block |
| `expr catch |e| { ... }` | Planned block handler, not v1 |
| `yield value` | Provide value from innermost block |
| `yield :label value` | Provide value from labeled block |
| `return` / `return value` | Exit function from within catch block |
| `return` / `return value` | Exit the current function; in fallible functions, return value dispatches by type |
## A word on memory allocation