Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference

Primer

On this page

Read this once before you write a Jadpo app. It shows how a project is laid out, the syntax, and the parts nearly every app uses, and it ends with a small app you can run on your own machine. Every example on this page is compiled and tested, so you can copy it.

What Jadpo is

Jadpo is a language for web backends: the server side of an app, which stores its data and answers its HTTP requests. Coding agents write most of it, and people review it.

One compiler checks the whole app at once. That covers the types, the stored data, the HTTP routes, who can sign in, what each person may see and change, the settings, calls to outside services, background work and the tests. You write each rule once, in the place the language gives it, and the compiler applies it everywhere.

Jadpo has no front end: a web page or a phone app calls it over HTTP. It is at version 0.x, so a new minor release can still break your app.

Some habits from TypeScript and other languages don't carry over. The compiler catches most of them and shows you the Jadpo way:

  • No null. A type ending in ? may hold none, and match reads it.
  • No exceptions. A function lists what can go wrong with fails, raises a failure with reject and passes one on with attempt.
  • No async, classes, methods, your own generics, while, break or floating point. for is the only loop, and Decimal is exact.
  • No hand-written SQL. You declare queries and writes, and the compiler turns them into SQL, with each person's permissions added.
  • No truthiness, no semicolons, and no !, && or ||: write not, and and or.

The commands

Everything goes through one command, jadpo. Run it anywhere inside your project: it finds the project by looking upwards for app.jadpo.

Command What it does
jadpo new <name> Starts a project in a new folder, with a starter route and its test
jadpo check Checks the whole project. It's quick, because it builds nothing
jadpo fmt Formats your files, and adds, removes and sorts their use lines
jadpo test Runs the tests. Tests that use the database need PostgreSQL installed
jadpo migrate new <purpose> Writes the next database migration from your entities
jadpo api update Updates the committed record of your API after you change a route
jadpo dev Runs the app on http://127.0.0.1:3000 and reloads it when you save
jadpo help <command> Explains a command

Reading an error

Jadpo has no warnings. Everything it reports is an error, and the app doesn't run until you fix it. Here a function returns a number its type doesn't allow:

example
/// How many tools a member lends at once.type ToolCount = Int {    min: 0    max: 20}/// The most a member may lend.function limit() -> ToolCount {    return 25}
text
error: `25` isn't a valid `ToolCount` --> main.jadpo:9:12  |9 |     return 25  |            ^^ breaks `max: 20`  reason: `ToolCount` declares `max: 20`, and the value is above 20. A literal is checked against every rule while compiling.  next: Use a value that `ToolCount` allows (agent)  rule: type.invalid_literal  help: https://jadpo.dev/docs/errors/type.invalid_literalcheck failed: 1 error, 1 file

Every error has the same parts:

  • reason: says why the code is wrong.
  • next: says what to do. (human) at the end of an error's next: line tells your coding agent to stop and ask you which fix you want, because the choice is yours to make (who can see data, what a setting holds). (agent) means your agent can make the fix itself. The marker is guidance that agents follow: nothing stops an agent that ignores it yet.
  • rule: is the error's name. Its page, on the site at https://jadpo.dev/docs/errors/<rule>, explains it; read it only when the reason and the next step aren't enough.

jadpo check --json gives the same parts as JSON, for tools. A few rules are judgement calls: when the code is right as written, a line decline <rule>: "<why>" above the declaration answers that one finding.

How a project is laid out

A project is a folder with app.jadpo at its top. Your code lives in domains: one folder for each part of the business, such as lending or billing, inside domains/. In a domain, each kind of declaration has its own folder, such as types/, entities/ or actions/. Each file holds one declaration and is named after it.

This primer builds a small app for neighbours on one street to lend each other tools. These are all the files it ends with:

text
app.jadpo               the application and who can sign inauthentication.jadpo    how people sign inconfig.jadpo            settings read from the environmentapi/main.jadpo_record   generated by jadpo api update; committeddatabase/migrations/    generated by jadpo migrate new; committeddomains/  lending/    types/              tool_name.jadpo  tool_status.jadpo  tool_view.jadpo  loan_length.jadpo    entities/           member.jadpo  tool.jadpo    failures/           email_taken.jadpo  tool_already_lent.jadpo    queries/            available_tools.jadpo  tool_by_id.jadpo    actions/            join.jadpo  add_tool.jadpo  lend_tool.jadpo  lend_tool.test.jadpo    routes/             tools.jadpo  tools.test.jadpo

