AWS Lambda with TypeScript: A Complete Development Guide

AWS Lambda represents the foundation of serverless computing, allowing you to run code without managing servers. When combined with TypeScript, Lambda functions become more maintainable, reliable, and developer-friendly. This guide will walk you through building production-ready Lambda functions with TypeScript, covering everything from setup to deployment and best practices.

Why TypeScript for Lambda?

TypeScript brings several compelling advantages to Lambda development. Type safety catches errors at compile time rather than runtime, preventing costly production issues. Enhanced developer experience includes intelligent autocomplete, refactoring support, and better tooling integration. Better maintainability comes from explicit interfaces and self-documenting code that’s easier for teams to understand and modify.

Most importantly, TypeScript helps you leverage AWS service types effectively, providing intellisense for event structures, API responses, and service configurations.

Architecture Overview

Let’s examine the typical architecture we’ll be building throughout this guide:

This architecture demonstrates how Lambda functions serve as the central compute layer, processing requests from various sources while maintaining proper separation of concerns.

Prerequisites

Before building your first TypeScript Lambda function, ensure you have the following tools installed:

  • Node.js (v18.x or later) - The runtime environment
  • AWS CLI (v2.x or later) - For AWS service interaction
  • SAM CLI (v1.x or later) - For local development and deployment
  • TypeScript (v4.x or later) - For type-safe development
  • An AWS account with appropriate Lambda and IAM permissions

Project Setup and Development Workflow

Setting up a TypeScript Lambda project involves several key components that work together to create a robust development environment. Let’s establish a project structure that supports both local development and production deployment.

1. Initialize Your Project

Start by creating a new TypeScript project with the necessary configuration:

mkdir my-lambda-function && cd my-lambda-function
npm init -y
npm install --save @aws-sdk/client-dynamodb @types/aws-lambda
npm install --save-dev typescript @types/node esbuild jest @types/jest ts-jest

The project structure should follow these conventions for maintainability:

my-lambda-function/
├── src/
│   ├── handlers/
│   │   └── api.ts
│   ├── types/
│   │   └── index.ts
│   └── utils/
│       └── response.ts
├── tests/
│   └── handlers/
│       └── api.test.ts
├── events/
│   └── api-gateway-event.json
├── template.yaml
├── tsconfig.json
└── package.json

2. Configure TypeScript for Lambda

Create a tsconfig.json optimized for AWS Lambda’s Node.js 18.x runtime:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}

This configuration ensures compatibility with Lambda’s runtime environment, strict type checking for better code quality, and proper module resolution for AWS SDK imports.

3. Building Type-Safe Lambda Handlers

The heart of any Lambda function is the handler - the entry point that processes events and returns responses. TypeScript enables us to create handlers that are both type-safe and self-documenting.

First, let’s create reusable types and utilities:

// src/types/index.ts
export interface ApiResponse<T = any> {
  statusCode: number;
  headers?: Record<string, string>;
  body: string;
}

export interface ErrorResponse {
  message: string;
  timestamp: string;
  requestId?: string;
}

// src/utils/response.ts
import { ApiResponse, ErrorResponse } from '../types';

export const createSuccessResponse = <T>(data: T, statusCode = 200): ApiResponse<T> => ({
  statusCode,
  headers: {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  },
  body: JSON.stringify(data),
});

export const createErrorResponse = (message: string, statusCode = 500, requestId?: string): ApiResponse<ErrorResponse> => ({
  statusCode,
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    message,
    timestamp: new Date().toISOString(),
    requestId,
  }),
});

Now, let’s create a production-ready handler that demonstrates best practices:

// src/handlers/api.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { createSuccessResponse, createErrorResponse } from '../utils/response';

// Initialize AWS clients outside the handler for connection reuse
const dynamoClient = new DynamoDBClient({ region: process.env.AWS_REGION });

interface UserData {
  id: string;
  name: string;
  email: string;
  createdAt: string;
}

export const handler = async (
  event: APIGatewayProxyEvent,
  context: Context
): Promise<APIGatewayProxyResult> => {
  // Enable response streaming for better performance
  context.callbackWaitsForEmptyEventLoop = false;

  try {
    const { httpMethod, pathParameters, body } = event;
    const { requestId } = context;

    // Route handling based on HTTP method
    switch (httpMethod) {
      case 'GET':
        return await handleGetUser(pathParameters?.id, requestId);
      case 'POST':
        return await handleCreateUser(body, requestId);
      default:
        return createErrorResponse(`Method ${httpMethod} not allowed`, 405, requestId);
    }
  } catch (error) {
    console.error('Handler error:', error);
    return createErrorResponse(
      'Internal server error',
      500,
      context.awsRequestId
    );
  }
};

async function handleGetUser(userId: string | undefined, requestId: string): Promise<APIGatewayProxyResult> {
  if (!userId) {
    return createErrorResponse('User ID is required', 400, requestId);
  }

  // Simulate database operation
  const userData: UserData = {
    id: userId,
    name: 'John Doe',
    email: 'john@example.com',
    createdAt: new Date().toISOString(),
  };

  return createSuccessResponse(userData);
}

