Katman

Typed Errors

Define error maps, use fail() for type-safe errors, and handle them on the client.

Most APIs handle errors with generic try/catch and status codes. Katman takes a different approach: you declare the errors a procedure can produce upfront, and TypeScript enforces them on both sides.

Error maps

An error map is an object you pass to a procedure's errors field. Each key is an error code, and the value is either a status number or a full config:

import {  } from "zod"

const  = k.mutation({
  : .({ : .() }),
  : {
    : 404,                            // just a status code
    : {                               // status + typed data
      : 403,
      : "You don't own this resource",
      : .({ : .() }),
    },
  },
  : ({ , ,  }) => {
    const  = .db.users.findById(.id)
    if (!) ("NOT_FOUND")
    if (.ownerId !== .user.id) {
      ("FORBIDDEN", { : "Not the owner" })
    }
    return .db.users.delete(.id)
  },
})

What fail() does

When you define an errors map, the fail() function in your resolver becomes fully typed:

  • It only accepts error codes you defined (like "NOT_FOUND" or "FORBIDDEN")
  • If the error has a data schema, fail() requires a second argument matching that schema
  • If the error has no data, the second argument is optional

fail() throws a KatmanError internally. It has a return type of never, so TypeScript knows that code after fail() won't run — no need for return after it.

fail() is only typed when you provide the errors map. Without it, fail() still works but accepts any string code.

Status shorthand vs full config

The two forms:

const  = {
  // Shorthand: just the HTTP status code
  : 404,

  // Full config: status, optional message, optional data schema
  : {
    : 409,
    : "Resource already exists",
    : z.object({ : z.number() }),
  },
}

The shorthand is fine when you just need a code and status. Use the full config when you want to attach structured data to the error.

KatmanError

Under the hood, fail() throws a KatmanError. You can also throw one directly from anywhere — guards, wraps, context factories, or resolvers:

import {  } from "katman"

// Common codes are mapped to HTTP statuses automatically
throw new ("NOT_FOUND")           // 404
throw new ("UNAUTHORIZED")        // 401
throw new ("BAD_REQUEST")         // 400
throw new ("FORBIDDEN")           // 403
throw new ("TOO_MANY_REQUESTS")   // 429
throw new ("INTERNAL_SERVER_ERROR") // 500

You can also create custom errors with any code:

import {  } from "katman"

throw new ("PAYMENT_REQUIRED", {
  : 402,
  : "Upgrade to Pro to use this feature",
  : { : "pro", : 29 },
})

Built-in error codes

These codes are recognized automatically — you don't need to specify a status:

CodeStatus
BAD_REQUEST400
UNAUTHORIZED401
FORBIDDEN403
NOT_FOUND404
METHOD_NOT_ALLOWED405
CONFLICT409
GONE410
UNPROCESSABLE_CONTENT422
TOO_MANY_REQUESTS429
INTERNAL_SERVER_ERROR500
NOT_IMPLEMENTED501
SERVICE_UNAVAILABLE503
GATEWAY_TIMEOUT504

Error JSON format

When an error is sent to the client, it has this shape:

{
  "defined": true,
  "code": "FORBIDDEN",
  "status": 403,
  "message": "You don't own this resource",
  "data": { "reason": "Not the owner" }
}

The defined field tells you whether this error came from an error map (true) or was thrown manually / unexpectedly (false). This is useful on the client for distinguishing expected business errors from unexpected crashes.

Client-side error handling

Errors thrown on the server are reconstructed as KatmanError instances on the client. You have two ways to handle them:

import {  } from "katman"

try {
await client.users.delete({ : 1 })
} catch () {
if ( instanceof ) {
  .(.)    // "FORBIDDEN"
  .(.)  // 403
  .(.) // "You don't own this resource"
  .(.)    // { reason: "Not the owner" }

  if (.) {
    // This error was declared in the error map — expected business logic
  } else {
    // This was an unexpected error
  }
}
}

The safe() helper wraps a promise and returns a result object instead of throwing:

import {  } from "katman/client"

const  = await (client.users.delete({ : 1 }))

if (.) {
.(..code) // "FORBIDDEN"
.(.)       // undefined
} else {
.(.)       // the deleted user
.(.)      // null
}

The safe() function returns an object with:

  • error — the error if one occurred, or null
  • data — the result if successful, or undefined
  • isErrortrue if the call failed
  • isSuccesstrue if the call succeeded

Validation errors

When input validation fails (the data doesn't match your Zod/Valibot schema), Katman automatically returns a 400 BAD_REQUEST with the validation issues:

{
  "code": "BAD_REQUEST",
  "status": 400,
  "message": "Validation failed",
  "data": {
    "issues": [
      { "path": ["email"], "message": "Invalid email" }
    ]
  }
}

You don't need to define this in your error map — it happens automatically whenever input validation fails.

Errors in guards

Guards can throw errors too. A common pattern is an auth guard that throws UNAUTHORIZED:

const  = k.guard(async () => {
  const  = .headers.authorization
  if (!) throw new KatmanError("UNAUTHORIZED")
  const  = await verifyToken()
  if (!) throw new KatmanError("UNAUTHORIZED", { : "Invalid token" })
  return {  }
})

When a guard throws, the procedure never runs. The error goes straight to the client.

What's next?

  • Middleware — learn about guards and wraps in detail
  • Client — set up the client that receives these typed errors
  • Procedures — the full config form where error maps are defined

On this page