A file starts with use lines naming where each name it uses comes from, the domain and then the folder: use lending/types { ToolName } reads domains/lending/types/tool_name.jadpo. Built-in types and the standard library (list., money., temporal.) are never imported. jadpo fmt adds missing imports and removes unused ones, so run it after writing a file. A type that a second file uses must move to its own file in types/; the compiler says so (layout.promotion_required) and jadpo fmt --layout makes the move. A file outside any project is checked on its own, with no layout and no use lines.

Syntax essentials

example
/// A number of things, never below zero.type Count = Int {    min: 0}type Label = Text {    max_length: 40}type Size = Enum {    small    large}type Parcel = Object {    size: Size    label: Label?}/// How many of the parcels are large.function large_parcels(parcels: List<Parcel>) -> Count {    var mut total: Count = 0    for parcel in parcels {        if (parcel.size == Size.large and parcel.label != none) {            total = Count(total + 1)        }    }    return total}/// What goes on the parcel's sticker.function sticker(parcel: Parcel) -> Label {    return match parcel.label {        some(label) -> label        none -> match parcel.size {            Size.small -> "Small parcel"            Size.large -> "Large parcel"        }    }}
  • Every type is declared type Name = Base { ... }. A block holds one item per line, with no commas; only a use line's names take commas. /// documents the next declaration; // is a plain comment.
  • Types are UpperCamelCase; everything else is lower_snake_case.
  • Every variable states its type. var is like const and never changes; var mut can be given a new value. Values never change: build a new object instead.
  • Conditions are in brackets and must be Bool. match must cover every case and can give a value.
  • Arithmetic gives the plain representation (Int), so Count(total + 1) builds a Count again. Building a value checks its rules; it is never a cast.
  • Order doesn't matter: a declaration can use one written later.

Types

Representation is not meaning. A tool's name and a person's email are both text, but they are different types, and the compiler never converts between them silently. The built-in representations are Bool, Int, Decimal, Text, Bytes and Uuid. About 250 standard types cover the rest (Email, Money, Instant, Duration, Url, PostalAddress and more), so look in the standard library before inventing one.

example
/// What a tool is called, as its owner wrote it.type ToolName = Text {    min_length: 1    max_length: 80}
example
/// Whether a tool can be borrowed.type ToolStatus = Enum {    available    lent}

A function, action or query takes and returns named types, never a bare Text or Int. The rules a type can carry are min, max, min_length, max_length, pattern and format. A literal that breaks them is a compile error. Building a computed value that may break them needs attempt and a block mapping each rule that can break to a failure (max_length: NameTooLong, or invalid: for all), unless the compiler can prove the value follows them.

The bases for objects are Object (inside the app), Input (what a request sends; its fields may be optional: true or have a default:) and Output (what a response sends). Enum variants can carry fields, which makes an enum a tagged union.

Entities and migrations

An entity is a stored record: one table. Each has an id: Uuid. A reference is the other entity's field type, Member.id, with what happens on delete. policy: says who may do what (see "Signing in and permissions").

example
/// Someone in the street who shares tools.entity Member {    id: Uuid    email: Email {        unique: true    }}
example
use lending/entities { Member }use lending/types { ToolName, ToolStatus }/// A tool a member lends to the others.entity Tool {    id: Uuid    owner_id: Member.id {        on_delete: OnDelete.cascade    }    name: ToolName    status: ToolStatus    due_back: Instant?    policy: {        create: [owner_id]        read: [Access.authenticated]        update: [owner_id]    }}

Each field is a type of its own: Tool.id and Member.id are both UUIDs, but one can't be passed for the other. Every entity gets Tool.get(id), Tool.create, Tool.update and Tool.delete, and a not-found failure, ToolNotFound, imported from lending/entities.

