Skip to main content
Amala 13.1 is here

Keep Koa.Add a contract.

A small TypeScript layer for controller routing, validator choice, 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

Your validator, at the edge

Pass any Standard Schema validator directly. Parsed output reaches the handler without an adapter or registry.

POST /v1/orders
const createOrder = z.object({
sku: z.string().trim(),
// The handler receives a number.
quantity: z.coerce.number().int().positive(),
});

@Controller('/orders')
class OrderController {
@Post('/')
create(@Body(createOrder) order: z.output<typeof createOrder>) {
return order;
}
}

async function main() {
const {app} = await bootstrapControllers({
controllers: [OrderController],
});
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