Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 1 Nov 2026. What's built · Install 0.2.0
jadpo
Guides: all 11

Guides

Handle failures

You end with a failure of your own, refused with the right status, handled where a caller wants to.

On this page

In Jadpo, a way an action can fail is a type of its own, listed in the action's signature, and the compiler makes every caller deal with it. There are no exceptions to forget. This guide refuses to lend a book that is already out, shows the client a 409, and lets one caller turn the failure into an ordinary answer.

You start from an app with a Book entity that has an on_loan field, a LoanView output type, and a fixture that stores a book for tests; Store and change a record and Write tests show how to write those.

Declare the failure

A failure is a type built on Failure, in a domain's failures/ folder:

domains/books/failures/book_on_loan.jadpo:

jadpo
/// The book is out on loan already.type BookOnLoan = Failure {    kind: FailureKind.conflict    message: "That book is out on loan."}
  • kind: says what sort of failure it is, and so the HTTP status a client gets. A failure never names a status itself.
  • message: is fixed text that is safe to show anyone.
  • The code clients see is the name in lower snake case, book_on_loan, and it stays the same so that clients can rely on it.

These are the kinds you declare:

Kind Status Use it when
invalid_value 422 a well-formed value breaks a rule
rejected 422 a business rule refuses the operation
not_found 404 the record doesn't exist
conflict 409 the current state conflicts with the request
not_permitted 403 the caller is known but may not do this
rate_limited 429 the caller must wait before trying again
unavailable 503 something it depends on is briefly unavailable

The runtime raises the rest itself, such as 401 when nobody is signed in, or a 500 for a bug.

Raise it, and say that you can

reject ends the action with a failure. fails lists every failure that can leave it, before the result type:

domains/books/actions/lend_book.jadpo:

jadpo
use books/entities { Book, BookNotFound }use books/types { LoanView }use books/failures { BookOnLoan }/// Lends a book that is on the shelf.action lend_book(book_id: Book.id) fails BookNotFound, BookOnLoan -> LoanView {    var book: Book = attempt Book.get(book_id)    if (book.on_loan) {        reject BookOnLoan    }    var lent: Book = Book.update(book) {        set: {            on_loan: true        }    }    return LoanView {        id: lent.id        on_loan: lent.on_loan    }}

Book.get can fail with BookNotFound, and attempt passes that on, so it is in the list too. The compiler checks the list both ways: a failure that can leave but isn't listed is an error, and so is one that is listed but can never happen.

A route that runs lend_book needs nothing more. Each failure reaches the client as its status, in the same error body every failure has: {"error": {"code": "book_on_loan", "message": "That book is out on loan.", "request_id": "…"}}. The route's OpenAPI document lists both failures under their statuses.

Handle it with match

A caller that wants to answer a failure itself, rather than pass it on, uses match, with one arm for success and one for each failure. The library's self-service kiosk shows a message when the book is out, so being out isn't an error there:

domains/books/types/lend_outcome.jadpo:

jadpo
/// What happened when a kiosk asked to lend a book.type LendOutcome = Enum {    lent    already_out}

domains/books/actions/lend_at_kiosk.jadpo:

jadpo
use books/entities { Book, BookNotFound }use books/types { LendOutcome }use books/failures { BookOnLoan }use books/actions { lend_book }/// Lends a book at the self-service kiosk, which shows a message rather/// than an error when the book is out.action lend_at_kiosk(book_id: Book.id) fails BookNotFound -> LendOutcome {    return match lend_book(book_id) {        success -> LendOutcome.lent        BookOnLoan -> LendOutcome.already_out        BookNotFound -> propagate    }}

Every failure needs an arm, and there is no catch-all, so a failure added to lend_book later stops this file compiling until someone decides what the kiosk does with it. An arm can recover with a value, as BookOnLoan does here; turn it into another failure, with reject SomeOtherFailure; or pass it on, with propagate.

Serve it and test it

domains/books/routes/books.jadpo:

jadpo
use books/entities { Book }use books/types { LoanView }use books/actions { lend_book }/// The library's books.routes Books {    prefix: /books    /// Lends a book.    POST /{book_id: Book.id}/loan {        authentication: RouteAuthentication.none        output: LoanView        run: lend_book(book_id)    }}

domains/books/actions/lend_book.test.jadpo:

jadpo
use books/entities { Book }use books/types { LendOutcome, LoanView }use books/failures { BookOnLoan }use books/actions { lend_at_kiosk }use books/fixtures { shelved_book }test "a book out on loan can't be lent again" {    var book: Book = shelved_book()    var lent: LoanView = attempt lend_book(book.id)    assert lent.on_loan    assert lend_book(book.id) fails BookOnLoan}test "the kiosk says the book is out" {    var book: Book = shelved_book()    var first: LendOutcome = attempt lend_at_kiosk(book.id)    var second: LendOutcome = attempt lend_at_kiosk(book.id)    assert first == LendOutcome.lent    assert second == LendOutcome.already_out}test "lending a book on loan answers 409" {    var book: Book = shelved_book()    var first: Response = request {        method: HttpMethod.post        path: "/books/{book_id}/loan"        params: {            book_id: book.id        }    }    assert first.status == 200    var second: Response = request {        method: HttpMethod.post        path: "/books/{book_id}/loan"        params: {            book_id: book.id        }    }    assert second.status == 409    assert second.code == "book_on_loan"}

assert ... fails BookOnLoan passes when the call fails with exactly that failure. Run jadpo api update for the API record, then the tests:

shell
jadpo test
output
tested: 3 passed, 2 action tests, 1 route test

Other places a failure comes from

The compiler knows what else can go wrong, and makes you name a failure for each:

  • a query for one record names one in missing: (Query records);
  • a write that can break a unique rule names one per rule in conflict: (Store and change a record);
  • a value worked out at run time that may break its type's rules, such as attempt LoanDays(days) { max: LoanTooLong }.

A failure can also carry fields: client: ones the caller sees under details, and internal: ones that only reach your logs.

Read more

Declaring a failure, Kinds, What the client sees, Saying what can fail, Passing a failure on, Turning one failure into another and Handling a failure in match.

Markdown for agents: /guides/guide-handle-failures.md.Built from Jadpo main at e00e61f. Jadpo's CI compiles this guide's code and runs its tests.