async function handleCreateUser(body: string | null, requestId: string): Promise<APIGatewayProxyResult> {
  if (!body) {
    return createErrorResponse('Request body is required', 400, requestId);
  }

  try {
    const userData = JSON.parse(body) as Partial<UserData>;
    
    // Validate required fields
    if (!userData.name || !userData.email) {
      return createErrorResponse('Name and email are required', 400, requestId);
    }

    const newUser: UserData = {
      id: crypto.randomUUID(),
      name: userData.name,
      email: userData.email,
      createdAt: new Date().toISOString(),
    };

    return createSuccessResponse(newUser, 201);
  } catch (error) {
    return createErrorResponse('Invalid JSON in request body', 400, requestId);
  }
}

This implementation demonstrates several key patterns:

  • Client reuse: AWS service clients are initialized outside the handler to leverage connection pooling
  • Type safety: All parameters and return values are properly typed
  • Error handling: Comprehensive error handling with appropriate HTTP status codes
  • Validation: Input validation prevents processing invalid data
  • Performance optimization: Context configuration optimizes cold start behavior

4. SAM Template Configuration

AWS SAM simplifies Lambda deployment by providing higher-level constructs that automatically configure related resources. Create a template.yaml file that defines your serverless application:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Parameters:
  Environment:
    Type: String
    Default: dev
    AllowedValues: [dev, staging, prod]

Globals:
  Function:
    Runtime: nodejs18.x
    Timeout: 30
    MemorySize: 256
    Tracing: Active
    Environment:
      Variables:
        NODE_OPTIONS: --enable-source-maps
        ENVIRONMENT: !Ref Environment

Resources:
  UserApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub 'user-api-${Environment}'
      CodeUri: dist/
      Handler: handlers/api.handler
      Events:
        GetUser:
          Type: Api
          Properties:
            Path: /users/{id}
            Method: GET
        CreateUser:
          Type: Api
          Properties:
            Path: /users
            Method: POST
      Policies:
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - logs:CreateLogGroup
                - logs:CreateLogStream
                - logs:PutLogEvents
              Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*'

Outputs:
  ApiUrl:
    Description: "API Gateway endpoint URL"
    Value: !Sub 'https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod'
    Export:
      Name: !Sub '${AWS::StackName}-ApiUrl'

This template demonstrates several important practices:

  • Environment parameterization enables the same template to work across multiple environments
  • Global configurations reduce duplication by setting common function properties
  • Least privilege IAM policies grant only the permissions needed
  • Outputs make important values available to other stacks or applications

5. Build and Deployment Optimization

Modern Lambda deployment requires optimized bundling to reduce cold start times and deployment package sizes. Create an optimized build process:

// build.js
const { build } = require('esbuild');
const { nodeExternalsPlugin } = require('esbuild-node-externals');

async function buildFunction() {
  await build({
    entryPoints: ['src/handlers/api.ts'],
    bundle: true,
    minify: true,
    sourcemap: true,
    platform: 'node',
    target: 'node18',
    outdir: 'dist',
    plugins: [nodeExternalsPlugin()],
    external: ['@aws-sdk/*'], // AWS SDK is available in Lambda runtime
  });
  
  console.log('✅ Build completed successfully');
}

buildFunction().catch(() => process.exit(1));

Update your package.json with optimized scripts:

{
  "scripts": {
    "build": "node build.js",
    "test": "jest",
    "test:watch": "jest --watch",
    "deploy:dev": "npm run build && sam deploy --parameter-overrides Environment=dev",
    "deploy:prod": "npm run build && sam deploy --parameter-overrides Environment=prod",
    "local": "npm run build && sam local start-api",
    "invoke": "npm run build && sam local invoke UserApiFunction"
  }
}

This build process provides several optimizations:

  • Tree shaking removes unused code to reduce bundle size
  • Minification further reduces the deployment package
  • Source maps enable proper debugging in CloudWatch
  • External dependencies exclude AWS SDK which is provided by the Lambda runtime

Testing Your Lambda Functions

Comprehensive testing is crucial for maintaining Lambda function reliability. TypeScript enables sophisticated testing strategies that catch issues before deployment.

Unit Testing Setup

Configure Jest for TypeScript testing:

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/tests'],
  testMatch: ['**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
  ],
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov'],
};

Create comprehensive tests that verify both success and error scenarios:

// tests/handlers/api.test.ts
import { APIGatewayProxyEvent, Context } from 'aws-lambda';
import { handler } from '../../src/handlers/api';

// Mock AWS SDK clients
jest.mock('@aws-sdk/client-dynamodb');

