within-ts

Result

A simple type for functions that can fail — without throwing

A simple type for functions that can fail — without throwing.

The problem

JavaScript's try/catch has two fundamental limitations:

  1. Errors are invisible in type signatures. async function getUser(id: string): Promise<User> — there is no indication of what errors this function may produce, or whether it can fail at all.

  2. Throwing creates implicit control flow. An error thrown several layers deep bypasses every function in between, making it difficult to trace where failures originate.

Go solved this by returning errors as values: result, err := getUser(id). The caller always sees and handles the error explicitly. TypeScript can do the same — we just need a type for it.

The solution

Result is a union of two shapes: Ok<A> (success) and Err<E> (failure). Every result has an .ok boolean that TypeScript uses to narrow the type.

The idiomatic pattern is early return on error — just like Go. Check for failure, bail out, and the happy path stays at the top level with no nesting:

import { Result } from "within-ts"

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return Result.err("division by zero")
  return Result.ok(a / b)
}

const result = divide(10, 0)
if (!result.ok) {
  console.log(result.error) // TypeScript knows this is string
  return
}
console.log(result.value) // TypeScript knows this is number

Wrapping unsafe code at the edges

Most third-party libraries throw exceptions. Wrap them at the boundary to keep your business logic throw-free:

// Wrap a sync function that might throw
const result = Result.try(() => JSON.parse(rawInput))
// Result<any, unknown>

// Wrap an async function that might throw
const result = await Result.tryPromise(() => fetch("/api/users"))
// Result<Response, unknown>

// Map the error to something typed
const result = await Result.tryPromise({
  try: () => fetch("/api/users"),
  catch: (e) => new NetworkError({ cause: e }),
})
// Result<Response, NetworkError>

The three-layer architecture

A recommended pattern for structuring error handling across your application:

  1. Edges — wrap unsafe code with Result.try / Result.tryPromise. External libraries throw; your code returns Results.
  2. Middle — pure business logic. Functions accept and return Result. Early-return on !result.ok.
  3. Route handlers — read the final Result, return an HTTP response or throw a framework-specific error.
// Edge: wrap the database call
async function findUser(id: string): Promise<Result<User, DbError>> {
  return Result.tryPromise({
    try: () => db.query("SELECT * FROM users WHERE id = $1", [id]),
    catch: (e) => new DbError({ cause: e }),
  })
}

// Middle: pure logic, no throws
async function getDisplayName(id: string): Promise<Result<string, DbError | NotFoundError>> {
  const result = await findUser(id)
  if (!result.ok) return result
  if (!result.value) return Result.err(new NotFoundError({ id }))
  return Result.ok(`${result.value.first} ${result.value.last}`)
}

// Handler: early return on error, happy path at top level
app.get("/user/:id", async (req, res) => {
  const result = await getDisplayName(req.params.id)
  if (!result.ok) {
    if (result.error._tag === "NotFound") return res.status(404).json(...)
    return res.status(500).json(...)
  }
  return res.json({ name: result.value })  // no else block needed
})

On this page