Skip to content

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/register and POST /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.

Terminal window
chuks new todo_api
cd todo_api

Open 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:

Terminal window
mkdir -p src/db src/middleware src/routes src/services

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.

src/services/types.chuks
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 include required, email, url, min=N, max=N, gte=N, lte=N, len=N, oneof=a b c, regex=…, uuid, alphanum, etc. See the @validate reference.
  • string? and bool? mean nullable. The compiler will refuse to use them in non-null contexts until you narrow with if (x != null) or coalesce with ??.
  • The two DTOs (CreateTodoDto, UpdateTodoDto) are distinct types from Todo. 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.

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");
});

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.

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.

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();
}

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"});
});
}
src/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.

Terminal window
chuks run-project

In another terminal:

Terminal window
# Register
curl -s -X POST localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"hunter22"}'
# {"token":"eyJhbGciOi..."}
# Use the token
TOKEN="eyJhbGciOi..."
# Create a todo
curl -s -X POST localhost:3000/todos \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Write the tutorial"}'
# List your todos
curl -s localhost:3000/todos -H "Authorization: Bearer $TOKEN"

Validation runs through the validate(dto) builtin in each handler:

Terminal window
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"]}

Compile to a self-contained native binary:

Terminal window
chuks build
./build/todo_api

The 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.

FeatureWhere
dataType + nominal typesservices/types.chuks
Nullable types (T?) + narrowingauthService.chuks
@validate annotations + validate(x)types.chuks, route handlers
req.parseBody(T)authRoutes, todoRoutes
Repository<T>userRepo, todoRepo
try/catch + custom errorsauthService, route handlers
Async / Task<T> / awaitthroughout
Route groups + middlewaremain.chuks
JWT signing & verificationauthService, authMiddleware
AOT compilationchuks build
  • Concurrencyspawn, 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.