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
dataschema,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") // 500You 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:
| Code | Status |
|---|---|
BAD_REQUEST | 400 |
UNAUTHORIZED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
METHOD_NOT_ALLOWED | 405 |
CONFLICT | 409 |
GONE | 410 |
UNPROCESSABLE_CONTENT | 422 |
TOO_MANY_REQUESTS | 429 |
INTERNAL_SERVER_ERROR | 500 |
NOT_IMPLEMENTED | 501 |
SERVICE_UNAVAILABLE | 503 |
GATEWAY_TIMEOUT | 504 |
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, ornulldata— the result if successful, orundefinedisError—trueif the call failedisSuccess—trueif 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