Skip to main content
Amala 13 is here

Keep Koa.Add a contract.

A small TypeScript layer for controller routing, validation, and OpenAPI—now ready to listen after bootstrap while carrying your Koa state and context types end to end.

  • Node.js 22+
  • Koa 3
  • MIT licensed
src/main.tsv13
import Koa from 'koa';
import {bootstrapControllers} from 'amala';

interface State {
user?: User;
services: Services;
}

interface Context {
requestId: string;
}

const app = new Koa<State, Context>();

async function main() {
await bootstrapControllers({
app,
controllers: [UserController],
});

app.listen(3000);
}

void main();
Ready to listenRoutes mount automatically
Typed contextNo implicit property bag
Koa-nativeManual composition stays available

Still just Koa

The context you already use, with types that follow it everywhere.

Define Koa state and context extensions once. Amala preserves them through middleware, controller creation, error handling, and the app and router it returns.

01

Compile-time guardrails. Undeclared context properties stop becoming silent any values.

02

No new runtime model. Koa remains Koa; application middleware still owns application state.

See typed context in bootstrap
One shared context contract
type AppContext = AmalaContext<State, Context>;

const authorize: AmalaMiddleware<State, Context> =
async (ctx, next) => {
ctx.state.user = await authenticate(ctx);
ctx.requestId = crypto.randomUUID();

// Fully typed in middleware, factories,
// error handlers, and returned app/router.
await next();
};

async function main() {
const {app} = await bootstrapControllers<State, Context>({
controllers: [UserController],
flow: [authorize],
});

app.listen(3000);
}

void main();

Small framework surface

Structure where it helps. Control where it matters.

01

Routes that read like code

A controller prefix and an HTTP decorator become a real Koa route. Bootstrap mounts it and returns the app.

GET /v1/users/:id
@Controller('/users')
class UserController {
@Get('/:id')
getOne(@Params('id') id: string) {
return {id};
}
}

async function main() {
const {app} = await bootstrapControllers({
controllers: [UserController],
});
app.listen(3000);
}

void main();
02

Inputs with a narrow shape

Inject only what a handler needs. The method signature documents the request instead of hiding it in context access.

GET /v1/search?q=amala
@Controller('/search')
class SearchController {
@Get('/')
find(@Query('q') query?: string) {
return {query};
}
}

async function main() {
const {app} = await bootstrapControllers({
controllers: [SearchController],
});
app.listen(3000);
}

void main();
03

Validation at the edge

A decorated class becomes a runtime request boundary, with strict unknown-field handling selected at bootstrap.

POST /v1/users
class CreateUserInput {
@IsEmail()
email!: string;
}

@Controller('/users')
class UserController {
@Post('/')
create(@Body({required: true}) input: CreateUserInput) {
return input;
}
}

async function main() {
const {app} = await bootstrapControllers({
controllers: [UserController],
validatorOptions: {
forbidNonWhitelisted: true,
whitelist: true,
},
});
app.listen(3000);
}

void main();
04

An API others can inspect

Configure the generated OpenAPI document beside the controllers it describes. Swagger stays on the same origin.

OpenAPI at /api/docs
async function main() {
const {app} = await bootstrapControllers({
basePath: '/api',
controllers: [UserController],
openAPI: {
spec: {
info: {
title: 'Example API',
version: '1.0.0',
},
},
},
});
app.listen(3000);
}

void main();

Own the boundary

Framework convenience, explicit security.

Types prevent accidental access; they do not authenticate users or validate values at runtime. Amala validates request inputs while your middleware owns identity, authorization, and application state.

Review the security guide