180 lines
6.8 KiB
Markdown
180 lines
6.8 KiB
Markdown
---
|
||
theme: gaia
|
||
highlightTheme: monokai
|
||
margin: 0
|
||
html: true
|
||
marp: true
|
||
---
|
||
|
||
# `result`
|
||
## Happy-Path Error Handling in Go
|
||
|
||
> Focus on success. Handle failure at the boundary.
|
||
|
||
<!---Hi everyone.
|
||
*This* is a 10-minute walkthrough of the result package — one type, a handful of functions, and a pattern that changes how we write application-layer error handling. No frameworks, no dependencies.--->
|
||
|
||
---
|
||
|
||
## The Problem
|
||
|
||
```go
|
||
func process(raw string) (Output, error) {
|
||
h, err := parseHeader(raw)
|
||
if err != nil { return Output{}, err }
|
||
|
||
f, err := validate(h)
|
||
if err != nil { return Output{}, err }
|
||
|
||
r, err := transform(f)
|
||
if err != nil { return Output{}, err }
|
||
|
||
return format(r)
|
||
}
|
||
```
|
||
|
||
- Half the function is error plumbing
|
||
- The actual logic is buried under boilerplate
|
||
|
||
<!---Standard idiomatic Go — nothing wrong with it. But notice: a 4-step function needs 4 error checks. Every layer handles an error it didn't cause and can't fix. As the call stack grows deeper, this pattern multiplies.--->
|
||
|
||
---
|
||
|
||
## The Idea
|
||
|
||
```go
|
||
func process(raw string) error {
|
||
return result.Run(func() {
|
||
h := parseHeader(raw).Expect("parse header")
|
||
f := validate(h).Expect("validate")
|
||
r := transform(f).Expect("transform")
|
||
_ = format(r).Expect("format")
|
||
})
|
||
}
|
||
```
|
||
|
||
- `Expect[T]` holds a value *or* an error — like a typed Result type
|
||
- `.Expect("context")` unwraps on success, exits on failure
|
||
|
||
<!--- Same behavior, same error messages, same semantics — the code now reads as a happy-path story. One error boundary at the bottom instead of four spread through the function. The caller gets a normal Go error; nothing unusual leaks out.--->
|
||
|
||
---
|
||
|
||
## How It Works
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A[".Expect() sees error"] --> B["panic"]
|
||
B --> C["Run's deferred\nrecover()"]
|
||
C --> D["normal Go error"]
|
||
```
|
||
|
||
- `result.Run` spawns one goroutine and defers recovery
|
||
- Failure exits via `panic` — caught and returned as a plain `error`
|
||
|
||
<!---The mechanism is small. result.Run spawns a goroutine and registers a deferred recover. When .Expect() encounters an error it panics with a typed sentinel value. The deferred recovery catches that specific type and returns it as a plain Go error. Genuine runtime panics — nil dereferences, index out of bounds — are re-panicked and still crash the program as they should.--->
|
||
|
||
---
|
||
|
||
## The One Rule
|
||
|
||
```go
|
||
// ✅ pkg/ library — returns Expect[T], never calls .Expect()
|
||
func Parse(raw string) result.Expect[Header] {
|
||
if raw == "" {
|
||
return result.Fail[Header](errors.New("empty input"))
|
||
}
|
||
return result.Ok(Header{raw})
|
||
}
|
||
|
||
// ✅ application code — calls .Expect() inside Run
|
||
h := parser.Parse(raw).Expect("parse header")
|
||
```
|
||
|
||
- Libraries **return** `Expect[T]` — let the caller decide
|
||
- Application code **chains** `.Expect()` inside `Run`
|
||
|
||
<!---This is the contract that makes the package composable. Library code is passive: it wraps a result and hands control back. Calling .Expect() inside a library would exit someone else's goroutine without their consent. Only application code, protected by a Run boundary, calls .Expect(). Think of it as: pkg/ produces, app/ consumes.--->
|
||
|
||
---
|
||
|
||
## Defers Work Normally
|
||
|
||
```go
|
||
result.Run(func() {
|
||
conn := pool.Acquire()
|
||
defer conn.Release() // ✅ always fires — success or failure
|
||
|
||
score := compute(conn).Expect("compute score")
|
||
_ = save(score).Expect("save result")
|
||
})
|
||
```
|
||
|
||
- Failure travels as a *value* — functions return normally
|
||
- Deferred cleanup fires before `.Expect()` triggers the exit
|
||
|
||
<!---A common concern. Defers are completely safe because failure is just a value. When compute() returns a Fail, it returns normally through the call stack — the defer fires. Only when .Expect() is called on that Fail result does the panic happen. By that point all defers up the stack have already run. No resource leaks.--->
|
||
|
||
---
|
||
|
||
## Debugging: Stack Traces
|
||
|
||
```
|
||
result.StackTrace(err):
|
||
|
||
main.processRequest.func1
|
||
/app/handler.go:42
|
||
main.run.func1
|
||
/app/main.go:17
|
||
```
|
||
|
||
- Trace captured at the `.Expect()` call site — pinpoints the failure
|
||
- `result.CaptureStack = false` at startup for production builds
|
||
|
||
<!---When something fails in production you want to know where. result captures a stack trace at the .Expect() call site, not deep inside an stdlib function. In production you can disable this with a single line before main starts; errors still propagate correctly, you just lose the trace. The benchmark shows this saves about 870 ns and 280 B per failure.--->
|
||
|
||
---
|
||
|
||
## Two Build Modes
|
||
|
||
| | Default | `-tags result_goexit` |
|
||
|--|--|--|
|
||
| Exit mechanism | `panic` | `runtime.Goexit` |
|
||
| Error path cost | ~1.4 µs | ~5.5 µs |
|
||
| Stray `recover()` safe | ⚠ re-panic unknowns | ✅ always safe |
|
||
|
||
- **Panic** (default): 4× faster; standard Go `recover()` idiom applies
|
||
- **Goexit** (opt-in): immune to accidental error swallowing by any middleware
|
||
|
||
<!---Go gives two ways to exit a goroutine mid-execution. Panic is faster but can be caught by a bare recover() somewhere up the stack — which would silently swallow the failure. Goexit cannot be intercepted by recover(). We default to panic because standard Go practice is to always check the type in recover() and re-panic anything unrecognised. Use the goexit build if your codebase has aggressive recover() usage in middleware or testing wrappers.--->
|
||
|
||
---
|
||
|
||
## Performance
|
||
|
||
| Scenario | Canonical Go | `result` | `result` (no trace) |
|
||
|--|--|--|--|
|
||
| Happy path | 1 324 ns | 1 046 ns | 1 027 ns |
|
||
| Error at depth 3 | 820 ns | 1 441 ns | 1 149 ns |
|
||
| Error at depth 10 | 1 257 ns | 1 576 ns | 1 113 ns |
|
||
|
||
*arm64, both sides spawn one goroutine per boundary*
|
||
|
||
- **Happy path**: result matches or beats canonical
|
||
- **Error path**: +300–600 ns — rounds to zero next to any I/O
|
||
|
||
<!---Both sides pay the same goroutine-spawn cost, so the comparison isolates only the error-propagation mechanism. Happy path is free. On the error path, result_nostack at depth 10 actually beats canonical — the panic exit skips the normal return sequence through the closure. In any real workload that touches a network, database, or disk, neither overhead is measurable.--->
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
- Write functions for the happy path; handle errors once at the boundary
|
||
- `pkg/` returns `Expect[T]` — application code calls `.Expect()` inside `Run`
|
||
- Defers, concurrent patterns, and stack traces all work as expected
|
||
- Tune with `CaptureStack` and build tags if the profile demands it
|
||
|
||
> **Best fit: application pipelines with 3+ sequential fallible steps.**
|
||
|
||
<!---The one-line pitch for the CTO: result is not a replacement for Go error handling — it's a pattern for the specific case where you have a deep pipeline of sequential operations and the error-threading boilerplate is obscuring what the code actually does. Use standard Go for library APIs. Use result for application orchestration code.--->
|