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 holdnone, andmatchreads it. - No exceptions. A function lists what can go wrong with
fails, raises a failure withrejectand passes one on withattempt. - No
async, classes, methods, your own generics,while,breakor floating point.foris the only loop, andDecimalis 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||: writenot,andandor.
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:
/// 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}
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'snext: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 athttps://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:
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
/// 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 auseline's names take commas.///documents the next declaration;//is a plain comment. - Types are
UpperCamelCase; everything else islower_snake_case. - Every variable states its type.
varis likeconstand never changes;var mutcan be given a new value. Values never change: build a new object instead. - Conditions are in brackets and must be
Bool.matchmust cover every case and can give a value. - Arithmetic gives the plain representation (
Int), soCount(total + 1)builds aCountagain. 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.
/// What a tool is called, as its owner wrote it.type ToolName = Text { min_length: 1 max_length: 80}
/// 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").
/// Someone in the street who shares tools.entity Member { id: Uuid email: Email { unique: true }}
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.
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 }}
/// 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.
use lending/entities { Tool }/// A tool as members see it.type ToolView = Output { pick: [Tool.id, Tool.name, Tool.status, Tool.due_back]}
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}
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::
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:
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:
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.
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.
use lending/entities { Member }application { jadpo: "0.1"}principal { user: { subject: Member.email }}
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.
use lending/types { LoanLength }config { /// How long a tool is lent for. loan_length: LoanLength { default: 14d }}
/// 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.
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.
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):
jadpo migrate new initialjadpo api updatejadpo checkjadpo test
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:
curl http://localhost:3000/tools{"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,migrationsandkinds-of-test. The IDs never change. - The index. The docs are published with
index.json, one entry per page with itsid,kind,title, a one-linesummaryand itstokens. 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>, orjadpo <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'sSECURITY.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.