Building Type-Safe APIs with AWS API Gateway and TypeScript
Building robust APIs requires more than just handling HTTP requests—it demands type safety, proper validation, and clear documentation. In this post, we’ll explore how to build production-ready APIs using AWS API Gateway with TypeScript, ensuring type safety from request to response while maintaining excellent developer experience.
Why Type-Safe APIs Matter
Type safety in API development provides several critical advantages:
- Compile-time Error Detection: Catch issues before deployment rather than in production
- Enhanced Developer Experience: IntelliSense, autocomplete, and refactoring support
- Self-Documenting Code: Types serve as living documentation that stays current
- Team Collaboration: Clear contracts between frontend and backend developers
- Reduced Integration Issues: Consistent interfaces prevent miscommunication
Project Structure Overview
Prerequisites
Ensure you have these essentials:
- AWS SDK v3 packages for API Gateway and DynamoDB
- TypeScript environment with strict type checking
- AWS SAM CLI for local development and deployment
- Jest for testing framework
Type Definitions and Contracts
Start with clear, comprehensive type definitions that serve as your API contract:
// src/types/api.ts
export interface CreateUserRequest {
name: string;
email: string;
department?: string;
}
export interface UserResponse {
id: string;
name: string;
email: string;
department?: string;
createdAt: string;
updatedAt: string;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
department?: string;
}
export interface ApiResponse<T = any> {
statusCode: number;
headers: Record<string, string>;
body: string;
}
export interface ErrorResponse {
error: string;
message: string;
statusCode: number;
}
These interfaces establish clear contracts for your API endpoints, ensuring consistency across your application and providing excellent TypeScript support.
Request Validation with Type Guards
Implement robust validation using TypeScript type guards for runtime type safety:
// src/utils/validation.ts
import { CreateUserRequest, UpdateUserRequest } from '../types/api';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const validateCreateUser = (body: unknown): body is CreateUserRequest => {
if (!body || typeof body !== 'object') return false;
const req = body as Record<string, unknown>;
return !!(
req.name && typeof req.name === 'string' && req.name.trim() &&
req.email && typeof req.email === 'string' && EMAIL_REGEX.test(req.email) &&
(req.department === undefined || typeof req.department === 'string')
);
};
export const validateUpdateUser = (body: unknown): body is UpdateUserRequest => {
if (!body || typeof body !== 'object') return false;
const req = body as Record<string, unknown>;
const hasValidName = !req.name || (typeof req.name === 'string' && req.name.trim());
const hasValidEmail = !req.email || (typeof req.email === 'string' && EMAIL_REGEX.test(req.email));
const hasValidDept = req.department === undefined || typeof req.department === 'string';
return hasValidName && hasValidEmail && hasValidDept &&
(req.name !== undefined || req.email !== undefined || req.department !== undefined);
};
export class ValidationError extends Error {
constructor(message: string, public field?: string) {
super(message);
this.name = 'ValidationError';
}
}
Response Utilities
Create consistent response formatting:
// src/utils/response.ts
import { ApiResponse, ErrorResponse } from '../types/api';
const CORS_HEADERS = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
} as const;
export const createResponse = <T>(statusCode: number, body: T): ApiResponse<T> => ({
statusCode,
headers: CORS_HEADERS,
body: JSON.stringify(body)
});
export const createErrorResponse = (
statusCode: number,
error: string,
message: string
): ApiResponse<ErrorResponse> =>
createResponse(statusCode, { error, message, statusCode });
Lambda Handler Implementation
Build type-safe Lambda functions with comprehensive error handling:
// src/handlers/users.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, GetCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import { v4 as uuidv4 } from 'uuid';
import { UserResponse, CreateUserRequest, UpdateUserRequest } from '../types/api';
import { validateCreateUser, validateUpdateUser, ValidationError } from '../utils/validation';
import { createResponse, createErrorResponse } from '../utils/response';
const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE_NAME = process.env.USERS_TABLE_NAME!;
export const createUser = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
if (!event.body) {
return createErrorResponse(400, 'Bad Request', 'Request body required');
}
const body = JSON.parse(event.body);
if (!validateCreateUser(body)) {
return createErrorResponse(400, 'Validation Error', 'Invalid request format');
}
const userId = uuidv4();
const timestamp = new Date().toISOString();
const user: UserResponse = {
id: userId,
name: body.name.trim(),
email: body.email.toLowerCase(),
department: body.department?.trim(),
createdAt: timestamp,
updatedAt: timestamp
};
await docClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: user,
ConditionExpression: 'attribute_not_exists(id)'
}));
return createResponse(201, user);
} catch (error) {
if (error instanceof ValidationError) {
return createErrorResponse(400, 'Validation Error', error.message);
}
if (error.name === 'ConditionalCheckFailedException') {
return createErrorResponse(409, 'Conflict', 'User already exists');
}
console.error('Create user error:', error);
return createErrorResponse(500, 'Internal Server Error', 'Failed to create user');
}
};
export const getUser = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
const userId = event.pathParameters?.id;
if (!userId) {
return createErrorResponse(400, 'Bad Request', 'User ID required');
}
const { Item } = await docClient.send(new GetCommand({
TableName: TABLE_NAME,
Key: { id: userId }
}));
if (!Item) {
return createErrorResponse(404, 'Not Found', 'User not found');
}
return createResponse(200, Item as UserResponse);
} catch (error) {
console.error('Get user error:', error);
return createErrorResponse(500, 'Internal Server Error', 'Failed to retrieve user');
}
};
export const updateUser = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
const userId = event.pathParameters?.id;
if (!userId || !event.body) {
return createErrorResponse(400, 'Bad Request', 'User ID and body required');
}
const body = JSON.parse(event.body);
if (!validateUpdateUser(body)) {
return createErrorResponse(400, 'Validation Error', 'Invalid update format');
}
const updateExpression: string[] = [];
const attributeNames: Record<string, string> = {};
const attributeValues: Record<string, any> = {};
if (body.name) {
updateExpression.push('#name = :name');
attributeNames['#name'] = 'name';
attributeValues[':name'] = body.name.trim();
}
if (body.email) {
updateExpression.push('#email = :email');
attributeNames['#email'] = 'email';
attributeValues[':email'] = body.email.toLowerCase();
}
if (body.department !== undefined) {
updateExpression.push('#dept = :dept');
attributeNames['#dept'] = 'department';
attributeValues[':dept'] = body.department.trim();
}
updateExpression.push('#updated = :updated');
attributeNames['#updated'] = 'updatedAt';
attributeValues[':updated'] = new Date().toISOString();
const { Attributes } = await docClient.send(new UpdateCommand({
TableName: TABLE_NAME,
Key: { id: userId },
UpdateExpression: `SET ${updateExpression.join(', ')}`,
ExpressionAttributeNames: attributeNames,
ExpressionAttributeValues: attributeValues,
ConditionExpression: 'attribute_exists(id)',
ReturnValues: 'ALL_NEW'
}));
return createResponse(200, Attributes as UserResponse);
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
return createErrorResponse(404, 'Not Found', 'User not found');
}
console.error('Update user error:', error);
return createErrorResponse(500, 'Internal Server Error', 'Failed to update user');
}
};
export const deleteUser = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
const userId = event.pathParameters?.id;
if (!userId) {
return createErrorResponse(400, 'Bad Request', 'User ID required');
}
await docClient.send(new DeleteCommand({
TableName: TABLE_NAME,
Key: { id: userId },
ConditionExpression: 'attribute_exists(id)'
}));
return createResponse(204, {});
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
return createErrorResponse(404, 'Not Found', 'User not found');
}
console.error('Delete user error:', error);
return createErrorResponse(500, 'Internal Server Error', 'Failed to delete user');
}
};
Infrastructure as Code with SAM
Define your API Gateway and Lambda infrastructure using a concise SAM template:
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Parameters:
Stage:
Type: String
Default: dev
AllowedValues: [dev, staging, prod]
Globals:
Function:
Runtime: nodejs18.x
CodeUri: dist/
Timeout: 30
Environment:
Variables:
USERS_TABLE_NAME: !Ref UsersTable
Resources:
# API Gateway with OpenAPI specification
UsersApi:
Type: AWS::Serverless::Api
Properties:
StageName: !Ref Stage
Cors:
AllowMethods: "'*'"
AllowHeaders: "'*'"
AllowOrigin: "'*'"
DefinitionBody:
openapi: 3.0.1
info:
title: !Sub "${AWS::StackName}-users-api"
version: 1.0.0
paths:
/users:
post:
summary: Create user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
x-amazon-apigateway-integration:
type: aws_proxy
httpMethod: POST
uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${CreateUserFunction.Arn}/invocations"
/users/{id}:
get:
summary: Get user
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
x-amazon-apigateway-integration:
type: aws_proxy
httpMethod: POST
uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GetUserFunction.Arn}/invocations"
components:
schemas:
CreateUserRequest:
type: object
required: [name, email]
properties:
name: { type: string, minLength: 1 }
email: { type: string, format: email }
department: { type: string }
UserResponse:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
department: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
# Lambda Functions
CreateUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/users.createUser
Events:
CreateUser:
Type: Api
Properties:
RestApiId: !Ref UsersApi
Path: /users
Method: POST
Policies:
- DynamoDBWritePolicy:
TableName: !Ref UsersTable
GetUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/users.getUser
Events:
GetUser:
Type: Api
Properties:
RestApiId: !Ref UsersApi
Path: /users/{id}
Method: GET
Policies:
- DynamoDBReadPolicy:
TableName: !Ref UsersTable
UpdateUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/users.updateUser
Events:
UpdateUser:
Type: Api
Properties:
RestApiId: !Ref UsersApi
Path: /users/{id}
Method: PUT
Policies:
- DynamoDBWritePolicy:
TableName: !Ref UsersTable
DeleteUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/users.deleteUser
Events:
DeleteUser:
Type: Api
Properties:
RestApiId: !Ref UsersApi
Path: /users/{id}
Method: DELETE
Policies:
- DynamoDBWritePolicy:
TableName: !Ref UsersTable
# DynamoDB Table
UsersTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
Outputs:
ApiUrl:
Description: API Gateway endpoint URL
Value: !Sub "https://${UsersApi}.execute-api.${AWS::Region}.amazonaws.com/${Stage}"
Export:
Name: !Sub "${AWS::StackName}-api-url"
TableName:
Description: DynamoDB table name
Value: !Ref UsersTable
Export:
Name: !Sub "${AWS::StackName}-table-name"
Advanced Patterns and Best Practices
Request/Response Middleware
Implement middleware for common concerns:
// src/utils/middleware.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { createErrorResponse } from './response';
type HandlerFunction = (event: APIGatewayProxyEvent) => Promise<APIGatewayProxyResult>;
export const withErrorHandling = (handler: HandlerFunction): HandlerFunction =>
async (event: APIGatewayProxyEvent) => {
try {
return await handler(event);
} catch (error) {
console.error('Unhandled error:', error);
return createErrorResponse(500, 'Internal Server Error', 'An unexpected error occurred');
}
};
export const withRequestLogging = (handler: HandlerFunction): HandlerFunction =>
async (event: APIGatewayProxyEvent) => {
const requestId = event.requestContext.requestId;
console.log(`[${requestId}] ${event.httpMethod} ${event.path}`);
const startTime = Date.now();
const result = await handler(event);
const duration = Date.now() - startTime;
console.log(`[${requestId}] Response: ${result.statusCode} (${duration}ms)`);
return result;
};
export const compose = (...middlewares: any[]) => (handler: HandlerFunction) =>
middlewares.reduceRight((acc, middleware) => middleware(acc), handler);
Schema Validation with JSON Schema
For complex validation scenarios, use JSON Schema:
// src/utils/schema-validation.ts
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const createUserSchema = {
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
email: { type: 'string', format: 'email', maxLength: 255 },
department: { type: 'string', maxLength: 100 }
},
additionalProperties: false
};
export const validateCreateUserSchema = ajv.compile(createUserSchema);
export const getValidationErrors = (validate: any): string[] => {
return validate.errors?.map((error: any) =>
`${error.instancePath || 'root'} ${error.message}`
) || [];
};
Comprehensive Testing Strategy
Implement thorough testing for type-safe APIs:
// tests/api.test.ts
import { APIGatewayProxyEvent } from 'aws-lambda';
import { createUser, getUser, updateUser, deleteUser } from '../src/handlers/users';
import { CreateUserRequest, UpdateUserRequest } from '../src/types/api';
// Mock AWS SDK
jest.mock('@aws-sdk/lib-dynamodb', () => ({
DynamoDBDocumentClient: {
from: jest.fn(() => ({ send: jest.fn() }))
},
PutCommand: jest.fn(),
GetCommand: jest.fn(),
UpdateCommand: jest.fn(),
DeleteCommand: jest.fn()
}));
const createMockEvent = (
body?: any,
pathParameters?: Record<string, string>
): APIGatewayProxyEvent => ({
body: body ? JSON.stringify(body) : null,
pathParameters,
headers: {},
multiValueHeaders: {},
httpMethod: 'POST',
isBase64Encoded: false,
path: '/users',
queryStringParameters: null,
multiValueQueryStringParameters: null,
stageVariables: null,
requestContext: {
requestId: 'test-request-id',
// ... other required properties
} as any,
resource: ''
});
describe('Users API Handlers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('createUser', () => {
it('creates user with valid data', async () => {
const validUser: CreateUserRequest = {
name: 'John Doe',
email: 'john@example.com',
department: 'Engineering'
};
const event = createMockEvent(validUser);
const result = await createUser(event);
expect(result.statusCode).toBe(201);
const responseBody = JSON.parse(result.body);
expect(responseBody.name).toBe('John Doe');
expect(responseBody.email).toBe('john@example.com');
expect(responseBody.id).toBeDefined();
});
it('rejects invalid email format', async () => {
const invalidUser = {
name: 'John Doe',
email: 'invalid-email',
department: 'Engineering'
};
const event = createMockEvent(invalidUser);
const result = await createUser(event);
expect(result.statusCode).toBe(400);
const responseBody = JSON.parse(result.body);
expect(responseBody.error).toBe('Validation Error');
});
it('handles missing request body', async () => {
const event = createMockEvent();
const result = await createUser(event);
expect(result.statusCode).toBe(400);
const responseBody = JSON.parse(result.body);
expect(responseBody.message).toBe('Request body required');
});
});
describe('updateUser', () => {
it('updates user with partial data', async () => {
const updateData: UpdateUserRequest = {
name: 'Jane Doe',
department: 'Product'
};
const event = createMockEvent(updateData, { id: 'user-123' });
// Mock successful DynamoDB response would go here
const result = await updateUser(event);
expect(result.statusCode).toBeDefined();
});
it('rejects empty update request', async () => {
const emptyUpdate = {};
const event = createMockEvent(emptyUpdate, { id: 'user-123' });
const result = await updateUser(event);
expect(result.statusCode).toBe(400);
});
});
});
Local Development Environment
Set up efficient local development with hot reloading:
{
"name": "aws-api-gateway-typescript",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src/**/*.ts --fix",
"local:api": "sam local start-api --parameter-overrides Stage=local --docker-network lambda-local",
"local:dynamodb": "docker run -p 8000:8000 amazon/dynamodb-local",
"deploy:dev": "sam build && sam deploy --parameter-overrides Stage=dev --config-env dev",
"deploy:prod": "sam build && sam deploy --parameter-overrides Stage=prod --config-env prod",
"logs:tail": "sam logs --name CreateUserFunction --stack-name users-api-dev --tail"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.300.0",
"@aws-sdk/lib-dynamodb": "^3.300.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.114",
"@types/jest": "^29.5.0",
"@types/node": "^18.15.0",
"@types/uuid": "^9.0.1",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.0",
"eslint": "^8.37.0",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"collectCoverageFrom": [
"src/**/*.ts",
"!src/**/*.d.ts"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
Security and Monitoring Best Practices
Request Rate Limiting and Throttling
# In your SAM template
UsersApi:
Type: AWS::Serverless::Api
Properties:
ThrottleConfig:
BurstLimit: 1000
RateLimit: 500
RequestValidatorId: !Ref RequestValidator
RequestValidator:
Type: AWS::ApiGateway::RequestValidator
Properties:
RestApiId: !Ref UsersApi
ValidateRequestBody: true
ValidateRequestParameters: true
API Key Management
// src/utils/auth.ts
import { APIGatewayProxyEvent } from 'aws-lambda';
import { createErrorResponse } from './response';
export const validateApiKey = (event: APIGatewayProxyEvent) => {
const apiKey = event.headers['x-api-key'] || event.headers['X-API-Key'];
if (!apiKey) {
return createErrorResponse(401, 'Unauthorized', 'API key required');
}
// In production, validate against AWS API Gateway or external service
if (apiKey !== process.env.EXPECTED_API_KEY) {
return createErrorResponse(403, 'Forbidden', 'Invalid API key');
}
return null; // Valid key
};
CloudWatch Integration
// src/utils/metrics.ts
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
const cloudWatch = new CloudWatchClient({});
export const recordMetric = async (metricName: string, value: number, unit: string = 'Count') => {
try {
await cloudWatch.send(new PutMetricDataCommand({
Namespace: 'UsersAPI',
MetricData: [{
MetricName: metricName,
Value: value,
Unit: unit,
Timestamp: new Date()
}]
}));
} catch (error) {
console.error('Failed to record metric:', error);
}
};
// Usage in handlers
export const createUserWithMetrics = async (event: APIGatewayProxyEvent) => {
const startTime = Date.now();
try {
const result = await createUser(event);
await recordMetric('UserCreated', 1);
await recordMetric('CreateUserLatency', Date.now() - startTime, 'Milliseconds');
return result;
} catch (error) {
await recordMetric('CreateUserError', 1);
throw error;
}
};
Deployment and Operations
Multi-Stage Deployment
# samconfig.toml
version = 0.1
[default]
[default.global.parameters]
stack_name = "users-api"
[default.build.parameters]
cached = true
parallel = true
[default.deploy.parameters]
capabilities = "CAPABILITY_IAM"
confirm_changeset = true
resolve_s3 = true
[dev]
[dev.deploy.parameters]
stack_name = "users-api-dev"
s3_prefix = "users-api-dev"
region = "us-east-1"
parameter_overrides = "Stage=dev"
[prod]
[prod.deploy.parameters]
stack_name = "users-api-prod"
s3_prefix = "users-api-prod"
region = "us-east-1"
parameter_overrides = "Stage=prod"
Performance Optimization
Response Caching
// src/utils/cache.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
export const withCaching = (handler: Function, ttlSeconds: number = 300) =>
async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const result = await handler(event);
if (result.statusCode === 200) {
result.headers = {
...result.headers,
'Cache-Control': `max-age=${ttlSeconds}`,
'ETag': `"${Buffer.from(result.body).toString('base64').slice(0, 32)}"`
};
}
return result;
};
// Usage
export const getCachedUser = withCaching(getUser, 600); // 10 minutes
Connection Reuse
// src/utils/db.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
// Reuse connection across Lambda invocations
let docClient: DynamoDBDocumentClient;
export const getDocumentClient = (): DynamoDBDocumentClient => {
if (!docClient) {
const dynamoClient = new DynamoDBClient({
maxAttempts: 3,
retryMode: 'adaptive'
});
docClient = DynamoDBDocumentClient.from(dynamoClient, {
marshallOptions: {
convertEmptyValues: false,
removeUndefinedValues: true
}
});
}
return docClient;
};
Conclusion
Building type-safe APIs with AWS API Gateway and TypeScript creates a robust foundation for scalable applications. This approach provides:
- Compile-time Safety: Catch errors before deployment
- Developer Experience: Enhanced tooling and documentation
- Maintainability: Clear contracts and consistent patterns
- Production Readiness: Comprehensive error handling and monitoring
The patterns demonstrated here extend to support authentication, complex validation, caching, and advanced API features. Key takeaways include:
- Start with Types: Define clear interfaces that serve as contracts
- Validate Early: Use type guards and schema validation at API boundaries
- Handle Errors Gracefully: Provide consistent error responses with proper HTTP codes
- Test Thoroughly: Implement comprehensive testing at unit and integration levels
- Monitor Actively: Use CloudWatch metrics and logging for production insights
In our next post, we’ll explore DynamoDB with TypeScript, building upon these API patterns to create type-safe data access layers that integrate seamlessly with your API Gateway endpoints.
Comments