Claude Code for neverthrow: Type-Safe Error Handling in TypeScript — Claude Skills 360 Blog
Blog / Tooling / Claude Code for neverthrow: Type-Safe Error Handling in TypeScript
Tooling

Claude Code for neverthrow: Type-Safe Error Handling in TypeScript

Published: March 17, 2027
Read time: 7 min read
By: Claude Skills 360

neverthrow brings Result-type error handling to TypeScript — functions return Result<T, E> instead of throwing, making errors visible in the type system. ok(value) wraps a success; err(error) wraps a failure. .map(fn) transforms the success value; .mapErr(fn) transforms the error. .andThen(fn) chains operations that return Result, short-circuiting on err. combine([r1, r2, r3]) collects multiple results into one, failing on the first error. fromThrowable(fn, mapErr) wraps a throwing function; fromPromise(promise, mapErr) wraps a Promise. ResultAsync is the async equivalent with the same chainable API. .match({ ok: fn, err: fn }) exhaustively handles both cases. Claude Code generates neverthrow Result pipelines, domain error unions, fromThrowable adapters for third-party APIs, and async ResultAsync chains that replace try/catch with typed railway-oriented flows.

CLAUDE.md for neverthrow

## neverthrow Stack
- Version: neverthrow >= 8.0
- Result: ok(value) | err(AppError) — function return type Result<T, E>
- Chain: result.map(fn).mapErr(fn).andThen(fnReturningResult)
- Async: ResultAsync.fromPromise(promise, e => toAppError(e))
- Combine: combine([r1, r2]) — Result<[T1, T2], E> — first error wins
- Match: result.match({ ok: v => ..., err: e => ... }) — exhaustive
- Wrap: fromThrowable(throwingFn, mapErr) — adapter for third-party code
- Errors: discriminated union type AppError = { code: "NOT_FOUND" } | { code: "UNAUTHORIZED" }

Domain Error Types

// lib/errors.ts — typed error union
export type AppError =
  | { code: "NOT_FOUND"; resource: string; id: string }
  | { code: "UNAUTHORIZED"; reason: string }
  | { code: "FORBIDDEN"; action: string }
  | { code: "VALIDATION"; field: string; message: string }
  | { code: "CONFLICT"; message: string }
  | { code: "RATE_LIMITED"; retryAfterSeconds: number }
  | { code: "EXTERNAL_API"; service: string; statusCode: number; message: string }
  | { code: "DATABASE"; operation: string; message: string }

// Helper constructors
export const AppErrors = {
  notFound: (resource: string, id: string): AppError =>
    ({ code: "NOT_FOUND", resource, id }),

  unauthorized: (reason: string): AppError =>
    ({ code: "UNAUTHORIZED", reason }),

  forbidden: (action: string): AppError =>
    ({ code: "FORBIDDEN", action }),

  validation: (field: string, message: string): AppError =>
    ({ code: "VALIDATION", field, message }),

  conflict: (message: string): AppError =>
    ({ code: "CONFLICT", message }),

  database: (operation: string, message: string): AppError =>
    ({ code: "DATABASE", operation, message }),

  externalApi: (service: string, statusCode: number, message: string): AppError =>
    ({ code: "EXTERNAL_API", service, statusCode, message }),
} as const

// Map AppError to HTTP status
export function toHttpStatus(error: AppError): number {
  switch (error.code) {
    case "NOT_FOUND": return 404
    case "UNAUTHORIZED": return 401
    case "FORBIDDEN": return 403
    case "VALIDATION": return 422
    case "CONFLICT": return 409
    case "RATE_LIMITED": return 429
    case "EXTERNAL_API": return 502
    case "DATABASE": return 500
  }
}

Service Layer with ResultAsync

// lib/order-service.ts — Result-based service functions
import { Result, ResultAsync, ok, err, combine } from "neverthrow"
import { AppError, AppErrors } from "./errors"

interface Order {
  id: string
  customerId: string
  status: string
  totalCents: number
  items: { productId: string; quantity: number; priceCents: number }[]
}

interface CreateOrderInput {
  customerId: string
  items: { productId: string; quantity: number }[]
}

// Validate input — synchronous Result
function validateOrderInput(input: CreateOrderInput): Result<CreateOrderInput, AppError> {
  if (!input.customerId) {
    return err(AppErrors.validation("customerId", "Customer ID is required"))
  }
  if (input.items.length === 0) {
    return err(AppErrors.validation("items", "Order must have at least one item"))
  }
  for (const item of input.items) {
    if (item.quantity <= 0) {
      return err(AppErrors.validation("items", `Invalid quantity for product ${item.productId}`))
    }
  }
  return ok(input)
}

