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

GET

/api/books

List all books

POST

/api/books

Create a book (with validation)

GET

/api/books/:integer

Get a book by ID

Prerequisites

  • The paradox CLI on your PATH

  • paradox-express installed (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