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. Silgi 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 .$errors() on the builder. Each key is an error code, and the value is either a status number or a full config:
import { } from 'zod'
const = k
.$input(.({ : .() }))
.$errors({
: 404, // just a status code
: {
// status + typed data
: 403,
: "You don't own this resource",
: .({ : .() }),
},
})
.$resolve(({ , , }) => {
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 errors via .$errors(), 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 SilgiError 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 use .$errors(). 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.
SilgiError
Under the hood, fail() throws a SilgiError. You can also throw one directly from anywhere — guards, wraps, context factories, or resolvers:
import { } from 'silgi'
// 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 'silgi'
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 SilgiError instances on the client. You have two ways to handle them:
import { } from "silgi"
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 "silgi/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), Silgi 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 = s.guard(async () => {
const = .headers.authorization
if (!) throw new SilgiError('UNAUTHORIZED')
const = await verifyToken()
if (!) throw new SilgiError('UNAUTHORIZED', { : 'Invalid token' })
return { }
})When a guard throws, the procedure never runs. The error goes straight to the client.
Typed guard errors
Guards can declare their own error maps. When a procedure uses that guard, the errors automatically merge — both in TypeScript inference and in the OpenAPI spec:
const = s.guard({
: { : 401 },
: async () => {
const = .headers.authorization
if (!) throw new SilgiError('UNAUTHORIZED')
return { : await verifyToken() }
},
})
const = s.guard({
: { : 429 },
: () => {
if (tooManyRequests()) throw new SilgiError('RATE_LIMITED')
},
})
const = k
.$use(, )
.$errors({ : 409 })
.$resolve(({ }) => {
// fail() accepts all three: 'UNAUTHORIZED' | 'RATE_LIMITED' | 'CONFLICT'
('CONFLICT')
})This gives you three things:
- Type safety —
fail()in the resolver knows about guard error codes, not just procedure error codes - OpenAPI — the generated spec includes
401,429, and409responses for this endpoint - Runtime —
fail('UNAUTHORIZED')produces the correct 401 status code
The simple s.guard(fn) form still works. The s.guard({ errors, fn }) form is opt-in — use it when you want the guard's errors to be part of the typed contract.
What's next?
- Middleware — learn about guards and wraps in detail
- Client — set up the client that receives these typed errors
- Procedures — the builder where error maps are defined