// Fetch customer — ResultAsync
function fetchCustomer(customerId: string): ResultAsync<{ id: string; email: string }, AppError> {
  return ResultAsync.fromPromise(
    fetch(`/api/customers/${customerId}`).then(async res => {
      if (res.status === 404) throw new Error("NOT_FOUND")
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json() as Promise<{ id: string; email: string }>
    }),
    e => {
      const msg = e instanceof Error ? e.message : "Unknown"
      if (msg === "NOT_FOUND") return AppErrors.notFound("customer", customerId)
      return AppErrors.externalApi("customers-api", 500, msg)
    }
  )
}

// Fetch product prices — ResultAsync
function fetchProductPrices(
  productIds: string[]
): ResultAsync<Map<string, number>, AppError> {
  return ResultAsync.fromPromise(
    fetch("/api/products/prices", {
      method: "POST",
      body: JSON.stringify({ ids: productIds }),
      headers: { "Content-Type": "application/json" },
    }).then(res => res.json() as Promise<{ id: string; priceCents: number }[]>),
    e => AppErrors.externalApi("products-api", 500, String(e))
  ).map(prices => new Map(prices.map(p => [p.id, p.priceCents])))
}

// Persist order — ResultAsync
function persistOrder(order: Omit<Order, "id">): ResultAsync<Order, AppError> {
  return ResultAsync.fromPromise(
    fetch("/api/orders", {
      method: "POST",
      body: JSON.stringify(order),
      headers: { "Content-Type": "application/json" },
    }).then(async res => {
      if (res.status === 409) throw Object.assign(new Error("CONFLICT"), { isConflict: true })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json() as Promise<Order>
    }),
    e => {
      if (e instanceof Error && (e as any).isConflict) return AppErrors.conflict("Duplicate order")
      return AppErrors.database("insert", String(e))
    }
  )
}

// Full create order pipeline — chained ResultAsync
export function createOrder(input: CreateOrderInput): ResultAsync<Order, AppError> {
  return ResultAsync.fromSafePromise(Promise.resolve(validateOrderInput(input)))
    .andThen(validInput =>
      // Fetch customer and prices in parallel
      combine([
        fetchCustomer(validInput.customerId),
        fetchProductPrices(validInput.items.map(i => i.productId)),
      ]).map(([customer, prices]) => ({ validInput, customer, prices }))
    )
    .andThen(({ validInput, prices }) => {
      // Build order with resolved prices
      const items = validInput.items.map(item => ({
        productId: item.productId,
        quantity: item.quantity,
        priceCents: prices.get(item.productId) ?? 0,
      }))

      const missingPrice = items.find(i => i.priceCents === 0)
      if (missingPrice) {
        return err(AppErrors.notFound("product-price", missingPrice.productId))
      }

      return ok({
        customerId: validInput.customerId,
        status: "pending" as const,
        totalCents: items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0),
        items,
      })
    })
    .andThen(persistOrder)
}

API Route Handler

// app/api/orders/route.ts — Result to HTTP response
import { createOrder } from "@/lib/order-service"
import { toHttpStatus } from "@/lib/errors"

export async function POST(req: Request) {
  const body = await req.json()

  const result = await createOrder(body)

  return result.match({
    ok: order => Response.json({ order }, { status: 201 }),
    err: error => Response.json(
      { error: { code: error.code, ...error } },
      { status: toHttpStatus(error) }
    ),
  })
}

fromThrowable Adapters

// lib/adapters.ts — wrap third-party throwing APIs
import { fromThrowable, ResultAsync } from "neverthrow"
import { z } from "zod"
import { AppErrors, AppError } from "./errors"

// Wrap Zod parse — converts ZodError to AppError
const parseWithSchema = <T>(schema: z.ZodSchema<T>) =>
  fromThrowable(
    (data: unknown) => schema.parse(data),
    (e) => {
      if (e instanceof z.ZodError) {
        const first = e.errors[0]!
        return AppErrors.validation(first.path.join("."), first.message)
      }
      return AppErrors.validation("unknown", String(e))
    }
  )

// Usage
const OrderSchema = z.object({
  customerId: z.string(),
  items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })),
})

export const parseOrderInput = parseWithSchema(OrderSchema)

// Wrap JSON.parse
export const safeJsonParse = fromThrowable(
  JSON.parse,
  () => AppErrors.validation("body", "Invalid JSON")
)

// Wrap crypto operations
import { createHmac } from "crypto"
export const computeHmac = fromThrowable(
  (key: string, data: string) => createHmac("sha256", key).update(data).digest("hex"),
  e => AppErrors.database("hmac", String(e))
)

For the Effect-ts alternative that extends Result-type error handling to a full algebraic effects system covering dependency injection, concurrency, streaming, and scheduling alongside typed errors — Effect is significantly more complex but handles entire application architecture, see the functional effects guide. For the true-myth alternative that provides Maybe<T> (Option type) and Result<T, E> with a similar railway-oriented API but with slightly different chaining ergonomics and no Async variant — true-myth is simpler for cases where only synchronous Result handling is needed, see the type-safe error patterns guide. The Claude Skills 360 bundle includes neverthrow skill sets covering Result pipelines, domain error unions, and async chains. Start with the free tier to try error handling generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free