Type-safe HTTP contracts for ReScript.
Define an HTTP endpoint once as a contract: method, path, and codecs for params, body, response and declared errors. Servers and clients both import that contract. The compiler checks handlers and calls against it, and the codecs validate every request and response at runtime.
resAPI works with Hono (including Cloudflare Workers) and Express 5 on the server, any Fetch environment on the client, and Sury schemas or hand-written codecs.
- Website: https://resapi.dev
- Get started: site/content/getting-started.md, for Hono (including Cloudflare Workers) or Express
Status: 0.2.0 preview. The API may change in 0.x releases. See the changelog for what is supported and the known limitations.
- One definition, both sides. The server handler and the client call get their types from the same contract value, with no generated code.
- Runtime validation at both boundaries. The server decodes params and bodies before your handler runs. The client decodes responses before you see them, with no casts.
- Compile-time checking. Wrong param fields, wrong body types, wrong handler results and undeclared errors are compile errors.
- Typed application errors. An endpoint declares its expected failures (
NotFound,AccessDenied) and their HTTP statuses. They stay separate from network, validation and server errors. - Auth is just middleware. Protect paths with Hono or Express middleware as usual. resAPI's
Rejectand typed keys make the rejection and the signed-in user typed on both ends. - Runtime independence. Contracts and the client depend only on Fetch. Server adapters exist for Hono (runs on Cloudflare Workers with no JS glue) and Express (Node).
npm install @resapi/core @resapi/hono @resapi/sury hono sury sury-ppxA shared contract, importable by both server and client:
// AccountApi.res
open ResApi
@schema type params = {id: string}
@schema type account = {id: string, name: string, balance: float}
@schema type accountError = | @as("not_found") NotFound
let getAccount =
Endpoint.get(
"/accounts/:id",
~params=SuryCodec.make(paramsSchema),
~response=SuryCodec.make(accountSchema),
)->Endpoint.withErrors(SuryCodec.make(accountErrorSchema), ~status=error =>
switch error {
| NotFound => 404
}
)The server, on Hono. A router is a Hono app, so it is already a Cloudflare Worker module:
// App.res
open ResApiHono
let accounts: dict<AccountApi.account> = dict{
"acct_123": {id: "acct_123", name: "Savings", balance: 100.0},
}
let app: Router.t<unit> =
Router.make()
->Router.get("/health", async _ctx => ResApi.Fetch.Response.fromString("ok"))
->Router.register(
AccountApi.getAccount->EndpointHandler.make(async (_ctx, {params}) =>
switch accounts->Dict.get(params.id) {
| Some(account) => Ok(account)
| None => Error(NotFound)
}
),
)
let default = appThe same server on Express 5, for Node. Install @resapi/express and express in place of @resapi/hono and hono:
// ExpressApp.res
open ResApiExpress
let accounts: dict<AccountApi.account> = dict{
"acct_123": {id: "acct_123", name: "Savings", balance: 100.0},
}
let app =
Router.make()
->Router.get("/health", async (_req, res) => res->Res.send("ok"))
->Router.register(
AccountApi.getAccount->EndpointHandler.make(async (_req, {params}) =>
switch accounts->Dict.get(params.id) {
| Some(account) => Ok(account)
| None => Error(NotFound)
}
),
)// Server.res
let server = ExpressApp.app->ResApiExpress.Router.listen(3000, () => Console.log("Listening on :3000"))The client:
// AccountClient.res
open ResApi
let client = Client.make(~baseUrl="https://api.example.com")
let accountName = async id =>
switch await client->Client.call(AccountApi.getAccount, ~params={id: id}) {
| Ok(account) => account.name
| Error(Application({error: NotFound})) => "no such account"
| Error(_) => "request failed"
}Endpoint.get also checks, when the module loads, that the route's :id matches the params codec. A mismatch stops the app from starting.
| Package | Contents | Depends on |
|---|---|---|
@resapi/core |
Contracts (Endpoint), codecs (Codec), route checking, typed Client, Fetch bindings |
ReScript only |
@resapi/hono |
Router, EndpointHandler, Context: runs contracts on Hono |
core; peer hono 4 |
@resapi/express |
Router, EndpointHandler, Req, Res: runs contracts on Express |
core; peer express 5 |
@resapi/sury |
SuryCodec.make: Sury schema → Codec.t |
core; peer sury 11 |
No adapter depends on another, and core depends on none of them. Any codec that implements the two-function Codec.t interface works.
- Getting started: contract, server and client, on Hono (including Cloudflare Workers) or Express 5
- Middleware and auth: framework middleware,
Rejectand typed keys - Request bodies: content types and size limits
- Routing beside typed endpoints: plain routes, other libraries, not-found handlers
- Testing: calling the server from the typed client in tests
- Errors: every failure the server and client report, the wire formats, and reading type errors
- API reference
- Architecture: design decisions
- Examples: Cloudflare Workers, Express
Requires Node 22 or later.
npm install
npm run build # compile all ReScript
npm test # unit, integration and compile-fail tests (Node)
npm run test:worker # example and probe Workers under wrangler dev (workerd)
npm run check:isolation # package boundaries in the compiled import graph
npm run check:clean-build # fresh clone build plus incremental-build checks
npm run check:packages # pack, then install into fresh projects (incl. the doc examples)
npm run release:check # every check a release needs
npm run format:checkThe NPM release process is documented in release-process.md
After editing any rescript.json, run npx rescript clean before building.
MIT, © Seaside Logic. Contributions: CONTRIBUTING.md. Security reports: SECURITY.md.