Auto-loading, self-validating, minimalist TypeScript framework for Amazon Web Service Lambdas
A DRY, configurable, declarative TypeScript library for working with AWS Lambdas that encourages Happy Path Programming — where inputs are validated before processing, eliminating the need for nested try/catch blocks and mid-level exceptions.
For comprehensive guides, API references, and advanced usage patterns, visit our official documentation site.
Building AWS Lambda functions shouldn't require mountains of boilerplate code. Acai-TS provides:
- 🚀 Zero Boilerplate: Auto-loading router that maps URLs to handlers automatically
- ✅ Built-in Validation: OpenAPI schema validation with zero configuration
- 🎨 Decorator Support: Clean, declarative API using TypeScript decorators
- 🔄 Event Processing: Simplified DynamoDB, S3, and SQS event handling with type safety
- 📝 Type-Safe: Full TypeScript support with comprehensive type definitions
- 🧪 Easy Testing: Lightweight design makes unit testing straightforward
- ⚡ Performance: Efficient routing and validation with minimal overhead
Acai-TS embraces Happy Path Programming (HPP) — a design pattern where validation happens upfront, ensuring your business logic runs on the "happy path" without defensive coding:
// ❌ Without Acai-TS: Defensive coding everywhere
export const handler = async (event: any) => {
try {
if (!event.body) throw new Error('No body');
const body = JSON.parse(event.body);
if (!body.email) throw new Error('Email required');
if (!isValidEmail(body.email)) throw new Error('Invalid email');
// Finally, business logic...
} catch (error) {
return { statusCode: 400, body: JSON.stringify({ error }) };
}
};
// ✅ With Acai-TS: Validation handled, focus on logic
export class CreateUserEndpoint extends BaseEndpoint {
@Validate({ requiredBody: 'CreateUserRequest' })
async post(request: Request, response: Response): Promise<Response> {
// Body is already validated - just write business logic!
const user = await this.userService.create(request.body);
response.body = user;
return response;
}
}npm install acai-ts- Node.js: >= 22.19.0
- TypeScript: >= 5.0
Note:
reflect-metadatais required for decorator support. Should be part of dependencies installed.
import 'reflect-metadata';
import { Router, BaseEndpoint, Before, After, Timeout, Validate, Response, Request } from 'acai-ts';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
// File: src/handlers/users.ts
// The router maps this file to /users based on file structure
// Define middleware
const authMiddleware = async (request: Request, response: Response) => {
if (!request.headers.authorization) {
response.code = 401;
response.setError('auth', 'Unauthorized');
}
};
// Define your endpoint class with method decorators
export class UsersEndpoint extends BaseEndpoint {
@Before(authMiddleware)
@Validate({ requiredBody: 'CreateUserSchema' })
@Timeout(5000)
async post(request: Request, response: Response): Promise<Response> {
// Create user logic
response.body = {
id: '123',
email: request.body.email,
name: request.body.name
};
return response;
}
@Before(authMiddleware)
async get(request: Request, response: Response): Promise<Response> {
// Get users logic
response.body = { users: [] };
return response;
}
}
// Lambda handler
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const router = new Router({
basePath: '/api/v1',
routesPath: './src/handlers/**/*.ts',
schemaPath: './openapi.yml' // Optional: OpenAPI validation
});
return await router.route(event);
};No decorators? No problem! Use file-based routing:
import { Router } from 'acai-ts';
export const handler = async (event) => {
const router = new Router({
basePath: '/api/v1',
routesPath: './src/handlers/**/*.ts' // Smart path - works in dev AND production!
});
return await router.route(event);
};
// File: src/handlers/users.controller.ts
export const requirements = {
post: {
requiredBody: 'CreateUserRequest'
}
};
export const post = async (request, response) => {
response.body = { id: '123', ...request.body };
return response;
};Smart Path Detection: Acai-TS automatically detects and transforms TypeScript source paths to build output paths! Specify ./src/handlers/**/*.ts and it will automatically find .build/src/handlers/**/*.js, dist/src/handlers/**/*.js, etc.
The router automatically maps:
POST /api/v1/users→src/handlers/users.controller.ts(post function)GET /api/v1/users/{id}→src/handlers/users/{id}.controller.ts(get function)
import {Event} from 'acai-ts/dynamodb';
import {DynamoDBStreamEvent} from 'aws-lambda';
export const handler = async (event: DynamoDBStreamEvent) => {
// Basic pattern - synchronous access
const ddbEvent = new Event(event, {
operations: ['create', 'update'], // Filter by operation type
globalLogger: true
});
// Direct access to records (no middleware)
for (const record of ddbEvent.records) {
console.log('New item:', record.body); // New image
console.log('Old item:', record.oldBody); // Old image (updates/deletes)
console.log('Operation:', record.operation); // 'create', 'update', or 'delete'
console.log('Keys:', record.keys);
}
};import {Event} from 'acai-ts/s3';
import {S3Event} from 'aws-lambda';
export const handler = async (event: S3Event) => {
const s3Event = new Event(event, {
getObject: true, // Auto-fetch S3 objects
isJSON: true, // Parse as JSON
requiredBody: 'DataSchema', // Validate against schema
schemaPath: './schemas/openapi.yml',
operations: ['create'] // Only process ObjectCreated events
});
// Must use process() when using getObject, validation, or middleware
await s3Event.process();
for (const record of s3Event.records) {
console.log('Bucket:', record.bucket); // Bucket object with name, arn, etc
console.log('Key:', record.key);
console.log('Parsed content:', record.body);
console.log('Operation:', record.operation); // 'create' or 'delete'
}
};import {Event} from 'acai-ts/sqs';
import {SQSEvent} from 'aws-lambda';
export const handler = async (event: SQSEvent) => {
const sqsEvent = new Event(event, {
globalLogger: true
});
// Direct access to records (body is automatically parsed if JSON)
for (const record of sqsEvent.records) {
console.log('Message ID:', record.messageId);
console.log('Body:', record.body);
console.log('Attributes:', record.attributes);
console.log('Receipt Handle:', record.receiptHandle);
}
};Acai-TS provides a unified Event class for processing DynamoDB Streams, S3 Events, and SQS Messages. Import from submodules for better tree-shaking:
import {Event} from 'acai-ts/dynamodb'; // DynamoDB Streams
import {Event} from 'acai-ts/sqs'; // SQS Messages
import {Event} from 'acai-ts/s3'; // S3 EventsFor simple processing without middleware or validation:
import {Event} from 'acai-ts/dynamodb';
import {DynamoDBStreamEvent} from 'aws-lambda';
export const handler = async (event: DynamoDBStreamEvent) => {
// No middleware - direct synchronous access
const ddbEvent = new Event(event, {
operations: ['create', 'update'] // Optional filtering
});
// Synchronous access - no await needed
const records = ddbEvent.records;
for (const record of records) {
console.log(record.body);
}
};For pre-processing, validation, or operation filtering with middleware:
import {Event} from 'acai-ts/dynamodb';
import {DynamoDBStreamEvent} from 'aws-lambda';
export const handler = async (event: DynamoDBStreamEvent) => {
const ddbEvent = new Event(event, {
// Before middleware runs before processing
before: async (records: any[]) => {
console.log(`Received ${records.length} records`);
// Can transform or enrich records here
},
// Filter by operation type
operations: ['create'],
// Schema validation
requiredBody: 'RecordSchema',
schemaPath: './schemas/openapi.yml'
});
// Must call process() when using middleware, validation, or getObject
await ddbEvent.process();
// Then access records
const records = ddbEvent.records;
for (const record of records) {
// Records are validated and filtered
console.log(record.body);
}
};
⚠️ Important: The@Before/@Afterdecorators from acai-ts are for Router/API Gateway endpoints only. For event handlers, use function wrapper patterns:
import {Event} from 'acai-ts/sqs';
import {SQSEvent} from 'aws-lambda';
// Define handler type
type HandlerFunction = (event: SQSEvent) => Promise<any>;
// Create wrapper functions (like decorators)
function withLogging(handler: HandlerFunction): HandlerFunction {
return async (event: SQSEvent) => {
console.log('START: Processing event');
const result = await handler(event);
console.log('END: Processing complete');
return result;
};
}
function withValidation(handler: HandlerFunction): HandlerFunction {
return async (event: SQSEvent) => {
if (!event.Records || event.Records.length === 0) {
throw new Error('No records to process');
}
return await handler(event);
};
}
// Core handler logic
async function processMessages(event: SQSEvent) {
const sqsEvent = new Event(event, {});
for (const record of sqsEvent.records) {
console.log('Message:', record.body);
}
return { statusCode: 200 };
}
// Apply wrappers (execute in order: validation → logging → handler)
export const handler = withLogging(
withValidation(
processMessages
)
);import {Event} from 'acai-ts/dynamodb';
import {DynamoDBStreamEvent} from 'aws-lambda';
export const handler = async (event: DynamoDBStreamEvent) => {
const ddbEvent = new Event(event, {
operations: ['create', 'update'], // Filter operations
before: async (records: any[]) => {
console.log(`Processing ${records.length} records`);
}
});
await ddbEvent.process();
for (const record of ddbEvent.records) {
console.log('Keys:', record.keys);
console.log('New data:', record.body); // NewImage
console.log('Old data:', record.oldBody); // OldImage (for updates/deletes)
console.log('Operation:', record.operation); // 'create', 'update', 'delete'
console.log('Event ID:', record.id);
console.log('Event name:', record.name); // 'INSERT', 'MODIFY', 'REMOVE'
console.log('Source ARN:', record.sourceARN);
}
return { statusCode: 200 };
};Available Operations:
'create'- Maps to DynamoDB INSERT events'update'- Maps to DynamoDB MODIFY events'delete'- Maps to DynamoDB REMOVE events
import {Event} from 'acai-ts/sqs';
import {SQSEvent} from 'aws-lambda';
export const handler = async (event: SQSEvent) => {
const sqsEvent = new Event(event, {
before: async (records: any[]) => {
console.log(`Processing ${records.length} messages`);
}
});
await sqsEvent.process();
for (const record of sqsEvent.records) {
console.log('Message ID:', record.messageId);
console.log('Body:', record.body); // Auto-parsed if JSON
console.log('Attributes:', record.attributes); // Message attributes
console.log('Receipt:', record.receiptHandle);
console.log('Source:', record.source); // 'aws:sqs'
}
return { statusCode: 200 };
};Batch Processing:
// SQS can send up to 10 messages per invocation
const sqsEvent = new Event(event, {});
console.log(`Batch size: ${sqsEvent.records.length}`); // Up to 10import {Event} from 'acai-ts/s3';
import {S3Event} from 'aws-lambda';
export const handler = async (event: S3Event) => {
const s3Event = new Event(event, {
operations: ['create'], // Only ObjectCreated events
getObject: true, // Fetch S3 object content
isJSON: true, // Parse as JSON
before: async (records: any[]) => {
console.log(`Processing ${records.length} S3 events`);
}
});
await s3Event.process(); // Required when using getObject
for (const record of s3Event.records) {
console.log('Bucket:', record.bucket); // Bucket object with name, arn
console.log('Key:', record.key);
console.log('Event:', record.eventName); // 'ObjectCreated:Put', etc.
console.log('Operation:', record.operation); // 'create' or 'delete'
console.log('Size:', record.size);
console.log('Content:', record.body); // Parsed JSON content
}
return { statusCode: 200 };
};Available Operations:
'create'- Maps to ObjectCreated:* events (Put, Post, Copy, CompleteMultipartUpload)'delete'- Maps to ObjectRemoved:* events (Delete, DeleteMarkerCreated)
S3 Object Fetching:
// Without getObject - just event metadata
const s3Event = new Event(event, {});
record.body; // undefined
// With getObject - fetches object content
const s3Event = new Event(event, { getObject: true });
await s3Event.process();
record.body; // Buffer or string
// With JSON parsing
const s3Event = new Event(event, { getObject: true, isJSON: true });
await s3Event.process();
record.body; // Parsed object
// With CSV parsing
const s3Event = new Event(event, { getObject: true, isCSV: true });
await s3Event.process();
record.body; // Array of parsed rowsTransform records into custom classes with type-safe methods:
import {Event} from 'acai-ts/dynamodb';
class UserRecord {
id: string;
email: string;
name: string;
constructor(record: any) {
this.id = record.body.id;
this.email = record.body.email;
this.name = record.body.name;
}
sendWelcomeEmail() {
console.log(`Sending email to ${this.email}`);
// Email sending logic
}
validate() {
return this.email.includes('@');
}
}
export const handler = async (event: DynamoDBStreamEvent) => {
const ddbEvent = new Event<UserRecord>(event, {
dataClass: UserRecord,
operations: ['create']
});
await ddbEvent.process();
for (const user of ddbEvent.records) {
// TypeScript knows these are UserRecord instances
if (user.validate()) {
user.sendWelcomeEmail(); // Type-safe method access!
}
}
};When to use synchronous access (.records):
- No middleware configured
- No validation needed
- No S3
getObjectneeded - Simple operation filtering only
When to use asynchronous access (await .process() then .records):
- Using
beforemiddleware - Schema validation with
requiredBody - S3 object fetching with
getObject - Any advanced processing
// ✅ Sync - OK
const event = new Event(rawEvent, { operations: ['create'] });
const records = event.records;
// ❌ Sync - ERROR: Must use process()
const event = new Event(rawEvent, {
before: async (r) => console.log(r.length)
});
const records = event.records; // Error thrown!
// ✅ Async - Correct
const event = new Event(rawEvent, {
before: async (r) => console.log(r.length)
});
await event.process();
const records = event.records; // Works!Acai-TS provides powerful method decorators for clean, declarative API Gateway endpoints using the class-based pattern.
⚠️ Important: The@Before,@After,@Auth,@Timeout, and@Validatedecorators are for Router/API Gateway endpoints only and work on class methods (not classes or standalone functions).For event handlers (DynamoDB, S3, SQS), these decorators will not work. Instead, use:
- Function wrapper patterns (see Event Processing Patterns section)
- Configuration options like
beforein the Event constructorSee the Event Processing Patterns section for event handler examples.
Acai-TS supports two patterns for defining API Gateway endpoints:
Best for simple endpoints with basic validation:
// File: src/handlers/users.ts
export const requirements = {
get: {
before: [authMiddleware],
requiredHeaders: ['x-api-key']
},
post: {
requiredBody: 'CreateUserRequest'
}
};
export const get = async (request: Request, response: Response) => {
response.body = { users: [] };
return response;
};
export const post = async (request: Request, response: Response) => {
response.body = { id: '123', ...request.body };
return response;
};Best for complex endpoints with multiple methods and middleware:
// File: src/handlers/users.ts
import { BaseEndpoint, Before, After, Timeout, Validate, Auth } from 'acai-ts';
export class UsersEndpoint extends BaseEndpoint {
@Before(authMiddleware)
@Validate({ requiredHeaders: ['x-api-key'] })
async get(request: Request, response: Response): Promise<Response> {
response.body = { users: [] };
return response;
}
@Before(authMiddleware)
@Validate({ requiredBody: 'CreateUserRequest' })
@Timeout(5000)
async post(request: Request, response: Response): Promise<Response> {
response.body = { id: '123', ...request.body };
return response;
}
@Before(authMiddleware)
async put(request: Request, response: Response): Promise<Response> {
response.body = { updated: true };
return response;
}
}All decorators are applied to class methods (get, post, put, patch, delete) in classes that extend BaseEndpoint.
Run middleware before method execution. Multiple middlewares execute in order:
const authCheck = async (request: Request, response: Response) => {
if (!request.headers['x-api-key']) {
response.code = 401;
response.setError('auth', 'API key required');
}
};
const rateLimiter = async (request: Request, response: Response) => {
// Rate limiting logic
};
export class ProtectedEndpoint extends BaseEndpoint {
@Before(rateLimiter, authCheck) // Executes: rateLimiter → authCheck → get()
async get(request: Request, response: Response): Promise<Response> {
response.body = { message: 'Authenticated and rate-limited!' };
return response;
}
}Run middleware after method execution. Multiple middlewares execute in order:
const addTimestamp = async (request: Request, response: Response) => {
response.body.timestamp = new Date().toISOString();
};
const addVersion = async (request: Request, response: Response) => {
response.body.version = '1.0';
};
export class DataEndpoint extends BaseEndpoint {
@After(addTimestamp, addVersion) // Executes: get() → addTimestamp → addVersion
async get(request: Request, response: Response): Promise<Response> {
response.body = { data: 'value' };
return response;
}
}Set request timeout for the method:
export class HeavyTaskEndpoint extends BaseEndpoint {
@Timeout(30000) // 30 seconds
async post(request: Request, response: Response): Promise<Response> {
await this.processHeavyTask();
response.body = { completed: true };
return response;
}
}Mark a method as requiring authentication. When used, the router's withAuth middleware will be executed:
// Configure auth middleware in router
const router = new Router({
basePath: '/api/v1',
routesPath: './src/handlers/**/*.ts',
withAuth: async (request, response) => {
// Your JWT validation logic here
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token || !validateJWT(token)) {
response.code = 401;
response.setError('auth', 'Invalid or missing authentication token');
}
}
});
// Use @Auth decorator on methods that require authentication
export class UsersEndpoint extends BaseEndpoint {
@Auth() // Requires authentication (default: required=true)
async get(request: Request, response: Response): Promise<Response> {
// Auth middleware runs before this method
response.body = { users: [] };
return response;
}
@Auth(false) // Explicitly disable auth requirement
async post(request: Request, response: Response): Promise<Response> {
// No auth required for this endpoint
response.body = { message: 'Public endpoint' };
return response;
}
// No @Auth decorator = no auth requirement
async options(request: Request, response: Response): Promise<Response> {
response.body = { message: 'CORS preflight' };
return response;
}
}Validate request data against schemas or requirements:
export class UsersEndpoint extends BaseEndpoint {
// Validate using OpenAPI schema
@Validate({ requiredBody: 'CreateUserRequest' })
async post(request: Request, response: Response): Promise<Response> {
response.body = { id: '123', ...request.body };
return response;
}
// Validate headers
@Validate({ requiredHeaders: ['x-api-key', 'authorization'] })
async get(request: Request, response: Response): Promise<Response> {
response.body = { users: [] };
return response;
}
// Validate query parameters
@Validate({ requiredQuery: ['page', 'limit'] })
async get(request: Request, response: Response): Promise<Response> {
const page = parseInt(request.queryParameters.page);
response.body = { page, users: [] };
return response;
}
// Validate using JSON schema
@Validate({
body: {
type: 'object',
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 2 }
}
}
})
async post(request: Request, response: Response): Promise<Response> {
response.body = { id: '123', ...request.body };
return response;
}
}Stack multiple decorators on a single method. They execute in a specific order:
export class UsersEndpoint extends BaseEndpoint {
@Before(rateLimiter) // Runs first (custom middleware)
@Auth() // Auth middleware runs after Before middleware
@Validate({ requiredBody: 'CreateUserRequest' }) // Validates request
@Timeout(5000) // Sets timeout
@After(addTimestamp, logResponse) // Runs last
async post(request: Request, response: Response): Promise<Response> {
// Your business logic here
response.body = { id: '123', ...request.body };
return response;
}
}Execution Order:
@Beforemiddleware (rateLimiter)@Authauthentication (router'swithAuthmiddleware)@Validatevalidation- Method execution with
@Timeout @Aftermiddleware (in order: addTimestamp → logResponse)
Define all HTTP methods for a resource in a single class:
export class UsersEndpoint extends BaseEndpoint {
// GET /users
@Before(authMiddleware)
async get(request: Request, response: Response): Promise<Response> {
response.body = { users: [] };
return response;
}
// POST /users
@Before(authMiddleware)
@Validate({ requiredBody: 'CreateUserRequest' })
@Timeout(5000)
async post(request: Request, response: Response): Promise<Response> {
response.body = { id: '123', ...request.body };
return response;
}
// PUT /users (if your routing supports it)
@Before(authMiddleware)
@Validate({ requiredBody: 'UpdateUserRequest' })
async put(request: Request, response: Response): Promise<Response> {
response.body = { updated: true };
return response;
}
// DELETE /users
@Before(authMiddleware)
async delete(request: Request, response: Response): Promise<Response> {
response.code = 204;
return response;
}
}Validate requests and responses against OpenAPI 3.0 schemas:
const router = new Router({
basePath: '/api/v1',
schemaPath: './openapi.yml',
autoValidate: true, // Validate requests against OpenAPI schema
validateResponse: true // Also validate responses
});import { ApiError } from 'acai-ts';
export class GetUserEndpoint extends BaseEndpoint {
async get(request: Request, response: Response): Promise<Response> {
const user = await this.userRepo.findById(request.pathParameters.id);
if (!user) {
throw new ApiError('User not found', 404, 'user_id');
}
response.body = user;
return response;
}
}import { Logger } from 'acai-ts';
// Global logger setup
Logger.setUpGlobal(true, {
callback: (log) => {
// Custom logging logic (e.g., send to CloudWatch, Datadog, etc.)
console.log('Custom handler:', log);
},
minLevel: 'INFO'
});
// Use in your code
const logger = new Logger();
logger.log('Processing request');
logger.error('Something went wrong');
// Or use global logger if set up
global.logger?.info('Using global logger');Process events with middleware for validation, enrichment, etc:
import {Event} from 'acai-ts/dynamodb';
const enrichRecord = async (records: any[]) => {
for (const record of records) {
record.metadata = await fetchMetadata(record.id);
}
};
const ddbEvent = new Event(event, {
before: enrichRecord,
operations: ['create'], // Use normalized operations: 'create', 'update', 'delete'
requiredBody: 'RecordSchema',
schemaPath: './schemas/openapi.yml'
});
await ddbEvent.process();Transform records into custom classes:
import {Event} from 'acai-ts/dynamodb';
class User {
id: string;
email: string;
constructor(record: any) {
this.id = record.body.id;
this.email = record.body.email;
}
sendWelcomeEmail() {
// Custom method
}
}
const ddbEvent = new Event<User>(event, {
dataClass: User,
operations: ['create'] // Use normalized operations
});
await ddbEvent.process();
for (const user of ddbEvent.records) {
user.sendWelcomeEmail(); // Type-safe method access!
}Constructor Options:
interface RouterConfig {
basePath?: string; // Base path to strip from requests (e.g., '/api/v1')
schemaPath?: string; // Path to OpenAPI schema file
routesPath: string; // Path to handler files with smart build detection
// Examples: './src/handlers/**/*.ts', 'src/handlers'
// Automatically transforms to build output (.js files)
// If no glob pattern (*) detected, '**/*.ts' is auto-appended
buildOutputDir?: string; // Build output directory (e.g., '.build', 'dist')
// Optional: Auto-detects common directories if not specified
// Checked in order: .build, build, dist, .dist
cache?: 'all' | 'none' | 'route'; // Cache mode for route resolution
autoValidate?: boolean; // Validate requests against OpenAPI schema (default: false)
validateResponse?: boolean; // Validate responses against schema (default: false)
timeout?: number; // Default timeout in ms
outputError?: boolean; // Output detailed error messages (default: false)
globalLogger?: boolean; // Enable global logging (default: false)
loggerCallback?: LoggerCallback; // Custom logger callback function
beforeAll?: BeforeMiddleware; // Global before middleware
afterAll?: AfterMiddleware; // Global after middleware
withAuth?: AuthMiddleware; // Global auth middleware
onError?: ErrorMiddleware; // Global error handler
onTimeout?: TimeoutMiddleware; // Global timeout handler
}Smart Path Detection:
Acai-TS automatically detects and transforms TypeScript source paths to JavaScript build output:
// ✅ Recommended: Use source paths
const router = new Router({
routesPath: './src/handlers/**/*.ts'
});
// Automatically finds: ./.build/src/handlers/**/*.js (or build/, dist/, .dist/)
// ✅ Explicit build directory
const router = new Router({
routesPath: './src/handlers/**/*.ts',
buildOutputDir: '.build'
});
// Uses: ./.build/src/handlers/**/*.js
// ✅ Also works: Direct path to build output
const router = new Router({
routesPath: '.build/src/handlers/**/*.js'
});Properties:
interface Request {
path: string; // Request path
method: string; // HTTP method
headers: Record<string, string>; // Request headers
queryParameters: Record<string, any>; // Query string params
pathParameters: Record<string, any>; // Path params (e.g., {id})
body: any; // Parsed request body
rawBody: string; // Raw request body string
context: any; // Custom context (set by middleware)
}Properties & Methods:
interface Response {
body: any; // Response body
code: number; // HTTP status code (default: 200)
headers: Record<string, string>; // Response headers
hasErrors: boolean; // Whether response has errors
errors: ErrorObject[]; // Array of error objects
// Methods
setHeader(key: string, value: string): void;
setHeaders(headers: Record<string, string>): void;
setError(key: string, message: string): void;
setErrors(errors: ErrorObject[]): void;
addBodyProperty(key: string, value: unknown): void;
addBodyProperties(properties: Record<string, unknown>): void;
compress(): void; // Enable gzip compression
}Class for defining API Gateway endpoints with method decorators:
import { BaseEndpoint, Before, After, Timeout, Validate, Auth } from 'acai-ts';
export class UsersEndpoint extends BaseEndpoint {
// Implement HTTP methods: get, post, put, patch, delete
async get(request: Request, response: Response): Promise<Response> {
// GET handler implementation
return response;
}
async post(request: Request, response: Response): Promise<Response> {
// POST handler implementation
return response;
}
async put(request: Request, response: Response): Promise<Response> {
// PUT handler implementation
return response;
}
async patch(request: Request, response: Response): Promise<Response> {
// PATCH handler implementation
return response;
}
async delete(request: Request, response: Response): Promise<Response> {
// DELETE handler implementation
return response;
}
}Supported HTTP Methods:
get(request, response)- Handles GET requestspost(request, response)- Handles POST requestsput(request, response)- Handles PUT requestspatch(request, response)- Handles PATCH requestsdelete(request, response)- Handles DELETE requests
Method Decorators:
All decorators are applied to the individual HTTP methods (not the class itself):
export class UsersEndpoint extends BaseEndpoint {
@Before(authMiddleware) // Runs before the method
@Validate({ requiredBody: 'UserSchema' }) // Validates request
@Timeout(5000) // Sets 5-second timeout
@After(loggingMiddleware) // Runs after the method
async post(request: Request, response: Response): Promise<Response> {
response.body = { id: '123', ...request.body };
return response;
}
}File Structure:
- Place endpoint classes in handler files:
src/handlers/users.ts - Export the class:
export class UsersEndpoint extends BaseEndpoint { ... } - Router automatically discovers and instantiates the class
- Route is determined by file path:
src/handlers/users.ts→/users
Submodule Imports:
// Import Event from submodules for better tree-shaking
import {Event} from 'acai-ts/dynamodb'; // For DynamoDB Streams
import {Event} from 'acai-ts/sqs'; // For SQS Messages
import {Event} from 'acai-ts/s3'; // For S3 Events
// Or import from main module (less optimal for tree-shaking)
import {Event as DDBEvent} from 'acai-ts';Event Configuration:
All event types use the same IEventConfig<T> interface:
interface IEventConfig<T> {
// Operation filtering (normalized across all event types)
operations?: OperationType[]; // ['create', 'update', 'delete']
operationError?: boolean; // Throw error on wrong operation (default: false)
// Middleware
before?: (records: any[]) => void | Promise<void>; // Pre-process middleware
// Data transformation
dataClass?: new (record: any) => T; // Transform to custom class
// Validation
requiredBody?: string | object; // Schema validation
schemaPath?: string; // Path to OpenAPI schema
validationError?: boolean; // Throw on validation error (default: true)
strictValidation?: boolean; // Strict schema validation
autoValidate?: boolean; // Auto-validate with OpenAPI
// S3-specific options
getObject?: boolean; // Auto-fetch S3 objects (S3 only)
isJSON?: boolean; // Parse S3 object as JSON (requires getObject)
isCSV?: boolean; // Parse S3 object as CSV (requires getObject)
// Logging
globalLogger?: boolean; // Enable global logging
loggerCallback?: (log: any) => void; // Custom logger callback
}Operation Types:
Operations are normalized across all event types:
type OperationType = 'create' | 'update' | 'delete';
// DynamoDB mapping:
// 'create' = INSERT
// 'update' = MODIFY
// 'delete' = REMOVE
// S3 mapping:
// 'create' = ObjectCreated:* (Put, Post, Copy, CompleteMultipartUpload)
// 'delete' = ObjectRemoved:* (Delete, DeleteMarkerCreated)
// SQS: No operation filtering (all messages treated as 'create')Run tests with Jest:
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverageRecommended project structure for routing:
my-lambda/
├── src/
│ ├── controllers/
│ │ ├── users/
│ │ │ ├── index.controller.ts # GET/POST /users
│ │ │ └── {id}.controller.ts # GET/PUT/DELETE /users/{id}
│ │ └── products.controller.ts # /products
│ ├── schemas/
│ │ └── openapi.yml
│ └── index.ts # Lambda entry point
├── test/
├── tsconfig.json
└── package.json
Happy Path Programming (HPP) is a design philosophy where validation happens upfront, ensuring your core business logic operates on the "happy path" without defensive coding:
- Validate Early: All inputs are validated before processing
- Fail Fast: Invalid inputs are rejected immediately with clear errors
- Clean Logic: Business logic doesn't need nested try/catch or null checks
- Type Safety: TypeScript ensures compile-time safety, Acai-TS ensures runtime safety
Acai-TS eliminates boilerplate through:
- Auto-loading: File-based routing discovers handlers automatically
- Convention over Configuration: Sensible defaults reduce config
- Decorators: Declarative metadata instead of imperative setup
- Shared Validation: Define schemas once, use everywhere
| Feature | Acai-TS | Lambda API | Serverless Express | AWS SDK |
|---|---|---|---|---|
| TypeScript-First | ✅ | ❌ | ❌ | ✅ |
| Decorator Support | ✅ | ❌ | ❌ | ❌ |
| Auto-loading Router | ✅ | ✅ | ❌ | ❌ |
| OpenAPI Validation | ✅ | ❌ | ❌ | ❌ |
| Event Processing (DDB/S3/SQS) | ✅ | ❌ | ❌ | ✅ |
| Happy Path Programming | ✅ | ❌ | ❌ | ❌ |
| Zero Boilerplate | ✅ | ✅ | ❌ | ❌ |
| Minimal Dependencies | ✅ | ✅ | ❌ | ✅ |
Error Message:
BuildPathNotFoundError: Build output path not found for "./src/handlers/**/*.ts".
Attempted paths: ./.build/src/handlers/**/*.js, ./build/src/handlers/**/*.js,
./dist/src/handlers/**/*.js, ./.dist/src/handlers/**/*.js
Cause: Acai-TS cannot find compiled JavaScript files in any of the common build directories.
Solutions:
-
Verify your build output exists:
# Check if your build directory exists ls -la .build/ # or dist/, build/, .dist/
-
Specify explicit build directory:
const router = new Router({ routesPath: './src/handlers/**/*.ts', buildOutputDir: 'dist' // or '.build', 'build', etc. });
-
Use direct path to compiled files:
const router = new Router({ routesPath: './dist/src/handlers/**/*.js' });
-
Ensure TypeScript is compiling correctly:
npm run build ls -la .build/src/handlers/ # Verify .js files exist
Error Message:
Decorators are not valid here
Cause: Using @ decorator syntax on exported const declarations or on class declarations.
Solution: Use decorators on class methods only:
// ❌ Wrong: @ syntax on const/function
@Before(middleware)
export const get = async (request, response) => { ... };
// ❌ Wrong: Decorators on the class itself
@Route('GET', '/users')
export class UsersEndpoint extends BaseEndpoint { ... }
// ✅ Correct: Decorators on class methods
export class UsersEndpoint extends BaseEndpoint {
@Before(middleware)
async get(request: Request, response: Response): Promise<Response> {
// handler code
}
}
// ✅ Alternative: Use requirements pattern for function-based handlers
export const requirements = {
get: {
before: [middleware]
}
};
export const get = async (request, response) => { ... };Problem: Router returns 404 for existing handlers.
Checks:
-
Verify file naming convention:
✅ users.controller.ts → /users ✅ users/{id}.controller.ts → /users/{id} ❌ usersController.ts → Won't match -
Check basePath configuration:
// If basePath is '/api/v1' // Request: GET /api/v1/users // Maps to: src/handlers/users.controller.ts const router = new Router({ basePath: '/api/v1', // Must match your API Gateway stage/path routesPath: './src/handlers/**/*.ts' });
-
Verify exported method names:
// File: users.controller.ts export const get = async (request, response) => { ... }; // ✅ GET /users export const post = async (request, response) => { ... }; // ✅ POST /users export const Get = async (request, response) => { ... }; // ❌ Case-sensitive!
Problem: TypeScript files in development but .js files not found in production.
Solution:
Ensure your build process compiles TypeScript before deployment:
// package.json
{
"scripts": {
"build": "tsc",
"prepack": "npm run build",
"deploy": "npm run build && serverless deploy"
}
}Error: Cannot find module 'acai-ts'
Solutions:
-
Install dependencies:
npm install acai-ts reflect-metadata
-
For serverless deployments, ensure
node_modulesis included:# serverless.yml package: patterns: - '!node_modules/**' - 'node_modules/acai-ts/**' - 'node_modules/reflect-metadata/**'
Problem: Schema validation fails even with correct data.
Checks:
-
Verify schema path:
const router = new Router({ schemaPath: './openapi.yml', // Relative to execution directory autoValidate: true });
-
Check schema references match:
// In handler export const requirements = { post: { requiredBody: 'CreateUserRequest' // Must match schema name exactly } };
-
Validate your OpenAPI schema:
# Use a validator npx @apidevtools/swagger-cli validate openapi.yml
Problem: Before/After middleware doesn't run.
Solutions:
-
For requirements pattern, ensure proper structure:
// ✅ Correct export const requirements = { get: { before: [authMiddleware, rateLimiter], // Array of middleware after: [loggingMiddleware] } }; export const get = async (request, response) => { ... };
-
For class-based decorators, ensure they're on methods (not class):
// ✅ Correct: Decorators on methods export class UsersEndpoint extends BaseEndpoint { @Before(authMiddleware) @After(loggingMiddleware) async get(request: Request, response: Response): Promise<Response> { // handler code } } // ❌ Wrong: Decorators on the class @Before(authMiddleware) export class UsersEndpoint extends BaseEndpoint { ... }
-
Verify middleware signature:
// ✅ Correct signature const middleware: BeforeMiddleware = async (request: Request, response: Response) => { // Your logic }; // ❌ Wrong - missing parameters const middleware = async () => { ... };
-
Check that your class extends BaseEndpoint:
// ✅ Correct export class UsersEndpoint extends BaseEndpoint { ... } // ❌ Wrong - missing extends export class UsersEndpoint { ... }
Problem: Slow response times or high memory usage.
Optimizations:
-
Enable caching:
const router = new Router({ routesPath: './src/handlers/**/*.ts', cache: 'all' // Cache route resolutions });
-
Reduce handler file scanning:
// ✅ Specific pattern routesPath: './src/handlers/users/**/*.ts' // ❌ Too broad routesPath: './src/**/*.ts'
-
Use lazy loading for heavy dependencies:
// Inside handler, not at module level export const post = async (request, response) => { const heavyLib = await import('heavy-library'); // Use heavyLib };
Problem: Using @Before/@After decorators on event handlers causes errors or doesn't execute.
Cause: The @Before, @After, @Auth, and @Timeout decorators are for Router/API Gateway endpoints only, not for event handlers (DynamoDB, S3, SQS).
Solutions:
-
Use function wrapper patterns:
import {Event} from 'acai-ts/sqs'; import {SQSEvent} from 'aws-lambda'; type HandlerFunction = (event: SQSEvent) => Promise<any>; function withLogging(handler: HandlerFunction): HandlerFunction { return async (event: SQSEvent) => { console.log('Processing event...'); const result = await handler(event); console.log('Complete!'); return result; }; } async function processEvent(event: SQSEvent) { const sqsEvent = new Event(event, {}); // Process records return { statusCode: 200 }; } export const handler = withLogging(processEvent);
-
Use the
beforeconfiguration option:const ddbEvent = new Event(event, { before: async (records: any[]) => { console.log(`Processing ${records.length} records`); } }); await ddbEvent.process();
See the Event Processing Patterns section for complete examples.
Error Message:
Cannot find module 'acai-ts/dynamodb' or its corresponding type declarations.
There are types at '.../node_modules/acai-ts/dist/esm/dynamodb/index.d.ts', but this
result could not be resolved under your current 'moduleResolution' setting.
Consider updating to 'node16', 'nodenext', or 'bundler'.
Cause: TypeScript moduleResolution is set to 'node' (legacy) instead of a modern setting that supports package subpath exports.
Solutions:
-
Update tsconfig.json (Recommended):
{ "compilerOptions": { "moduleResolution": "node16" // or "nodenext" or "bundler" } } -
Use main module import (less optimal for tree-shaking):
// Instead of: import {Event} from 'acai-ts/dynamodb'; // Use: import {Event as DDBEvent} from 'acai-ts';
Note: Submodule imports (acai-ts/dynamodb, acai-ts/sqs, acai-ts/s3) are preferred for better tree-shaking and smaller bundle sizes.
Error Message:
Must use Event.process() with these params & await the records
Cause: Trying to use synchronous .records access when middleware, validation, or S3 getObject is configured.
Solution:
The access pattern depends on your configuration:
// ✅ Sync access (no middleware) - OK
const event = new Event(rawEvent, {
operations: ['create'] // Simple filtering only
});
const records = event.records; // Direct access
// ❌ Sync access with middleware - ERROR
const event = new Event(rawEvent, {
before: async (r: any[]) => console.log(r.length),
operations: ['create']
});
const records = event.records; // Throws error!
// ✅ Async access with middleware - Correct
const event = new Event(rawEvent, {
before: async (r: any[]) => console.log(r.length),
operations: ['create']
});
await event.process(); // Required!
const records = event.records; // Now worksRequires await .process():
- When using
beforemiddleware - When using
requiredBodyvalidation - When using S3
getObject - Any advanced processing
Direct .records access OK:
- Simple operation filtering only (
operations: ['create']) - No middleware
- No validation
- No S3 object fetching
Error Message:
record is operation: insert; only allowed create,update,delete
Cause: Using AWS event names ('INSERT', 'MODIFY', 'REMOVE', 'ObjectCreated:Put') instead of normalized operation types.
Solution:
Always use the normalized operation types:
// ❌ Wrong: AWS event names
operations: ['INSERT', 'MODIFY'] // DynamoDB
operations: ['ObjectCreated:Put'] // S3
// ✅ Correct: Normalized types
operations: ['create', 'update'] // Works for all event types
operations: ['create'] // Filter creates onlyOperation Mappings:
DynamoDB:
'create'→ INSERT'update'→ MODIFY'delete'→ REMOVE
S3:
'create'→ ObjectCreated:* (Put, Post, Copy, CompleteMultipartUpload)'delete'→ ObjectRemoved:* (Delete, DeleteMarkerCreated)
SQS:
- No operation filtering (all messages are treated as events)
Problem: Following examples but properties like .newImage, .bucketName, .messageAttributes don't exist.
Cause: Using incorrect or outdated property names.
Solution:
Use these correct property names:
DynamoDB Records:
record.id // Event ID
record.name // Event name: 'INSERT', 'MODIFY', 'REMOVE'
record.operation // Normalized: 'create', 'update', 'delete'
record.keys // DynamoDB keys
record.body // New image (NewImage)
record.oldBody // Old image (OldImage) - for updates/deletes
record.size // Approximate size
record.sourceARN // Stream ARN
record.sequencer // Sequence numberS3 Records:
record.bucket // Bucket object with .name, .arn, .ownerIdentity
record.key // Object key/path
record.eventName // Full event name: 'ObjectCreated:Put'
record.operation // Normalized: 'create' or 'delete'
record.size // Object size in bytes
record.eTag // Object ETag
record.body // Object content (if getObject: true)
record.source // 'aws:s3'SQS Records:
record.messageId // Message ID
record.body // Message body (auto-parsed if JSON)
record.attributes // Message attributes (NOT messageAttributes!)
record.receiptHandle // Receipt handle for deletion
record.source // 'aws:sqs'Common Mistakes:
// ❌ Wrong
record.newImage // Use: record.body
record.oldImage // Use: record.oldBody
record.bucketName // Use: record.bucket (it's an object!)
record.messageAttributes // Use: record.attributesContributions are welcome! Please follow these guidelines:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
# Clone the repository
git clone https://github.com/yourusername/acai-ts.git
cd acai-ts
# Install dependencies
npm install
# Run tests
npm test
# Build the project
npm run build
# Lint code
npm run lint
# Format code
npm run formatApache 2.0 © Paul Cruse III
Acai-TS is the TypeScript evolution of acai-js, originally developed by Syngenta. Special thanks to the original contributors for establishing the Happy Path Programming philosophy and building the foundation this library builds upon.
- 📖 Documentation: https://syngenta.github.io/acai-ts-docs/
- 💻 Examples: https://github.com/syngenta/acai-ts-docs/tree/main/examples
- 🐛 Issues: GitHub Issues
- 💬 Discussions: GitHub Discussions
Made with 💙 by developers who believe AWS Lambda development should be enjoyable.