Error Philosophy - HeyItWorked/babel-shelf GitHub Wiki

Error Philosophy

Three languages, three fundamentally different answers to: "What happens when something goes wrong?"


The Three Schools

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."

Go: Errors Are Values

book, err := insertBook(book)
if err != nil {
    http.Error(w, "could not create book", 500)
    return
}

The Good

  • 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.

The Bad

  • if err != nil fatigue — 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".

In This Project

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
}

TypeScript: Exceptions (The Default Path)

// 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)
}

The Good

  • 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.

The Bad

  • Invisible failure modesinsertBook can 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).

In This Project

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: Result Types (Coming in the Gleam Implementation)

// 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()
}

The Good

  • 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 hidden throw.

The Bad

  • 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.

The use Keyword (Gleam's Ergonomic Shortcut)

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."


Comparison: The Same Error in Three Languages

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

The Deeper Lesson

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 != nil becomes 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 Result types 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.

⚠️ **GitHub.com Fallback** ⚠️