Error Philosophy - HeyItWorked/babel-shelf GitHub Wiki
Three languages, three fundamentally different answers to: "What happens when something goes wrong?"
| School | Language | Mechanism | Philosophy |
|---|---|---|---|
| Explicit errors | Go |
(value, error) return |
"Errors are just values. Check them." |
| Exceptions | TypeScript/Python |
throw / try-catch
|
"Errors are exceptional. Handle them if you want." |
| Result types | Gleam/Rust | Result(Ok, Error) |
"Errors are expected. The compiler makes you handle them." |
book, err := insertBook(book)
if err != nil {
http.Error(w, "could not create book", 500)
return
}- Every error path is visible in the code. No hidden control flow.
- You can wrap errors with context:
fmt.Errorf("insert failed: %w", err) - Errors are just interfaces — you can create custom error types.
-
if err != nilfatigue — you write this dozens of times per file. -
Easy to forget — nothing stops you from ignoring the error:
result, _ := insertBook(book) // _ silently discards the error
-
No stack traces by default — you just get a string like
"sql: no rows".
The handlers always check errors, but with a shortcut — any DB error in getBookById returns "not found" (even if it was a connection error). A production app would distinguish:
if errors.Is(err, sql.ErrNoRows) {
// actually not found
} else {
// database is broken
}// handlers.ts — no try/catch at all!
export async function createBook(c: Context): Promise<Response> {
const body = await c.req.json()
const book = await insertBook(body) // if this throws, Hono catches it
return c.json(book, 201)
}- Clean happy path — no error-checking noise in the main logic.
- Propagation is free — errors bubble up through the call stack automatically.
- Hono catches unhandled errors and returns 500.
-
Invisible failure modes —
insertBookcan fail, but the handler signature doesn't tell you. - No compiler enforcement — TypeScript won't warn if you forget to catch.
-
Any function can throw — including ones you don't expect (like
JSON.parse).
The TS bookshelf doesn't use try/catch at all in handlers. If pool.query throws, Hono's error handler catches it and returns 500. This works for a learning project but would need error handling in production.
The one exception is deleteBook, which returns a boolean instead of throwing:
export async function deleteBook(id: number): Promise<boolean> {
const result = await pool.query(sql, [id])
return result.rowCount !== null && result.rowCount > 0
// no throw — converts "not found" to false
}// Gleam — you MUST handle both cases
case insert_book(conn, title, author, status) {
Ok(book) -> json_response(encode_book(book), 201)
Error(DbError(e)) -> wisp.internal_server_error()
Error(NotFound) -> wisp.not_found()
}-
Compiler enforces handling — if you don't match
Error, it won't compile. - Exhaustive matching — add a new error variant, every handler that doesn't handle it breaks at compile time.
-
No exceptions — the only way to fail is through
Result. No hiddenthrow.
- More verbose than exceptions for the happy path.
- Must convert at boundaries — library functions return their own error types, you need to map them to yours.
Without use, error handling is deeply nested:
case int.parse(id_string) {
Error(_) -> wisp.bad_request()
Ok(id) ->
case get_book_by_id(conn, id) {
Error(_) -> wisp.not_found()
Ok(book) -> json_response(encode_book(book), 200)
}
}With use, it flattens:
use id <- result.try(int.parse(id_string) |> result.replace_error(BadRequest))
use book <- result.try(get_book_by_id(conn, id))
json_response(encode_book(book), 200)use is syntactic sugar for "if this returns Error, stop and return it. If it returns Ok, unwrap the value and continue."
Scenario: User sends GET /books/abc — the ID isn't a number.
// Go — parse returns error, handler checks it
id, err := strconv.Atoi(r.PathValue("id")) // Atoi("abc") → 0, error
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}// TypeScript — parse returns NaN, handler checks it
const id = Number(c.req.param("id")) // Number("abc") → NaN
if (isNaN(id)) {
return c.text("invalid id", 400)
}// Gleam — parse returns Result, pattern match
case int.parse(id_string) {
Ok(id) -> get_book(id, ctx)
Error(_) -> wisp.bad_request()
}# Python — parse raises exception
try:
id = int(request.args["id"]) # int("abc") → ValueError
except ValueError:
return "invalid id", 400| Language | Parse failure returns | How you detect it | What if you don't check? |
|---|---|---|---|
| Go | (0, error) |
if err != nil |
0 silently used as ID |
| TypeScript | NaN |
isNaN() |
Query with NaN — DB error or weird behavior |
| Python | Raises ValueError
|
try/except |
Unhandled exception → 500 |
| Gleam | Error(Nil) |
case match |
Won't compile — must match both variants |
Each approach has a trust model:
- Go trusts the developer to check errors. Easy to get right, easy to forget.
- TypeScript trusts the framework to catch uncaught errors. Convenient, but masks problems.
- Gleam trusts the compiler to enforce correctness. Annoying initially, impossible to get wrong.
The babel-shelf project is small enough that all three approaches produce working code. The differences matter more at scale:
- Go's
if err != nilbecomes tedious in a 100-file codebase, but the error paths are always visible. - TypeScript's implicit throwing becomes dangerous when you can't tell which functions throw and which don't.
- Gleam's
Resulttypes add friction to every function call, but refactoring never introduces unhandled errors.
Which is "best"? Depends on what you optimize for. Go optimizes for readability. TypeScript optimizes for velocity. Gleam optimizes for correctness.