Tutorial: Building a Bookshelf API
Build a type-safe Express.js bookshelf API from a Paradox specification. You will define types and endpoints in .dox, generate TypeScript types and an Express server scaffold, and wire up handlers with an in-memory data store.
What You Will Build
A REST API with three endpoints:
| Method | Path | Description |
|---|---|---|
|
|
List all books |
|
|
Create a book (with validation) |
|
|
Get a book by ID |
Prerequisites
-
The
paradoxCLI on yourPATH -
paradox-expressinstalled (npm install paradox-express) -
Node.js with TypeScript
Step 1: Write the Specification
Create bookshelf.dox:
import std
type Book
title: Text
author: Text
year: Integer
type CreateBookRequest
title: Text
author: Text
year: Integer
valid CreateBookRequest
title != "" | Title is required
author != "" | Author is required
year > 0 | Year must be positive
listBooks: URI
http://localhost/api/books GET (Unit. [Book])
createBook: URI
http://localhost/api/books POST (CreateBookRequest. Book)
getBook: URI
http://localhost/api/books/${Integer} GET (Unit. Book)
Each URI constant declares an endpoint with its HTTP method and input/output types. The valid block adds runtime validation rules to CreateBookRequest.
Step 2: Generate TypeScript Types
paradox generate --typescript
This produces .dox/index.ts with your types and validation functions:
export interface Book {
title: string;
author: string;
year: number;
}
export interface CreateBookRequest {
title: string;
author: string;
year: number;
}
export const validCreateBookRequest = (input: unknown): CreateBookRequest => {
const _x = input as globalThis.Record<string, unknown>;
// ... structural checks ...
const x = input as CreateBookRequest;
const errors: string[] = [];
if (x.title === "") errors.push(`Title is required`);
if (x.author === "") errors.push(`Author is required`);
if (x.year <= 0) errors.push(`Year must be positive`);
if (errors.length > 0) throw new Error(errors.join('; '));
return x;
}
The validation rules from your .dox spec are compiled into validCreateBookRequest — the generated server will call this automatically on incoming requests.
Step 3: Generate the Express Server
paradox-express --title "Bookshelf API" --uris listBooks --uris createBook --uris getBook
This generates two files:
.dox/handlers.ts
import type { Request, Response } from 'express';
export interface Handlers {
listBooks: (req: Request, res: Response) => void | Promise<void>;
createBook: (req: Request, res: Response) => void | Promise<void>;
getBook: (req: Request, res: Response) => void | Promise<void>;
}
.dox/server.ts
import express, { Express, RequestHandler } from "express";
import type { Handlers } from "./handlers";
export const manifest = {
title: "Bookshelf API",
author: null,
endpoints: [
{ method: "GET", path: "/api/books", accept: ["application/json"] },
{ method: "POST", path: "/api/books", accept: ["application/json"] },
{ method: "GET", path: "/api/books/:integer", accept: ["application/json"] }
],
};
export const createServer = (
handlers: Handlers,
opts?: { app?: Express; middleware?: RequestHandler[] }
): Express => {
const app = opts?.app ?? express();
// ... middleware, health check, routes ...
return app;
};
The generated server handles JSON parsing, routing, and the /health endpoint. You only implement the business logic.
Step 4: Wire It Up
Create index.ts at your project root:
import { createServer } from "./.dox/server";
import type { Handlers } from "./.dox/handlers";
import type { Book } from "./.dox/index";
const books: Book[] = [];
let nextId = 1;
const handlers: Handlers = {
listBooks: async (_req, res) => {
res.json(books);
},
createBook: async (req, res) => {
const book: Book = { ...req.body };
books.push(book);
nextId++;
res.status(201).json(book);
},
getBook: async (req, res) => {
// Unnamed captures like ${Integer} use the lowercased type name as the param
const id = parseInt(req.params.integer, 10);
const book = books[id - 1];
if (!book) {
res.status(404).json({ error: "Not found" });
return;
}
res.json(book);
},
};
const app = createServer(handlers);
app.listen(3000, () => console.log("Bookshelf API running on :3000"));
The Handlers interface ensures every endpoint is implemented with the correct signature. The generated server calls validCreateBookRequest on POST request bodies before they reach your handler.
Step 5: Test It
# Create a book
curl -X POST http://localhost:3000/api/books \
-H "Content-Type: application/json" \
-d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}'
# List all books
curl http://localhost:3000/api/books
# Get book by ID
curl http://localhost:3000/api/books/1
# Validation error -- empty title
curl -X POST http://localhost:3000/api/books \
-H "Content-Type: application/json" \
-d '{"title": "", "author": "Nobody", "year": 2024}'
# => 400 Bad Request: Title is required
# Health check
curl http://localhost:3000/health
Next Steps
-
Reference — Full flag reference, content types, error handling, and
createServeroptions -
URI Types — URI literal syntax and structure
-
TypeScript Code Generation — Details on generated types and validation
-
Servant Tutorial — The same API in Haskell
-
Gloo-net Tutorial — Generate a Rust/WASM client for this API