After changing an entity, run jadpo migrate new <purpose>. It writes a dated folder in database/migrations/ with a snapshot and the SQL. Commit it. jadpo check fails while the migrations and the entities disagree. When a change needs a decision, such as a value for a new field, the folder gets a changes.jadpo to fill in, and jadpo migrate new run again finishes it.

Failures

A failure is an expected outcome, declared as a type. Its kind decides the HTTP status; code never picks a status. Its public code is its name in snake case, tool_already_lent. client: fields are sent to the caller; internal: fields never leave the server.

example
use lending/entities { Tool }/// The tool is out on loan, so it can't be lent again.type ToolAlreadyLent = Failure {    kind: FailureKind.conflict    message: "That tool is already lent out."    client: {        tool_id: Tool.id    }}
example
/// Another member already uses this address.type EmailTaken = Failure {    kind: FailureKind.conflict    message: "That email address is already a member's."}

The kinds an app declares are invalid_value (422), not_found (404), not_permitted (403), conflict (409), rejected (422), rate_limited (429) and unavailable (503). The runtime raises the rest itself, such as unauthenticated (401) and internal_fault (500), and they are never listed in fails. Every failure reaches a client in one JSON envelope: { "error": { "code", "message", "request_id", "details" } }.

Queries

Every read is a named query in queries/. The result type says how many records come back: one (needs missing:), zero or one (T?), a list (needs order: and limit:) or a Page<T>. A query reads only the fields its result type needs.

example
use lending/entities { Tool }/// A tool as members see it.type ToolView = Output {    pick: [Tool.id, Tool.name, Tool.status, Tool.due_back]}
example
use lending/entities { Tool }use lending/types { ToolStatus, ToolView }/// Tools nobody has borrowed, by name.query available_tools() -> List<ToolView> {    from: Tool    where: Tool.status == ToolStatus.available    order: [Tool.name asc]    limit: 100}
example
use lending/entities { Tool, ToolNotFound }use lending/types { ToolView }/// One tool.query tool_by_id(tool_id: Tool.id) fails ToolNotFound -> ToolView {    from: Tool    where: Tool.id == tool_id    missing: ToolNotFound}

Actions and writes

A function is pure. A query only reads. An action may read, write, call other actions and call outside services, and the whole action commits or rolls back together. Only actions read the time, clock.now.

A write that can break a unique rule maps it in conflict::

example
use lending/entities { Member }use lending/failures { EmailTaken }/// Makes someone a member.action join(email: Member.email) fails EmailTaken -> Member {    return attempt Member.create {        set: {            email: email        }        conflict: {            Member.email: EmailTaken        }    }}

An action that needs the caller takes it as a parameter of type Principal.user, the signed-in user (see "Signing in and permissions"). An Input used by one action lives in the action's file:

example
use lending/entities { Tool }use lending/types { ToolName, ToolStatus, ToolView }/// What adding a tool takes.type AddTool = Input {    name: ToolName}/// Adds a tool the caller owns.action add_tool(input: AddTool, caller: Principal.user) -> ToolView {    var tool: Tool = Tool.create {        set: {            owner_id: caller.id            name: input.name            status: ToolStatus.available            due_back: none        }    }    return ToolView {        id: tool.id        name: tool.name        status: tool.status        due_back: tool.due_back    }}

fails lists exactly the failures that can leave a callable. attempt starts a statement and passes a call's failures on; reject raises one, with every field it declares:

example
use lending/entities { Tool, ToolNotFound }use lending/types { ToolStatus, ToolView }use lending/failures { ToolAlreadyLent }use lending/queries { tool_by_id }/// Records that the owner has lent a tool out.action lend_tool(tool_id: Tool.id) fails ToolNotFound, ToolAlreadyLent -> ToolView {    var tool: Tool = attempt Tool.get(tool_id)    if (tool.status == ToolStatus.lent) {        reject ToolAlreadyLent {            tool_id: tool.id        }    }    var lent: Tool = Tool.update(tool) {        set: {            status: ToolStatus.lent            due_back: clock.now + config.loan_length        }    }    return attempt tool_by_id(lent.id)}

