Deploy TypeScript Lambda with CDK
The source code is available on GitHub.
It makes you have headache to deploy Lambda functions with IaC tools. The situation is worse if you want to deploy TypeScript Lambda because your CDK is written in TypeScript. You have to compile TypeScript files before deployment. Each Lambda project must be compiled seamlessly. It's the worst if you need Lambda Layers on which some of your Lambda functions depend.
Here is how to organize TypeScript Lambda source codes and how to deploy with CDK.
I will cover five types of Lambda projects.
- Lambda that depends on nothing
- Lambda that depends on AWS SDK
- Lambda that depends on NPM
- Lambda Layer
- Lambda that depends on Lambda Layer
File Structure
.
├── .gitignore
├── lambda
│ ├── individual-lambda-project
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.mts
│ │ └── tsconfig.json
│ ├── package.json
│ └── tsconfig.base.json
├── package.json
...
Whatever kind of Lambda, each Lambda project contains at least three files.
package.json: Orchestrates how the project must be built for the final Lambda code.
tsconfig.json: Resolves types in the local editor. Defines how to compile.
src/index.mts: Entrypoint of Lambda runtime. ESModules enable us to split code into many files. src will be output to build preserving its directory structure.
Beside each Lambda, we need some federation at CDK root.
package.json
{
"scripts": {
…
"build:lambda": "cd lambda && npm run build",
…
},
…
}
One TypeScript CDK project comes with a default build script, which compiles TypeScript constructs. We shall distinguish the build process of Lambda from that of constructs. The root package.json delegates the detailed process of building Lambda to lambda/package.json.
lambda/package.json
{
"scripts": {
"build": "npm-run-all build:**",
"build:independent": "cd 01-independent && npm run build",
"build:aws-dependent": "cd 02-aws-dependent && npm run build",
"build:npm-dependent": "cd 03-npm-dependent && npm run build",
"build:layer": "cd 04-layer && npm run build",
"build:layer-dependent": "cd 05-layer-dependent && npm run build"
}
}
This is a vexing file. You must add more build scripts as we add more Lambda projects. It’s not smart. But it does the job. This file also delegates the detailed build process to subordinates.
lambda/tsconfig.base.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"declaration": false,
"esModuleInterop": true,
"inlineSourceMap": false,
"inlineSources": false,
"isolatedModules": true,
"skipLibCheck": true,
"sourceMap": false,
"strict": true,
}
}
The tsconfig.json of each Lambda project extends this configuration.
.gitignore
lambda/**/build
Every Lambda project outputs a build directory. We don’t want to commit them. Let’s ignore.
Now I can show you how to compose an individual Lambda.
Independent Lambda
An independent Lambda follows a basic structure of TypeScript Lambda project.
lambda/01-independent
├── package.json
├── src
│ └── index.mts
└── tsconfig.json
lambda/01-independent/package.json
{
"scripts": {
"build": "tsc"
}
}
All you need do is tsc. The command compiles as we want it to be based on the tsconfig.json.
lambda/01-independent/tsconfig.json
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./build"
},
"include": ["src/**/*.mts"]
}
What you need is proper rootDir and outDir. You might not need include but you want it to avoid unexpected behavior. This setting compiles all TypeScript files under the src to build preserving the directory structure.
lambda/01-independent/src/index.mts
export const handler = async () => {
console.log('hello, world');
}
As simple as possible as a sample. You can import submodules not just on the editor but also during runtime after build and deployment.
AWS Dependent Lambda
You don’t have to install AWS SDK to each Lambda project. The SDK is available in the execution environment. So an AWS dependent Lambda follows a basic structure of TypeScript Lambda project.
lambda/02-aws-dependent
├── package.json
├── src
│ └── index.mts
└── tsconfig.json
The only problem is that your IDE cannot resolve types of SDK unless it's locally installed. You can install node_modules at each Lambda level. But the act is storage-consuming as many Lambda use the same Node.js package. The installation at the root level solves the issue.
# At root
npm install --save-dev @aws-sdk/client-ses
Other than that, the rest is same as the independent Lambda.
lambda/02-aws-dependent/package.json
{
"scripts": {
"build": "tsc"
}
}
lambda/02-aws-dependent/tsconfig.json
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./build",
"rootDir": "./src",
},
"include": ["src/**/*.mts"]
}
lambda/02-aws-dependent/src/index.mts
import { SESClient } from "@aws-sdk/client-ses";
export const handler = async () => {
new SESClient({});
}
NPM Dependent Lambda
The file structure of NPM dependent Lambda is same as the preceding two Lambdas.
lambda/03-npm-dependent
├── package.json
├── src
│ └── index.mts
└── tsconfig.json
The build process comes a different way. The output build will contain:
build
├── index.mjs
├── node_modules
│ └── chalk
└── package.json
lambda/03-npm-dependent/package.json
{
"scripts": {
"prebuild": "npm install",
"build": "tsc",
"postbuild": "cp package.json build/ && cp -r node_modules build/"
},
"dependencies": {
"chalk": "^5.6.2"
}
}
Unlike AWS SDK, you must install non-AWS SDK packages at each Lambda level. The process involves installation of packages before the build and copying after. The npm install creates package-lock.json. package-lock.json is not necessary during the Lambda runtime. We skip copying the file into build.
lambda/03-npm-dependent/tsconfig.json
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./build",
"rootDir": "./src",
},
"include": ["src/**/*.mts"]
}
lambda/03-npm-dependent/src/index.mts
import chalk from 'chalk';
export const handler = async () => {
console.log(chalk.blue('hello, world'));
}
Layer
Deploying Lambda Layer is tricky. There are two cases that you want to create the layer.
- Bundle NPM packages used across Lambda functions.
- Bundle your own modules used across Lambda functions.
I will cover the both cases at the same time. The project structure is:
lambda/04-layer
├── package.json
├── src
│ ├── index.mts
│ └── package.json
└── tsconfig.json
The final build directory will look:
build
└── nodejs
└── node_modules
├── @acme
│ └── layer
│ ├── index.mjs
│ └── package.json
└── chalk
lambda/04-layer/package.json
{
"scripts": {
"prebuild": "npm install",
"build": "tsc",
"postbuild": "npm-run-all postbuild:*",
"postbuild:package-json": "cp src/package.json build/nodejs/node_modules/@acme/layer/",
"postbuild:node-modules": "cp -r node_modules/. build/nodejs/node_modules/"
},
"dependencies": {
"chalk": "^5.6.2"
}
}
The build process is similar to NPM dependent Lambda but different in that:
- You must treat your own code as a package.
- Your own code and NPM packages must be copied to different paths.
- The output path must be carefully planned according to Layer paths.
It is a good practice to give your packages an organizational namespace so that they’d never conflict with the public packages.
lambda/04-layer/tsconfig.json
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./build/nodejs/node_modules/@acme/layer",
"rootDir": "./src"
},
"include": ["src/**/*.mts"]
}
The outDir value must match the path used in the postbuild:package-json script.
lambda/04-layer/src/index.mts
export function helloWorld() {
console.log('hello, world')
}
lambda/04-layer/src/package.json
{
"name": "@acme/layer",
"version": "1.0.0",
"type": "module",
"main": "./index.mjs",
"exports": "./index.mjs"
}
We must create package.json at the source level and include it in @acme/layer so that your Lambda function that depends on this layer can import.
How to plan layers is an art. The solution varies project to project. You can create a layer at organizational level like @acme that contains all your own common packages used across your Lambda functions.
Layer Dependent Lambda
We want to use a Lambda layer composed in the previous section. Since all dependencies are accessible through the layer, the dependent project is not as complicated as the layer.
lambda/05-layer-dependent
├── package.json
├── src
│ └── index.mts
└── tsconfig.json
lambda/05-layer-dependent/package.json
{
"scripts": {
"build": "esbuild \"src/**/*.mts\" --platform=node --outdir=build --out-extension:.js=.mjs --outbase=src"
}
}
Using esbuild is a hack. You can skip checking type at compilation.
lambda/05-layer-dependent/tsconfig.json
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@acme/layer": ["../04-layer/src"]
},
},
"include": ["src/**/*.mts"]
}
“The inferred value for rootDir is the longest common path of all non-declaration input files,” states the TypeScript official document.
If we try to build src by tsc with this configuration, the the end of “longest common path” will be lambda. And the JavaScript files would be compiled next to the the original TypeScript file, not to build, i.e. src/index.mjs.
If we add "rootDir": "./src", then run tsc, we would get the error:
error TS6059: File '/path/to/cdk-project/lambda/04-layer/src/index.mts' is not under 'rootDir' '/path/to/cdk-project/lambda/05-layer-dependent/src'. 'rootDir' is expected to contain all source files.
If we omit the baseUrl and paths properties, your IDE won’t be able to resolve the types of the layer.
These are reasons why we need the configuration for the IDE and the build process. The TypeScript default build system tsc disables us to build files as a Lambda code. And that’s why we need esbuild which assumes that the type checking is done either with IDE or in one of CI/CD workflows.
lambda/05-layer-dependent/src/index.mts
import { helloWorld } from '@acme/layer';
import chalk from 'chalk';
export const handler = async () => {
helloWorld();
console.log(chalk.blue('hello, world'));
}
With your TypeScript config ready, the coding Lambda is as simple as that.
CDK Stack
The last thing you need to do is to create NodejsFunction instances for every Lambda project.
lib/deploy-typescript-lambda-with-cdk-stack.ts
import { Stack } from 'aws-cdk-lib';
import { Architecture, Code, LayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs';
import type { StackProps } from 'aws-cdk-lib';
export class DeployTypescriptLambdaWithCdkStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// 01
new NodejsFunction(this, 'Independent', {
functionName: 'independent',
code: Code.fromAsset('./lambda/01-independent/build'),
handler: 'index.handler',
architecture: Architecture.ARM_64,
runtime: Runtime.NODEJS_24_X,
});
// 02
new NodejsFunction(this, 'AwsDependent', {
functionName: 'aws-dependent',
code: Code.fromAsset('./lambda/02-aws-dependent/build'),
handler: 'index.handler',
architecture: Architecture.ARM_64,
runtime: Runtime.NODEJS_24_X,
});
// 03
new NodejsFunction(this, 'NpmDependent', {
functionName: 'npm-dependent',
code: Code.fromAsset('./lambda/03-npm-dependent/build'),
handler: 'index.handler',
architecture: Architecture.ARM_64,
runtime: Runtime.NODEJS_24_X,
});
// 04
const layer = new LayerVersion(this, 'Layer', {
layerVersionName: 'layer',
compatibleRuntimes: [Runtime.NODEJS_24_X],
compatibleArchitectures: [Architecture.ARM_64],
code: Code.fromAsset('./lambda/04-layer/build'),
});
// 05
new NodejsFunction(this, 'LayerDependent', {
functionName: 'layer-dependent',
code: Code.fromAsset('./lambda/05-layer-dependent/build'),
handler: 'index.handler',
architecture: Architecture.ARM_64,
runtime: Runtime.NODEJS_24_X,
layers: [layer],
});
}
}
Clean up
Time and time again, you run build:lambda locally to see how your final Lambda codes would look like. The mistakes of past commands during development can compound under the build directory. The need to clean build directories emerged.
package.json
{
"scripts": {
…
"clean:lambda": "find lambda -path '*/node_modules/*' -prune -o -type d -name \"build\" -exec rm -rf {} +",
…
},
…
}