Amala is a small, decorator-based TypeScript framework for building REST APIs on Koa. Controllers become routes, decorated arguments receive request data, your preferred Standard Schema library validates inputs, and Koa remains available whenever the application needs it.
Documentation · Getting started · Request validation · API reference · Security guide · Report an issue
Upgrading from v12? Read the v13 migration guide. Older documentation remains available from the version selector.
Install Amala in a TypeScript project:
npm install amala
npm install --save-dev typescriptCreate src/main.ts:
import {bootstrapControllers, Controller, Get} from 'amala';
@Controller('/')
class HelloController {
@Get('/')
hello() {
return {message: 'Hello, world!'};
}
}
async function main() {
const {app} = await bootstrapControllers({
controllers: [HelloController],
disableVersioning: true, // Keep the first URL at `/` instead of `/v1/`.
});
app.listen(3000);
}
void main();Use this baseline tsconfig.json:
{
"compilerOptions": {
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"module": "commonjs",
"outDir": "dist",
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
},
"include": ["src/**/*.ts"]
}Compile, start, and request the route:
npx tsc
node dist/main.js
curl http://localhost:3000{"message":"Hello, world!"}Amala 13 attaches generated routes to the Koa app by default. That makes the result of bootstrapControllers() ready to listen without another router setup step.
- Controller routes: define endpoints with
@Controller,@Get,@Post, and the other HTTP decorators. - Focused arguments: inject only the body, query value, path parameter, header, state, or Koa context a handler needs.
- Your validation library: pass Zod, Valibot, or any Standard Schema validator directly to
@Body,@Query, or@Params; existing class-validator inputs remain supported. - Typed Koa context: carry application state and context extensions through middleware, controller construction, error handling, and bootstrap results.
- Versioning and discovery: serve multiple API versions and generate an OpenAPI document with Swagger UI.
- Koa-native composition: bring an existing app, use ordinary Koa middleware, or mount the generated router yourself.
Amala intentionally does not provide authentication, authorization, a dependency-injection container, or a service lifecycle. Those remain application and Koa middleware concerns.
- Node.js 22 or newer
- TypeScript with
experimentalDecoratorsandemitDecoratorMetadataenabled
Amala currently uses TypeScript's legacy decorator implementation. Its argument injection API depends on parameter decorators and emitted parameter-type metadata, which standard decorators do not yet provide.
For a generated starter application instead of manual setup, run:
npm create amala-app@latest my-api@Controller() supplies the shared route prefix. HTTP decorators register controller methods beneath it:
import {
bootstrapControllers,
Controller,
Delete,
Get,
Params,
Patch,
Post,
Put,
Query,
} from 'amala';
@Controller('/users')
class UserController {
@Get('/')
list(@Query('page') page?: string) {
return {page: page ?? '1'};
}
@Get('/:id')
getOne(@Params('id') id: string) {
return {id};
}
@Post('/')
create() {}
@Put('/:id')
replace(@Params('id') id: string) {}
@Patch('/:id')
update(@Params('id') id: string) {}
@Delete('/:id')
remove(@Params('id') id: string) {}
}
async function main() {
const {app} = await bootstrapControllers({
controllers: [UserController],
});
app.listen(3000);
}
void main();With the default versioning configuration, these routes live below /v1/users. Set disableVersioning: true when the application should expose /users directly.
Argument decorators keep handlers focused on the part of the Koa request they actually use:
| Decorator | Injected value |
|---|---|
@Body() / @Body('field') |
The complete request body or one field |
@Body(schema) / @Body('field', schema) |
A body or field validated and transformed by Standard Schema |
@Params() / @Params('id') |
All path parameters or one parameter |
@Params(schema) / @Params('id', schema) |
Path values validated and transformed by Standard Schema |
@Query() / @Query('q') |
The parsed query or one query value |
@Query(schema) / @Query('q', schema) |
Query values validated and transformed by Standard Schema |
@Header() / @Header('name') |
All request headers or one header |
@State() / @State('name') |
Koa state or one state value |
@CurrentUser() |
ctx.state.user |
@Session() / @Session('name') |
The configured Koa session or one value |
@File() |
Uploaded file data from koa-body or @koa/multer |
@Req() / @Res() |
The Koa request or response |
@Ctx() / @Ctx('name') |
The complete Koa context or one context property |
For example:
import {
bootstrapControllers,
Controller,
Get,
Header,
Params,
Query,
} from 'amala';
@Controller('/users')
class UserController {
@Get('/:id')
findOne(
@Params('id') id: string,
@Query('include') include?: string,
@Header('x-request-id') requestId?: string,
) {
return {id, include, requestId};
}
}
async function main() {
const {app} = await bootstrapControllers({
controllers: [UserController],
});
app.listen(3000);
}
void main();Prefer the narrowest decorator that supplies what a handler needs. It reduces coupling to Koa and makes controller methods easier to test.
Amala accepts any Standard Schema validator directly. Install the library your application prefers; this example uses Zod:
npm install zodimport {
Body,
bootstrapControllers,
Controller,
Params,
Post,
Query,
} from 'amala';
import {z} from 'zod';
const createOrderSchema = z.object({
sku: z.string().trim().min(1),
// Coercion and defaults happen before the controller receives the order.
quantity: z.coerce.number().int().positive().default(1),
});
const orderIdSchema = z.string().uuid();
const notifySchema = z
.enum(['true', 'false'])
.default('false')
.transform(value => value === 'true');
@Controller('/orders')
class OrderController {
@Post('/')
create(
@Body(createOrderSchema) order: z.output<typeof createOrderSchema>,
@Query('notify', notifySchema) notify: boolean,
) {
// Both arguments are already validated and transformed here.
return {order, notify};
}
@Post('/:id/cancel')
cancel(
@Params('id', orderIdSchema) id: string,
@Body('reason', z.string().trim().min(3)) reason: string,
) {
return {cancelled: id, reason};
}
}
async function main() {
const {app} = await bootstrapControllers({
controllers: [OrderController],
});
app.listen(3000);
}
void main();Amala selects the decorated value, runs the schema even when that value is missing, and injects the parsed output. That makes schema defaults, coercion, trimming, and asynchronous validation visible to the controller. Invalid input returns 422 with normalized field messages but without echoing the rejected value or validator object.
When a validator exposes Standard JSON Schema, Amala also uses its openapi-3.0 input schema for generated request bodies and parameters. Runtime-only validators still work; Amala omits schema-derived OpenAPI details instead of inventing them.
Existing class-validator inputs remain compatible. Keep using @Body() or @Body({required: true}) with a decorated class and pass strict behavior through validatorOptions. Applications can migrate endpoint by endpoint without a flag or adapter.
Validation establishes shape, not identity or permission. Continue to authorize every protected operation against trusted server-side state.
Koa allows middleware to place application values in ctx.state or directly on ctx. Amala preserves separate types for both without introducing another runtime abstraction.
import Koa from 'koa';
import {
AmalaContext,
AmalaMiddleware,
bootstrapControllers,
Controller,
Get,
} from 'amala';
interface User {
id: string;
name: string;
}
interface Services {
users: {
list(): Promise<User[]>;
};
}
interface AppState {
services: Services;
user?: User;
}
interface ContextExtensions {
requestId: string;
}
type AppContext = AmalaContext<AppState, ContextExtensions>;
const services: Services = {
users: {
async list() {
return [];
},
},
};
const requestContext: AmalaMiddleware<AppState, ContextExtensions> =
async (ctx, next) => {
// `ctx.state` follows the AppState contract.
ctx.state.services = services;
// Direct additions to Koa context use ContextExtensions.
ctx.requestId = crypto.randomUUID();
await next();
};
@Controller('/users')
class UserController {
constructor(private readonly ctx: AppContext) {}
@Get('/')
async list() {
return {
requestId: this.ctx.requestId,
users: await this.ctx.state.services.users.list(),
};
}
}
async function main() {
const app = new Koa<AppState, ContextExtensions>();
await bootstrapControllers({
app, // The Koa generics let Amala infer both context types.
controllers: [UserController],
flow: [requestContext],
});
app.listen(3000);
}
void main();When Amala creates the Koa app, provide the types directly:
async function main() {
const {app} = await bootstrapControllers<AppState, ContextExtensions>({
controllers: [UserController],
flow: [requestContext],
});
app.listen(3000);
}
void main();These generics prevent accidental undeclared property access during compilation. They do not freeze the context object, validate middleware output, or prove that ctx.state.user was authenticated.
Amala does not need a binding registry for this. The application creates its services, and ordinary Koa middleware exposes them through the typed request context.
Authentication and authorization belong in Koa middleware. @Flow() applies that middleware globally, to a controller, or to one endpoint:
import {
AmalaMiddleware,
bootstrapControllers,
Controller,
CurrentUser,
Flow,
Get,
} from 'amala';
const requireUser: AmalaMiddleware<AppState, ContextExtensions> =
async (ctx, next) => {
if (!ctx.state.user) {
ctx.throw(401, 'Authentication required');
}
await next();
};
@Controller('/account')
@Flow(requireUser)
class AccountController {
@Get('/')
profile(@CurrentUser() user: User) {
return user;
}
}
async function main() {
const {app} = await bootstrapControllers<AppState, ContextExtensions>({
controllers: [AccountController],
});
app.listen(3000);
}
void main();@CurrentUser() only reads ctx.state.user; it does not authenticate the request. A trusted middleware must verify the credential and establish that state first.
Version 1 is active by default. Configure additional versions at bootstrap and use @Version() when one handler belongs to a particular version:
import {bootstrapControllers, Controller, Get, Version} from 'amala';
@Controller('/users')
class UserController {
@Get('/')
@Version('1', 'Use version 2.')
listV1() {
return {version: 1};
}
@Get('/')
listCurrent() {
return {version: 2};
}
}
async function main() {
const {app} = await bootstrapControllers({
controllers: [UserController],
versions: {
1: 'Version 1 will be removed on 2027-01-01.',
2: true,
},
});
app.listen(3000);
}
void main();The version-specific handler serves /v1; the unversioned fallback serves the remaining configured versions. Deprecation messages are returned in the Deprecation response header.
OpenAPI generation is enabled by default. With basePath: '/api', Amala serves:
- OpenAPI JSON at
GET /api/docs - Swagger UI at
GET /api/swagger
Customize the document during bootstrap:
async function main() {
const {app} = await bootstrapControllers({
controllers: [UserController],
openAPI: {
enabled: true,
publicURL: 'https://api.example.com',
spec: {
info: {
title: 'Example API',
version: '1.0.0',
},
},
},
});
app.listen(3000);
}
void main();Omit publicURL to keep Swagger on the same origin. Disable or protect these endpoints when the route inventory should not be public by passing openAPI: {enabled: false} to bootstrapControllers().
@File() supports files parsed by Amala's default koa-body middleware and files supplied by @koa/multer:
import {bootstrapControllers, Controller, File, Post} from 'amala';
@Controller('/users')
class UserController {
@Post('/avatar')
uploadAvatar(@File() file: unknown) {
// Validate the actual content before storing or processing an upload.
return {received: Boolean(file)};
}
}
async function main() {
const {app} = await bootstrapControllers({
bodyParser: {
multipart: true,
formidable: {
maxFileSize: 5 * 1024 * 1024,
maxFiles: 1,
},
},
controllers: [UserController],
});
app.listen(3000);
}
void main();Multipart parsing is enabled by default for backward compatibility. The example makes that choice explicit and sets limits. Pass bodyParser: {multipart: false} when the application does not accept uploads.
When uploads are enabled, configure formidable.maxFileSize, maxFiles, and field limits. Generate server-side filenames, keep uploads outside the public web root, and inspect content instead of trusting the supplied filename or Content-Type.
Amala 13 mounts generated routes automatically. Set attachRoutes: false when an established Koa application needs to decide exactly where the router belongs:
async function main() {
const {app, router} = await bootstrapControllers({
attachRoutes: false,
controllers: [UserController],
});
// Middleware registered here runs before controller routes.
app.use(requestLogger);
app.use(rateLimiter);
// Mount Amala's already-generated router at the chosen point.
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000);
}
void main();attachRoutes controls mounting and middleware order. Controller discovery and route generation still happen during bootstrapControllers() in both modes.
You can also supply an existing typed app or router:
async function main() {
const {app, router} = await bootstrapControllers({
app: koaApp,
router: koaRouter,
controllers: [UserController],
});
app.listen(3000);
}
void main();Amala creates a fresh controller for every request and passes the Koa context to its constructor. The optional controllerFactory hook is available when an application needs a different construction strategy:
async function main() {
const {app} = await bootstrapControllers({
controllers: [UserController],
controllerFactory: async (ControllerClass, ctx) => {
return new ControllerClass(ctx);
},
});
app.listen(3000);
}
void main();The hook runs once per request and may return a promise. Amala does not provide or manage a dependency-injection container, binding registry, or service lifecycle.
The default handler formats Boom errors, returns safe validation details for client errors, hides server-error details, and logs only the internal response status. Supply errorHandler when the application needs another response envelope or a redacting structured logger:
async function main() {
const {app} = await bootstrapControllers({
controllers: [UserController],
errorHandler: async (error, ctx) => {
const errorId = crypto.randomUUID();
// A real reporter must narrow `error` and redact sensitive fields.
await reportErrorSafely({error, errorId});
ctx.status = 500;
ctx.body = {error: 'Internal Server Error', errorId};
},
});
app.listen(3000);
}
void main();The error value is unknown; narrow it before accessing error-specific properties. Keep the public response generic and do not place secrets, authorization headers, cookies, request bodies, or raw third-party URLs in logs.
Defaults make local development convenient. Public applications should choose their security-sensitive behavior explicitly:
async function main() {
const {app} = await bootstrapControllers({
basePath: '/api',
bodyParser: {
formLimit: '56kb',
jsonLimit: '1mb',
multipart: false,
textLimit: '56kb',
},
controllers: [HealthController, UserController],
cors: {
enabled: true,
opts: {
credentials: true,
origin: 'https://app.example.com',
},
},
openAPI: {enabled: false},
useHelmet: true,
validatorOptions: {
forbidNonWhitelisted: true,
whitelist: true,
},
});
app.listen(3000);
}
void main();Add authentication, authorization, CSRF protection where applicable, rate limits, request timeouts, trusted-proxy configuration, TLS termination, and upload inspection according to the application's threat model. See the production security guide for the complete checklist.
- Generated routes are attached to the Koa app by default. Set
attachRoutes: falsefor manual composition. - API version
v1is enabled unlessdisableVersioningis true. - CORS and OpenAPI/Swagger endpoints are enabled by default.
- Multipart parsing is enabled for backward compatibility.
- Controllers are constructed once per request with the typed Koa context.
- Controller classes and glob paths are trusted startup configuration and must never come from request input.
- Getting started
- Request validation
bootstrapControllersreference- Decorator reference
- Migrate from v12 to v13
- Production security guide
- Troubleshooting
The repository includes a reproducible Amala vs matched Koa vs Fastify suite for static routing, path parameters, class-validator input, and Standard Schema input. The Standard Schema workload uses the same Zod schema in Amala and plain Koa, verifies equivalent output before measurement, and gives Fastify an equivalent native JSON Schema.
npm run benchmark:smoke # Fast correctness check for harness changes.
npm run benchmark # Standard 40-second warm-up and measurement profile.Read the methodology and latest machine-readable results. These synthetic numbers measure framework overhead on one machine; they are not a promise about application performance.
npm ci
npm test
npm run build
npm run lintThe documentation site requires Node.js 20 or newer:
cd docs
npm ci
npm run buildSee CONTRIBUTING.md for the pull-request and release workflow. Report suspected vulnerabilities privately by following SECURITY.md.