describe('API Handler', () => {
  const mockContext: Partial<Context> = {
    awsRequestId: 'test-request-id',
    callbackWaitsForEmptyEventLoop: false,
  };

  describe('GET /users/{id}', () => {
    it('returns user data for valid ID', async () => {
      const event: Partial<APIGatewayProxyEvent> = {
        httpMethod: 'GET',
        pathParameters: { id: 'user-123' },
        requestContext: { requestId: 'test-request' } as any,
      };

      const response = await handler(event as APIGatewayProxyEvent, mockContext as Context);

      expect(response.statusCode).toBe(200);
      const body = JSON.parse(response.body);
      expect(body.id).toBe('user-123');
      expect(body.name).toBeDefined();
      expect(body.email).toBeDefined();
    });

    it('returns 400 for missing user ID', async () => {
      const event: Partial<APIGatewayProxyEvent> = {
        httpMethod: 'GET',
        pathParameters: null,
        requestContext: { requestId: 'test-request' } as any,
      };

      const response = await handler(event as APIGatewayProxyEvent, mockContext as Context);

      expect(response.statusCode).toBe(400);
      const body = JSON.parse(response.body);
      expect(body.message).toBe('User ID is required');
    });
  });

  describe('POST /users', () => {
    it('creates user with valid data', async () => {
      const userData = { name: 'Jane Doe', email: 'jane@example.com' };
      const event: Partial<APIGatewayProxyEvent> = {
        httpMethod: 'POST',
        body: JSON.stringify(userData),
        requestContext: { requestId: 'test-request' } as any,
      };

      const response = await handler(event as APIGatewayProxyEvent, mockContext as Context);

      expect(response.statusCode).toBe(201);
      const body = JSON.parse(response.body);
      expect(body.name).toBe(userData.name);
      expect(body.email).toBe(userData.email);
      expect(body.id).toBeDefined();
    });

    it('returns 400 for invalid JSON', async () => {
      const event: Partial<APIGatewayProxyEvent> = {
        httpMethod: 'POST',
        body: 'invalid-json',
        requestContext: { requestId: 'test-request' } as any,
      };

      const response = await handler(event as APIGatewayProxyEvent, mockContext as Context);

      expect(response.statusCode).toBe(400);
      const body = JSON.parse(response.body);
      expect(body.message).toBe('Invalid JSON in request body');
    });
  });
});

Local Development and Testing

SAM CLI provides excellent local development capabilities:

# Start API Gateway locally
sam local start-api --port 3000

# Test specific function with custom event
sam local invoke UserApiFunction --event events/api-gateway-event.json

# Generate sample events
sam local generate-event apigateway aws-proxy > events/api-gateway-event.json

Create test events for different scenarios:

// events/create-user-event.json
{
  "httpMethod": "POST",
  "path": "/users",
  "headers": {
    "Content-Type": "application/json"
  },
  "body": "{\"name\":\"John Doe\",\"email\":\"john@example.com\"}",
  "requestContext": {
    "requestId": "test-123"
  }
}

This testing approach ensures comprehensive coverage of both success and failure paths, realistic scenarios through event-driven testing, and fast feedback through local development capabilities.

Production Best Practices

Deploying Lambda functions to production requires careful consideration of security, monitoring, and operational excellence. Here are the essential practices for production-ready deployments.

Environment Configuration and Security

Use AWS Systems Manager Parameter Store for secure configuration management:

# template.yaml additions
Resources:
  UserApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Environment:
        Variables:
          DB_TABLE_NAME: !Ref UsersTable
          PARAMETER_STORE_PREFIX: !Sub '/myapp/${Environment}'
      Policies:
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ssm:GetParameter
                - ssm:GetParameters
              Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/myapp/${Environment}/*'

Monitoring and Observability

Enable comprehensive monitoring with X-Ray tracing and CloudWatch insights:

Resources:
  UserApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Tracing: Active
      DeadLetterQueue:
        Type: SQS
        TargetArn: !GetAtt DeadLetterQueue.Arn
      Events:
        ErrorAlarm:
          Type: CloudWatchEvent
          Properties:
            Pattern:
              source: ['aws.lambda']
              detail-type: ['Lambda Function Invocation Result - Failure']

Security and Access Control

Implement least privilege access with specific IAM policies:

Resources:
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess
      Policies:
        - PolicyName: DynamoDBAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                Resource: !GetAtt UsersTable.Arn

Key security considerations include:

  • Principle of least privilege: Grant only the minimum permissions required
  • Environment separation: Use separate IAM roles and policies for each environment
  • Secrets management: Never hardcode sensitive values in your code
  • Regular auditing: Periodically review and update permissions

Conclusion

TypeScript transforms Lambda development from a loosely-typed, error-prone process into a robust, maintainable development experience. The combination of compile-time type checking, excellent tooling support, and AWS service integration creates a powerful foundation for serverless applications.

Key takeaways from this guide include:

  • Type safety dramatically reduces runtime errors and improves code quality
  • Proper project structure enables scalability and team collaboration
  • Optimized build processes improve performance and reduce costs
  • Comprehensive testing ensures reliability across deployments
  • Production best practices provide security and operational excellence

The patterns demonstrated here scale from simple functions to complex serverless architectures. By establishing these foundations early, you’ll be well-positioned to build robust, maintainable serverless applications that can evolve with your business needs.

In our next post, we’ll explore AWS Step Functions with TypeScript, learning how to orchestrate complex workflows and build resilient distributed systems.

Comments