Skip to content
Back

TypeScript Error Handling

TypeScript

throws vanish from the type; a Result puts the failure in the signature

A throw is not in the return type. Callers compile. The failure is still unhandled. A Result makes the failure a value: success or error, one of the two, in the signature.

This note follows Kyle's walkthrough. Runtime instanceof on an Error subclass is TypeScript Classes and Runtime Identity. never is TypeScript Infer, Extends, and Ternaries. useUnknownInCatchVariables is TypeScript Beyond Strict. This note is the return-value half.



1. Sprinkled handlers

A Next.js createProject action inlines auth, permission, Zod, and the database. Unauthenticated redirects to login. Unauthorized redirects to a 403 page. Invalid input returns a message. The insert sits in a try/catch that returns "unexpected error".


An API route for the same create needs JSON and status codes, not redirect. Copy the checks. Swap each redirect for NextResponse.json. Two callers, two error policies, one domain.


Failure: handling the same unauthenticated / unauthorized / invalid-data cases in the action and the route by pasting. Change one. The other lies.


2. Service that throws

Pull the checks into createProjectService. Throw UnauthenticatedError and UnauthorizedErrorError subclasses. Return the project on success. The action and the route both call one function. Each maps failures the way its layer should: redirect vs JSON.


ts
class UnauthenticatedError extends Error {
  constructor() {
    super("unauthenticated")
    this.name = "UnauthenticatedError"
  }
}

async function createProjectService(input: unknown): Promise<Project> {
  const user = await getUser()
  if (user === null) throw new UnauthenticatedError()
  // permission, parse, insert — throw or return
  return project
}

try {
  const project = await createProjectService(input)
  revalidatePath("/projects")
  redirect(`/projects/${project.id}`)
} catch (error) {
  if (error instanceof UnauthenticatedError) redirect("/login")
  if (error instanceof UnauthorizedError) redirect("/unauthorized")
  if (error instanceof Error) return { message: error.message }
  return { message: "unexpected error" }
}

catch is unknownTypeScript Beyond Strict. instanceof is the test that survives a throw — TypeScript Classes. Next redirect() throws. Put it in the try and the catch eats the redirect.


Failure: treating custom Error classes as a typed contract. They are a runtime test after the stack has unwound.


3. The type hole

Delete UnauthenticatedError from the service. Both callers still instanceof UnauthenticatedError. Dead code. Add RandomError. Both callers still compile. Nothing in Promise<Project> mentioned either error.


Zod safeParse in the same file is already a Result: { success: true, data } or { success: false, error }. The insert path throws. Two channels. Neither is exhaustive.


Failure: throwing from save after parse already returned a Result. The signature still says Project.


4. Result tuple

Return the failure. Error first, so the caller names it before the data. One of the two slots is always null.


ts
type Result<E, S> = [E, null] | [null, S]

function ok<S>(data: S): Result<never, S> {
  return [null, data]
}

function error<E extends { reason: string }>(err: E): Result<E, never> {
  return [err, null]
}

never on the unused side is the empty set — TypeScript Infer, Extends, and Ternaries. ok(project) cannot carry an error. error({ reason }) cannot carry a project. The service's return type is the union of every ok and error it hits.


ts
async function createProjectService(
  input: unknown
): Promise<
  Result<
    | { reason: "unauthenticated" }
    | { reason: "unauthorized" }
    | { reason: "invalid_data"; details: ZodError }
    | { reason: "unexpected" },
    Project
  >
> {
  const user = await getUser()
  if (user === null) return error({ reason: "unauthenticated" as const })
  if (!can(user, "create", "project")) {
    return error({ reason: "unauthorized" as const })
  }

  const parsed = schema.safeParse(input)
  if (!parsed.success) {
    return error({ reason: "invalid_data" as const, details: parsed.error })
  }

  try {
    const project = await insertProject(parsed.data)
    return ok(project)
  } catch {
    return error({ reason: "unexpected" as const })
  }
}

as const on each reason is what makes the field a literal, not string. The action no longer try/catches the service. It destructures.


ts
const [err, project] = await createProjectService(input)

if (err === null) {
  revalidatePath("/projects")
  redirect(`/projects/${project.id}`)
  return
}

switch (err.reason) {
  case "unauthenticated":
    redirect("/login")
    break
  case "unauthorized":
    redirect("/unauthorized")
    break
  case "invalid_data":
    return { message: "invalid data", details: err.details }
  case "unexpected":
    return { message: "unexpected error" }
}

After err === null, project is Project. After the if, err.reason is the literal union. Same function from the route: map those reasons to status codes instead of redirect.


Failure: Result<S> with error?: string. Both branches readable. No exhaustiveness.


5. Exhaustive reasons

A default that satisfies never is the tripwire. Add a reason in the service, forget the case, the caller does not compile. Remove a reason, the leftover case is the error.


ts
switch (err.reason) {
  case "unauthenticated":
    redirect("/login")
    break
  case "unauthorized":
    redirect("/unauthorized")
    break
  case "invalid_data":
    return { message: "invalid data", details: err.details }
  case "unexpected":
    return { message: "unexpected error" }
  default: {
    err.reason satisfies never
    throw new Error(`unhandled: ${err.reason}`)
  }
}

invalid_data is the only variant with details. Narrow on reason and the extra field appears. A shared optional details on every variant would sit on unauthenticated too.


Failure: reason: string. The switch is a chain of guesses. satisfies never has nothing to reject.


6. neverthrow

neverthrow is the same Result, as an object, with combinators. ok / err replace the tuple helpers. Async is okAsync / errAsync. .match takes the success callback and the error callback. .andThen sequences another fallible step and unions the error types. ResultAsync<Project, E> is success first — the opposite of the tuple's Result<E, S>.


ts
import { ok, err, okAsync, ResultAsync } from "neverthrow"

function createProjectService(
  input: unknown
): ResultAsync<
  Project,
  | { reason: "unauthenticated" }
  | { reason: "unauthorized" }
  | { reason: "invalid_data"; details: ZodError }
  | { reason: "unexpected" }
> {
  // err({ reason: "unauthenticated" as const })
  // okAsync(project)
}

const result = await createProjectService(input)

return result
  .andThen((project) => publish(project))
  .match(
    (project) => {
      revalidatePath("/projects")
      redirect(`/projects/${project.id}`)
    },
    (error) => {
      switch (error.reason) {
        case "unauthenticated":
          redirect("/login")
          break
        // …
        default: {
          error.reason satisfies never
          throw new Error(`unhandled: ${error.reason}`)
        }
      }
    }
  )

as const on reasons is still required. neverthrow will not infer the literals for you. Effect can do this job and a lot of other jobs. It is too big for error handling alone. neverthrow's ESLint plugin flags a Result that is never matched or unwrapped. The rule is the point. The config is not.


Failure: andThen then throwing inside the callback. Two channels again. The error union stops growing.


Takeaway

A throw is invisible to the type. A Result is the failure in the signature.

  1. Does the caller need a different policy than the service? Return a Result. Map it at the edge: redirect vs JSON vs a toast.
  2. Is the reason a literal union? as const, then switch with satisfies never. reason: string is an untyped throw with extra steps.
  3. Are you sequencing fallible steps? Homemade ok / error is the type. neverthrow is .andThen so the error union concatenates. Effect is a platform.

The Error subclass still earns a keep when something is actually thrown — instanceof after catch. That object model is TypeScript Classes. This note is when you do not throw.


Recap Q&A