Tutorial: Build a REST API with Auth & Database
This tutorial walks you through building a production-style Todo API from scratch. Along the way you’ll touch every major Chuks feature: typed dataTypes, nullable types, @validate and @json annotations, repositories with hooks, JWT authentication, route groups, middleware, async/await, and AOT compilation.
By the end you’ll have a working API with:
POST /auth/registerandPOST /auth/login(returns a JWT)GET /todos,POST /todos,PUT /todos/:id,DELETE /todos/:id(auth-protected)- SQLite-backed persistence
- A native binary you can deploy anywhere
You should already have Chuks installed — see Installation if not.
1. Scaffold the project
Section titled “1. Scaffold the project”chuks new todo_apicd todo_apiOpen chuks.json and add a couple of dependencies we’ll use:
{ "name": "todo_api", "version": "0.1.0", "entry": "src/main.chuks", "scripts": { "run-project": "chuks run src/main.chuks", "build-project": "chuks build src/main.chuks" }, "dependencies": {}}Create the directories we’ll fill in:
mkdir -p src/db src/middleware src/routes src/services2. Define the data model
Section titled “2. Define the data model”Create src/services/types.chuks — this is where our typed dataTypes live. Think of dataType as a TypeScript-style structural type that the compiler also uses for nominal type checking.
export dataType User { id: int; email: string; passwordHash: string; createdAt: string;}
export dataType Todo { id: int; userId: int; title: string; done: bool; createdAt: string;}
// Input DTOs — used at the request boundary, validated before they reach the service layer.export dataType RegisterDto { email: string @validate("required,email"), password: string @validate("required,min=8"),}
export dataType CreateTodoDto { title: string @validate("required,min=1,max=200"),}
export dataType UpdateTodoDto { title: string? @validate("min=1,max=200"), done: bool?,}A few things to notice:
- Each
@validate("…")annotation goes after the field’s type and takes a single comma-separated rule string. Built-in rules includerequired,email,url,min=N,max=N,gte=N,lte=N,len=N,oneof=a b c,regex=…,uuid,alphanum, etc. See the@validatereference. string?andbool?mean nullable. The compiler will refuse to use them in non-null contexts until you narrow withif (x != null)or coalesce with??.- The two DTOs (
CreateTodoDto,UpdateTodoDto) are distinct types fromTodo. Chuks v0.0.7’s nominal type-checker won’t let you accidentally pass one where the other is expected.
Validation isn’t automatic — call the validate(x) builtin on a typed dataType value and you get back a []string of error messages (empty when valid). We’ll wire that into the routes in step 7.
3. Wire up the database
Section titled “3. Wire up the database”Create src/db/connection.chuks:
import { db } from "std/db";
export async function dbConnection() { return await db.open("sqlite", "todo_api.db");}Now schemas. Create src/db/userEntity.chuks:
import { db } from "std/db";import { Schema, SchemaBuilder } from "std/db/schema";import { User } from "../services/types.chuks";
export const UserSchema: Schema = db.define<User>("users", (schema: SchemaBuilder) => { schema.pk("id").auto(); schema.string("email").notNull().unique(); schema.string("passwordHash").notNull(); schema.timestamp("createdAt").defaultTo("now"); schema.timestamp("updatedAt").defaultTo("now");});And src/db/todoEntity.chuks:
import { db } from "std/db";import { Schema, SchemaBuilder } from "std/db/schema";import { Todo } from "../services/types.chuks";
export const TodoSchema: Schema = db.define<Todo>("todos", (schema: SchemaBuilder) => { schema.pk("id").auto(); schema.int("userId").notNull(); schema.string("title").notNull(); schema.bool("done").notNull().defaultTo(false); schema.timestamp("createdAt").defaultTo("now"); schema.timestamp("updatedAt").defaultTo("now");});4. Repositories
Section titled “4. Repositories”Create src/db/userRepo.chuks:
import { Repository } from "std/db/repository";import { User } from "../services/types.chuks";import { UserSchema } from "./userEntity.chuks";
export class UserRepo extends Repository<User> { constructor() { super(UserSchema); }
async findByEmail(email: string): Task<User?> { return await this.where("email", email).first(); }}And src/db/todoRepo.chuks:
import { Repository } from "std/db/repository";import { Todo } from "../services/types.chuks";import { TodoSchema } from "./todoEntity.chuks";
export class TodoRepo extends Repository<Todo> { constructor() { super(TodoSchema); }
async findByUser(userId: int): Task<any> { return await this.where("userId", userId).orderBy("createdAt", "desc").all(); }}Repository<T> gives you find, where, first, all, create, update, delete, transactions, and lifecycle hooks out of the box.
5. Auth service
Section titled “5. Auth service”Create src/services/authService.chuks:
import { crypto } from "std/crypto";import { jwt } from "std/jwt";import { UserRepo } from "../db/userRepo.chuks";import { User, RegisterDto } from "./types.chuks";
const JWT_SECRET: string = "change-me-in-production";
export class AuthService { private userRepo: UserRepo;
constructor(userRepo: UserRepo) { this.userRepo = userRepo; }
async register(dto: RegisterDto): Task<string> { const existing: User? = await this.userRepo.findByEmail(dto.email); if (existing != null) { throw new Error("Email already registered"); }
const hash: string = crypto.bcryptHash(dto.password, 10); const created: User = await this.userRepo.create({ email: dto.email, passwordHash: hash, }); return this.signToken(created); }
async login(email: string, password: string): Task<string> { const user: User? = await this.userRepo.findByEmail(email); if (user == null) { throw new Error("Invalid credentials"); } // After the null check above, the compiler narrows `user` from `User?` to `User`. if (!crypto.bcryptVerify(password, user.passwordHash)) { throw new Error("Invalid credentials"); } return this.signToken(user); }
private signToken(user: User): string { return jwt.sign({ "sub": user.id, "email": user.email, }, JWT_SECRET, 1 * 60 * 60); // 1 hour expiration }
static verify(token: string): (map[string]any?) | null { try { return jwt.verify(token, JWT_SECRET); } catch (e: Error) { return null; } }}Notice how User? is narrowed to User after the if (user == null) { throw ... } guard, this is v0.0.7’s control-flow narrowing in action.
6. Auth middleware
Section titled “6. Auth middleware”Create src/middleware/authMiddleware.chuks:
import { Request, Response, NextFunction } from "std/http";import { AuthService } from "../services/authService.chuks";
export function authMiddleware(req: Request, res: Response, next: NextFunction): void { const header: string? = req.headers.get("authorization"); if (header == null || !header.startsWith("Bearer ")) { res.status(401).json('{"error":"Missing or invalid Authorization header"}'); return; }
const token: string = header.substring(7); const claims = AuthService.verify(token); if (claims == null) { res.status(401).json('{"error":"Invalid or expired token"}'); return; }
// Attach the user id to the request context so downstream handlers can use it. var t = Task.current(); if(t != null) { t.context.withValue("userId", claims.get("sub")); } next();}7. Routes
Section titled “7. Routes”Create src/routes/authRoutes.chuks:
import { Request, Response } from "std/http";import { AuthService } from "../services/authService.chuks";import { RegisterDto } from "../services/types.chuks";
export function registerAuthRoutes(app: HttpServer, authService: AuthService): void { app.post("/auth/register", async function(req: Request, res: Response) { try { const dto: RegisterDto = req.parseBody(RegisterDto); const errs: []string = validate(dto); if (errs.length > 0) { return res.status(400).json({ "errors": errs }); } const token: string = await authService.register(dto); return res.status(201).json({ "token": token }); } catch (e: Error) { return res.status(400).json({ "error": e.message }); } });
app.post("/auth/login", async function(req: Request, res: Response) { try { const dto: RegisterDto = req.parseBody(RegisterDto); const errs: []string = validate(dto); if (errs.length > 0) { return res.status(400).json({ "errors": errs }); } const token: string = await authService.login(dto.email, dto.password); return res.json({ "token": token }); } catch (e: Error) { return res.status(401).json({ "error": e.message }); } });}req.parseBody(RegisterDto) reads the JSON body and gives you a typed RegisterDto instance. Validation is a separate step: call the validate(x) builtin on the typed value to get back a []string of error messages — empty when everything passed.
Now src/routes/todoRoutes.chuks:
import { Request, Response } from "std/http";import { TodoRepo } from "../db/todoRepo.chuks";import { CreateTodoDto, UpdateTodoDto, Todo } from "../services/types.chuks";
export function registerTodoRoutes(app: RouteGroup, todoRepo: TodoRepo): void { app.get("/todos", async function(_: Request, res: Response) { // get the userId from the request context, which was set by the auth middleware. var t = Task.current() if (t == null || t.context.value("userId") == null) { return res.status(401).json({"error":"Unauthorized"}); } const userId: int = t.context.value("userId"); const todos: any = await todoRepo.findByUser(userId); return res.json(todos); });
app.post("/todos", async function(req: Request, res: Response) { var t = Task.current() if (t == null || t.context.value("userId") == null) { return res.status(401).json({"error":"Unauthorized"}); } const userId: int = t.context.value("userId"); const dto: CreateTodoDto = req.parseBody(CreateTodoDto); const errs: []string = validate(dto); if (errs.length > 0) { return res.status(400).json({ "errors": errs }); }
const result: any = await todoRepo.create({ userId: userId, title: dto.title, done: false, }); // Repository.create returns { rowsAffected, lastInsertId }; fetch the row. const created: Todo? = await todoRepo.where("id", result.lastInsertId).first(); return res.status(201).json(created); });
app.put("/todos/:id", async function(req: Request, res: Response) { var t = Task.current() if (t == null || t.context.value("userId") == null) { return res.status(401).json({"error":"Unauthorized"}); } const userId: int = t.context.value("userId"); var id = req.params.get("id"); if (id == null) { return res.status(400).json({"error":"Missing id parameter"}); } const dto: UpdateTodoDto = req.parseBody(UpdateTodoDto); const errs: []string = validate(dto); if (errs.length > 0) { return res.status(400).json({ "errors": errs }); }
// Build a partial update — only include fields the caller actually sent. var patch: map[string]any = {}; if (dto.title != null) { patch["title"] = dto.title; } if (dto.done != null) { patch["done"] = dto.done; }
const result: any = await todoRepo.where("id", id).where("userId", userId).update(patch); if (result.rowsAffected == 0) { return res.status(404).json({"error":"Todo not found"}); } return res.json({"message":"updated"}); });
app.delete("/todos/:id", async function(req: Request, res: Response) { var t = Task.current() if (t == null || t.context.value("userId") == null) { return res.status(401).json({"error":"Unauthorized"}); } const userId: int = t.context.value("userId"); const id = req.params.get("id"); if(id == null) { return res.status(400).json({"error":"Missing id parameter"}); } const result: any = await todoRepo.where("id", id).where("userId", userId).delete(); if (result.rowsAffected == 0) { return res.status(404).json({"error":"Todo not found"}); } return res.json({"message":"deleted"}); });}8. Wire it all up in main.chuks
Section titled “8. Wire it all up in main.chuks”import { createServer, Request, Response } from "std/http";import { dbConnection } from "./db/connection.chuks";import { UserRepo } from "./db/userRepo.chuks";import { TodoRepo } from "./db/todoRepo.chuks";import { AuthService } from "./services/authService.chuks";import { authMiddleware } from "./middleware/authMiddleware.chuks";import { registerAuthRoutes } from "./routes/authRoutes.chuks";import { registerTodoRoutes } from "./routes/todoRoutes.chuks";
async function main(): Task<any> { const conn = await dbConnection();
const userRepo = new UserRepo(); await userRepo.useConnection(conn);
const todoRepo = new TodoRepo(); await todoRepo.useConnection(conn);
const authService = new AuthService(userRepo);
var app = createServer();
app.get("/", function(req: Request, res: Response) { res.json('{"name":"todo_api","status":"ok"}'); });
// Public auth routes registerAuthRoutes(app, authService);
// Protected /todos group — every route inside requires a valid JWT. var todos = app.group("/", authMiddleware); registerTodoRoutes(todos, todoRepo);
println("Listening on http://localhost:3000"); app.listen(3000); return null;}app.group(prefix, middleware) is v0.0.7’s route-group feature — every handler registered on the returned object inherits the prefix and middleware stack.
9. Run it
Section titled “9. Run it”chuks run-projectIn another terminal:
# Registercurl -s -X POST localhost:3000/auth/register \ -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","password":"hunter22"}'# {"token":"eyJhbGciOi..."}
# Use the tokenTOKEN="eyJhbGciOi..."
# Create a todocurl -s -X POST localhost:3000/todos \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"Write the tutorial"}'
# List your todoscurl -s localhost:3000/todos -H "Authorization: Bearer $TOKEN"Validation runs through the validate(dto) builtin in each handler:
curl -s -X POST localhost:3000/auth/register \ -H "Content-Type: application/json" \ -d '{"email":"not-an-email","password":"x"}'# {"errors":["email must be a valid email","password must be at least 8 characters"]}10. Build for production
Section titled “10. Build for production”Compile to a self-contained native binary:
chuks build./build/todo_apiThe resulting binary is a single static executable — drop it on a server, run it under systemd or a process manager, and you’re done. See Production Deployment for cross-compilation, Docker images, and tuning tips.
What you used
Section titled “What you used”| Feature | Where |
|---|---|
dataType + nominal types | services/types.chuks |
Nullable types (T?) + narrowing | authService.chuks |
@validate annotations + validate(x) | types.chuks, route handlers |
req.parseBody(T) | authRoutes, todoRoutes |
Repository<T> | userRepo, todoRepo |
try/catch + custom errors | authService, route handlers |
Async / Task<T> / await | throughout |
| Route groups + middleware | main.chuks |
| JWT signing & verification | authService, authMiddleware |
| AOT compilation | chuks build |
Where to go next
Section titled “Where to go next”- Concurrency —
spawn, channels,Task.all()for parallel work. - Generics — write your own
Repository<T>-style generic classes. - Package Management — pull in
chuks_postgres,chuks_redis, etc. - HTTP Server — deeper coverage of routing, streaming, file uploads, and WebSockets.
- Architecture — how the VM and AOT compiler work under the hood.