Silgi

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 data schema, 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') // 500

You 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:

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 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, 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), 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 safetyfail() in the resolver knows about guard error codes, not just procedure error codes
  • OpenAPI — the generated spec includes 401, 429, and 409 responses for this endpoint
  • Runtimefail('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

On this page