template/docs/result-slides.md
2026-05-04 19:15:11 +00:00

180 lines
6.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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**: +300600 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.--->