To handle a failure instead of passing it on, match on the call: success(value) -> ..., and one arm per failure that recovers, rejects another, or propagates it. Calls are bare in their own domain; another domain's public actions are called through it, billing.charge(...). No outside call may sit inside a for.

Routes

A route group is a thin door: method, path, input, output and the one action or query it runs. GET and DELETE read their input from the query string, the other methods from the JSON body. output: must be exactly what the route runs returns. A failure's kind sets the status.

example
use lending/entities { Tool }use lending/types { ToolView }use lending/actions { AddTool, add_tool, lend_tool }use lending/queries { available_tools, tool_by_id }/// The street's tools.routes Tools {    prefix: /tools    /// Tools free to borrow.    GET / {        output: List<ToolView>        run: available_tools()    }    /// Adds a tool the caller owns.    POST / {        input: AddTool        output: ToolView        status: SuccessStatus.created        run: add_tool(input, current_principal.user)    }    /// One tool.    GET /{tool_id: Tool.id} {        output: ToolView        run: tool_by_id(tool_id)    }    /// Lends a tool out.    POST /{tool_id: Tool.id}/lend {        output: ToolView        run: lend_tool(tool_id)    }}

The group's name matches its file and its prefix. jadpo check writes an OpenAPI document per API to build/openapi/. Run jadpo api update after changing a route and commit api/main.jadpo_record: it is the wire contract, and jadpo check fails while it disagrees with the routes.

Signing in and permissions

app.jadpo holds the application, whose jadpo: is the Jadpo release line the app is written for, and the principal: who a signed-in caller can be. A user is found by a unique field of an entity. authentication.jadpo lists the ways to sign in.

example
use lending/entities { Member }application {    jadpo: "0.1"}principal {    user: {        subject: Member.email    }}
example
authentication {    browser_session: {        cookie: "tool_share_session"    }}

This keeps people signed in with a cookie, but gives them no way to sign in yet. sign_in: { magic_link: { link: "/sign-in" } } emails a link and a six-digit code that sign the person in, and needs the session's origin: and an email connection; the Learn path builds it step by step. There are no passwords. Passkeys go beside the magic link, and api_token, service_key and external_token sign in other programs. Code never sees a cookie or a token. Every route needs a signed-in caller unless it says authentication: RouteAuthentication.none; without one it answers 401. A route reads the caller as current_principal and passes current_principal.user to its action.

Policy is always on. Each entity's policy: lists who may create, read, update and delete it, and anything not listed is refused. Who is one of: a user field on the record (owner_id), a membership role, Access.authenticated, Access.public or Access.system for background work. The compiler adds policy to the SQL of every query and write, so no query filters by owner by hand. A record the caller may not read is the code's own not-found failure; a write they may not make is 403 not_permitted. A call with no caller, as tests and fixtures make, runs without policy.

Configuration

Every setting is a field of the one config { } in config.jadpo. Each reads the environment variable named after it in capitals, LOAN_LENGTH, and is checked before the app starts. A field without a default: is required; test: gives its value in tests. Code reads config.loan_length anywhere. Secrets are typed (PostgresUrl, SigningKey) and set locally with jadpo config set.

example
use lending/types { LoanLength }config {    /// How long a tool is lent for.    loan_length: LoanLength {        default: 14d    }}
example
/// How long one loan lasts.type LoanLength = Duration {    min: 1d    max: 60d}

A fixed value that is the same in every deployment is a constant, not a setting.

Tests

Tests sit beside their subject as <name>.test.jadpo and see everything it declares. A test is a sentence and a body with assert. The kind comes from what it calls: a function makes a pure test, an action an action test, and request { } a route test. Each test gets its own database, built from the migrations, and its own clock. Principal.user(record) makes a caller, in tests only.

example
use lending/entities { Member }use lending/types { ToolStatus, ToolView }use lending/failures { ToolAlreadyLent }use lending/actions { AddTool, add_tool, join }test "a lent tool is due back in two weeks" {    var ann: Member = attempt join("ann@example.test")    var drill: ToolView = add_tool(        AddTool {            name: "Drill"        },        Principal.user(ann)    )    var lent: ToolView = attempt lend_tool(drill.id)    assert lent.status == ToolStatus.lent    assert lent.due_back == clock.now + 14d}test "a tool can't be lent twice" {    var ann: Member = attempt join("ann@example.test")    var ladder: ToolView = add_tool(        AddTool {            name: "Ladder"        },        Principal.user(ann)    )    attempt lend_tool(ladder.id)    assert lend_tool(ladder.id) fails ToolAlreadyLent}

A route test goes through the real HTTP boundary: input decoding, sign-in, policy and statuses.

example
use lending/entities { Member }use lending/types { ToolView }use lending/actions { AddTool, add_tool, join }test "a signed-in member adds a tool" {    var ann: Member = attempt join("ann@example.test")    var response: Response = request {        method: HttpMethod.post        path: "/tools"        as: Principal.user(ann)        input: AddTool {            name: "Hedge trimmer"        }    }    assert response.status == 201}test "only a tool's owner lends it out" {    var ann: Member = attempt join("ann@example.test")    var bob: Member = attempt join("bob@example.test")    var saw: ToolView = add_tool(        AddTool {            name: "Saw"        },        Principal.user(ann)    )    var response: Response = request {        method: HttpMethod.post        path: "/tools/{tool_id}/lend"        params: {            tool_id: saw.id        }        as: Principal.user(bob)    }    assert response.status == 403    assert response.code == "not_permitted"}

Shared test data goes in a fixture in fixtures/. A test's fakes: scripts what a provider package answers.

Run it

Copy each file above to its path in an empty folder, all but the API record, which a command writes. Then make the migration and the record, check, and run the tests (they need PostgreSQL installed):

sh
jadpo migrate new initialjadpo api updatejadpo checkjadpo test
text
checked: 19 files in 0.1 stested: 4 passed, 2 action tests, 2 route tests in 1.0 s

jadpo dev serves it, with a throwaway database of its own. Every route needs someone signed in, so until you add a way to sign in, a request is refused:

sh
curl http://localhost:3000/tools
text
{"error":{"code":"authentication_required","message":"Sign in first.","request_id":"req_9342ee18ff3ce32e"}}

Finding more

Read only what a task needs.

  • The Learn path builds a todo app with sign-in a step at a time, starting from Learn Jadpo.
  • Topics. Every section of the reference is a topic with a stable ID, such as named-queries, the-operations-every-entity-gets, grants, magic-links, migrations and kinds-of-test. The IDs never change.
  • The index. The docs are published with index.json, one entry per page with its id, kind, title, a one-line summary and its tokens. Read it, pick pages by title and summary, and fetch only those.
  • Outside services. Packages, providers, connections, drivers and capabilities are explained together in how-integrations-fit-together.
  • Errors. Each rule has a page named after its ID, such as failure.kind_required.
  • Commands. jadpo help <command>, or jadpo <command> --help.
  • Whole apps. The teaching projects in the repository's examples/ folder build up from a first project to apps with tenants, roles, providers, background work and AI. Each passes its tests.
  • The rules. Where anything disagrees with the reference, the reference wins.
  • A bug in Jadpo. If the compiler crashes, rejects code these docs say is valid, accepts code they say it refuses, or a page is wrong, read the error's page first. Then cut the problem down to the smallest project that shows it and ask your user before filing anything: an issue is published under their name. Give them a drafted report, or a link to the issue form with title= filled in. Put no secrets, settings values or other app code in it. After a crash, ~/.cache/jadpo/crashes/ holds a file for the report; your user reads it before it is attached. Never post on your own. A suspected security hole goes to the private route in the repository's SECURITY.md (GitHub's "Report a vulnerability" under Security), never to a public issue.

Not built yet

A feature that isn't built is the error toolchain.not_built_yet where you use it. Not built yet, among others: seeds and jadpo seed; jadpo rename; entities and caches in Redis; failure logging; sign-in with OAuth providers, and OAuth grants; token scopes; most of personal data, and of the debugging and review tools; data builders and assert emitted in tests; flags, audit, search and documents; and installers, so jadpo is built from its source with cargo build --release -p jadpo-cli.

Markdown for agents: /docs/primer.md.Built from Jadpo main at a707efb.