Tutorial on AWS SAM with TypeScript and Jest Support

The AWS Serverless Application Model (SAM) is a robust tool for creating serverless applications, and it often goes hand in hand with JavaScript. 62% of developers in medium-sized and large companies opt for JavaScript when writing code for serverless environments. However, TypeScript is rapidly gaining traction and has surpassed JavaScript as the third among developers.

While finding JavaScript boilerplate code is relatively easy, initiating AWS SAM projects using TypeScript can be more intricate. This tutorial provides a step-by-step guide on building an AWS SAM project with TypeScript from scratch, explaining how the different components work together. A basic understanding of AWS Lambda functions is all you need to follow along.

Building Our AWS SAM TypeScript Project from the Ground Up

Our serverless application relies on several components. We’ll begin by configuring the AWS environment, setting up our npm package, and integrating Webpack functionality. Once that’s done, we can create, invoke, and test our Lambda function to see our application in action.

Getting Your Environment Ready

Setting up the AWS environment involves installing the following:

  1. AWS CLI
  2. AWS SAM CLI
  3. Node.js and npm

Remember that this tutorial requires installing Docker during step 2 to enable local testing of our application.

Setting Up an Empty Project

Let’s create a project directory named aws-sam-typescript-boilerplate and a subfolder called src to store our code. From the project directory, we’ll initialize a new npm package using the following command:

1
npm init -y # -y option skips over project questionnaire

This will generate a package.json file within our project.

Adding the Webpack Configuration

Webpack, primarily used for JavaScript applications, bundles modules. Since TypeScript compiles down to JavaScript, Webpack will prepare our code for the web browser. We’ll install two libraries and a custom loader:

  • webpack: This is the core library.
  • webpack-cli: These are command-line utilities for Webpack.
  • ts-loader: This is a TypeScript loader specifically designed for Webpack.
1
npm i --save-dev webpack webpack-cli ts-loader

The sam build command, used by the AWS SAM CLI for building, can slow down development. This is because it attempts to run npm install for each function, leading to redundancy. To speed things up, we’ll use an alternative build command provided by the aws-sam-webpack-plugin library.

1
npm i --save-dev aws-sam-webpack-plugin

Webpack doesn’t come with a default configuration file. Let’s create a custom config file named webpack.config.js in the root folder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const AwsSamPlugin = require('aws-sam-webpack-plugin');

const awsSamPlugin = new AwsSamPlugin();

module.exports = {
    entry: () => awsSamPlugin.entry(),
    output: {
        filename: (chunkData) => awsSamPlugin.filename(chunkData),
        libraryTarget: 'commonjs2',
        path: path.resolve('.')
    },
    devtool: 'source-map',
    resolve: {
        extensions: ['.ts', '.js']
    },
    target: 'node',
    mode: process.env.NODE_ENV || 'development',
    module: {
        rules: [{ test: /\.tsx?$/, loader: 'ts-loader' }]
    },
    plugins: [awsSamPlugin]
};

Let’s break down the different parts:

  • entry: This section specifies where Webpack should begin building the bundle by loading the entry object from the AWS::Serverless::Function resource.
  • output: This points to the destination where the build output should be placed (in this case, .aws-sam/build). We also define the target library as commonjs2, which assigns the return value of the entry point to module.exports, the default entry point for Node.js environments.
  • devtool: This generates a source map file named app.js.map within our build output destination. This map connects our original code to the code executed in the web browser, aiding in debugging when the NODE_OPTIONS environment variable is set to --enable-source-maps for our Lambda function.
  • resolve: This instructs Webpack to prioritize processing TypeScript files over JavaScript files.
  • target: This tells Webpack to treat Node.js as our target environment, meaning it will utilize Node.js’s require function for loading chunks during compilation.
  • module: This applies the TypeScript loader to all files matching the specified test condition, ensuring that any file with a .ts or .tsx extension is handled by the loader.
  • plugins: This section helps Webpack identify and utilize our aws-sam-webpack-plugin.

In the first line, we’ve temporarily disabled a specific ESLint rule for this file. While our ESLint configuration, which we’ll set up later, generally advises against using the require statement, we prefer it over import in Webpack and will make an exception for this file.

Incorporating TypeScript Support

Integrating TypeScript brings several benefits to the development process:

  • It prevents warning messages related to missing type declarations.
  • It provides valuable type validation.
  • It offers helpful autocompletion suggestions within your IDE.

Let’s start by installing TypeScript locally for our project (you can skip this step if you have TypeScript installed globally):

1
npm i --save-dev typescript

Next, we’ll include type definitions for the libraries we’re using:

1
npm i --save-dev @types/node @types/webpack @types/aws-lambda

Now, let’s create a TypeScript configuration file named tsconfig.json in the project’s root directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    "compilerOptions": {
        "target": "ES2015",
        "module": "commonjs",
        "sourceMap": true,
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
    },
    "include": ["src/**/*.ts", "src/**/*.js"],
    "exclude": ["node_modules"]
}

Here, we’re adhering to the default configuration recommended by the TypeScript community. We’ve added the include property to incorporate files within the src folder into our program. Additionally, we’ve included the exclude property to prevent TypeScript compilation for the node_modules folder, as we won’t be directly modifying code within that directory.

Creating a Lambda Function

Up until now, we haven’t written any actual Lambda code for our serverless application. Let’s change that. Inside the src folder we created earlier, create a subfolder named test-lambda. Within test-lambda, create a file named app.ts and add the following Lambda function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { APIGatewayEvent } from 'aws-lambda';

export const handler = async (event: APIGatewayEvent) => {
    console.log('incoming event is', JSON.stringify(event));
    const response = {
        statusCode: 200,
        body: JSON.stringify({ message: 'Request was successful.' })
    };
    return response;
};

This function serves as a simple placeholder. When executed, it returns a 200 response with a basic message body. We’ll be able to run this code after one more step.

Adding the AWS Template File

AWS SAM relies on a template file to transpile our code and deploy it to the cloud. Create a file named template.yaml in your project’s root folder and add the following content:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: AWS SAM Boilerplate Using TypeScript

Globals:
  Function:
    Runtime: nodejs14.x # modify the version according to your need
    Timeout: 30
    
Resources:
  TestLambda:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.handler
      FunctionName: "Test-Lambda"
      CodeUri: src/test-lambda/
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /test
            Method: get

This template file defines a Lambda function that can be accessed through an HTTP GET API. Note that the version specified in the Runtime: line might need to be adjusted based on your requirements.

Running the Application

To run our application, we need to include a new script within the package.json file. This script will handle building the project using Webpack. Your package.json file might already contain some scripts, such as an empty test script. Add the build script as follows:

1
2
3
"scripts": {
   "build": "webpack-cli"
}

Executing npm run build from your project’s root directory should create a build folder named .aws-sam. If you’re using a Mac and don’t see this folder, you might need to reveal hidden files by pressing Command + Shift + ..

Next, we’ll initiate a local HTTP server to test our function:

1
sam local start-api

Visiting the test endpoint in a web browser should display a success message.

The web browser shows the link "127.0.0.1:3000/test" in the address bar. Below the address bar, the webpage is blank except for a message reading '{"message": "Request was successful."}.

The console output should indicate that the function is being mounted within a Docker container before execution. This is why we installed Docker earlier.

1
2
3
4
Invoking app.handler (nodejs14.x)
Skip pulling image and use local one: public.ecr.aws/sam/emulation-nodejs14.x:rapid-1.37.0-x86_64.

Mounting /Users/mohammadfaisal/Documents/learning/aws-sam-typescript-boilerplate/.aws-sam/build/TestLambda as /var/task:ro, delegated inside runtime container

Elevating Our Development Workflow for Professional Environments

Our project is now up and running. However, adding a few finishing touches will create an outstanding developer experience, enhancing productivity and fostering better collaboration.

Optimizing the Build Process with Hot Reloading

Manually running the build command after every code change can become tiresome. Hot reloading addresses this issue. We can introduce another script to our package.json file to monitor for file modifications:

1
"watch": "webpack-cli -w"

Open a separate terminal window and execute npm run watch. With this command running, your project will automatically recompile whenever you make changes to your code. Try modifying the message within your code, refresh your webpage, and observe the updated result.

Enhancing Code Quality Using ESLint and Prettier

No TypeScript or JavaScript project is truly complete without incorporating ESLint and Prettier. These tools are essential for upholding consistent code quality and style within your project.

First, let’s install the core dependencies:

1
npm i --save-dev eslint prettier

To ensure ESLint and Prettier work seamlessly together in our TypeScript project, we’ll also install some helper dependencies:

1
2
3
4
5
npm i --save-dev \
eslint-config-prettier \
eslint-plugin-prettier \
@typescript-eslint/parser \
@typescript-eslint/eslint-plugin

Now, let’s set up our linter by creating an ESLint configuration file named .eslintrc in the project’s root directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
{
    "root": true,
    "env": {
        "es2020": true,
        "node": true,
        "jest": true
    },
    "parser": "@typescript-eslint/parser",
    "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:prettier/recommended"
    ],
    "ignorePatterns": ["src/**/*.test.ts", "dist/", "coverage/", "test/"],
    "parserOptions": {
        "ecmaVersion": 2018,
        "sourceType": "module",
        "ecmaFeatures": {
            "impliedStrict": true
        }
    },
    "rules": {
        "quotes": ["error", "single", { "allowTemplateLiterals": true }],
        "default-case": "warn",
        "no-param-reassign": "warn",
        "no-await-in-loop": "warn",
        "@typescript-eslint/no-unused-vars": [
            "error",
            {
                "vars": "all",
                "args": "none"
            }
        ]
    },
    "settings": {
        "import/resolver": {
            "node": {
                "extensions": [".js", ".jsx", ".ts", ".tsx"]
            }
        }
    }
}

It’s important to note that in the extends section of this file, the configuration for the Prettier plugin must be placed as the last item. This ensures that Prettier errors are displayed as ESLint errors within your editor. We’re adhering to the ESLint recommended settings specifically designed for TypeScript and have added some custom preferences within the rules section. Feel free to explore the available available rules and tailor the settings to your liking. We’ve chosen to include the following rules:

  • An error will be raised if we don’t use single quotes for strings.
  • A warning will be triggered when a switch statement lacks a default case.
  • A warning will be issued if we attempt to reassign a function parameter.
  • A warning will be displayed if an await statement is used within a loop.
  • An error will be raised for unused variables, as these can make code harder to read and more prone to errors over time.

Our ESLint configuration is already set up to seamlessly integrate with Prettier formatting. (You can find more details in the eslint-config-prettier GitHub project.) Now, let’s create a configuration file for Prettier named .prettierrc:

1
2
3
4
5
6
{
    "trailingComma": "none",
    "tabWidth": 4,
    "semi": true,
    "singleQuote": true
}

These settings are derived from Prettier’s official documentation, and you’re welcome to modify them to your preferences. We’ve made adjustments to the following properties:

  • trailingComma: We’ve changed this from es5 to none to prevent trailing commas.
  • semi: We’ve changed this from false to true because we prefer semicolons at the end of each line.

It’s time to see ESLint and Prettier in action. In your app.ts file, change the declaration of the response variable from const to let. Using let in this scenario is not ideal, as we don’t intend to modify the value of response. Your editor should now display an error, highlighting the violated rule and providing suggestions for fixing the code. Make sure to enable ESLint and Prettier within your editor’s settings if you haven’t already.

The editor displays a line of code assigning a value to the variable "let response." The line shows a yellow lightbulb next to it, and the word "response" has a red underline and an error pop-up above it. The error pop-up first defines the variable "response" and reads: "let response: { statusCode: number; body: string; }." Below the definition, the error message reads: "'response' is never reassigned. Use 'const' instead. eslint(prefer-const)." Below the error message, two options read: "View Problem" or "Quick Fix."

Maintaining Code Integrity with Jest Testing

There’s a wide range of libraries available for testing, such as Jest, Mocha, and Storybook. In our project, we’ll utilize Jest for several reasons:

  • It’s incredibly easy to learn.
  • Setting it up requires minimal effort.
  • It offers a convenient way to perform snapshot testing.

Let’s install the necessary dependencies:

1
npm i --save-dev jest ts-jest @types/jest

Next, create a Jest configuration file named jest.config.js within your project’s root directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
module.exports = {
    roots: ['src'],
    testMatch: ['**/__tests__/**/*.+(ts|tsx|js)'],
    transform: {
        '^.+\\.(ts|tsx)

We're customizing three specific options in this file:

-   `roots`: This array specifies the directories that Jest should scan for test files. In our case, it will exclusively look for test files within the `src` subfolder and its descendants.
-   `testMatch`: This array of glob patterns defines the file extensions that Jest should recognize as test files.
-   `transform`: This option enables us to write our tests in TypeScript by employing the `ts-jest` package.

Within the `src/test-lambda` directory, create a new folder named `__tests__`. Inside this folder, create a file named `handler.test.ts` to house our first test:

```ts
import { handler } from '../app';
const event: any = {
    body: JSON.stringify({}),
    headers: {}
};

describe('Demo test', () => {
    test('This is the proof of concept that the test works.', async () => {
        const res = await handler(event);
        expect(res.statusCode).toBe(200);
    });
});

Now, let’s revisit our package.json file and add the test script:

1
"test": "jest"

Navigating to your terminal and running npm run test should now present you with a passing test:

The top of the console shows a green "Pass" indicator and the test file name, "src/test-lambda/__tests__/handler.test.ts." The next line reads, "Demo test." The next line shows a green check mark followed by "This is the proof of concept that the test works. (1 ms)." After a blank line, the first line reads: "Test Suites: 1 passed, 1 total." The second reads: "Tests: 1 passed, 1 total." The third reads: "Snapshots: 0 total." The fourth reads: "Time: 0.959 s." The last line reads: "Ran all test suites."

Managing Source Control with .gitignore

It’s generally good practice to configure Git to exclude specific files and directories from source control. Create a .gitignore file using gitignore.io to prevent unnecessary files from being tracked:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Runtime data
pids
*.pid
*.seed
*.pid.lock

npm-debug.log
package.lock.json
/node_modules
.aws-sam
.vscode

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional ESLint cache
.eslintcache

Poised for Success: Your Blueprint Awaits

You’ve now successfully set up a comprehensive AWS SAM boilerplate project project using TypeScript. We’ve focused on establishing a solid foundation and emphasized the importance of maintaining high code quality through the integration of ESLint, Prettier, and Jest. This example from our AWS SAM tutorial serves as a robust blueprint, equipping you to kickstart your next significant project on the right foot.

The Toptal Engineering Blog extends its sincere gratitude to Christian Loef for his invaluable review of the code samples presented in this article.

The AWS logo with the word "PARTNER" and the text "Advanced Tier Services" below that.

: ’ts-jest' } };

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

We're customizing three specific options in this file:

-   `roots`: This array specifies the directories that Jest should scan for test files. In our case, it will exclusively look for test files within the `src` subfolder and its descendants.
-   `testMatch`: This array of glob patterns defines the file extensions that Jest should recognize as test files.
-   `transform`: This option enables us to write our tests in TypeScript by employing the `ts-jest` package.

Within the `src/test-lambda` directory, create a new folder named `__tests__`. Inside this folder, create a file named `handler.test.ts` to house our first test:

```ts
import { handler } from '../app';
const event: any = {
    body: JSON.stringify({}),
    headers: {}
};

describe('Demo test', () => {
    test('This is the proof of concept that the test works.', async () => {
        const res = await handler(event);
        expect(res.statusCode).toBe(200);
    });
});

Now, let’s revisit our package.json file and add the test script:

1
"test": "jest"

Navigating to your terminal and running npm run test should now present you with a passing test:

The top of the console shows a green "Pass" indicator and the test file name, "src/test-lambda/__tests__/handler.test.ts." The next line reads, "Demo test." The next line shows a green check mark followed by "This is the proof of concept that the test works. (1 ms)." After a blank line, the first line reads: "Test Suites: 1 passed, 1 total." The second reads: "Tests: 1 passed, 1 total." The third reads: "Snapshots: 0 total." The fourth reads: "Time: 0.959 s." The last line reads: "Ran all test suites."

Managing Source Control with .gitignore

It’s generally good practice to configure Git to exclude specific files and directories from source control. Create a .gitignore file using gitignore.io to prevent unnecessary files from being tracked:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Runtime data
pids
*.pid
*.seed
*.pid.lock

npm-debug.log
package.lock.json
/node_modules
.aws-sam
.vscode

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional ESLint cache
.eslintcache

Poised for Success: Your Blueprint Awaits

You’ve now successfully set up a comprehensive AWS SAM boilerplate project project using TypeScript. We’ve focused on establishing a solid foundation and emphasized the importance of maintaining high code quality through the integration of ESLint, Prettier, and Jest. This example from our AWS SAM tutorial serves as a robust blueprint, equipping you to kickstart your next significant project on the right foot.

The Toptal Engineering Blog extends its sincere gratitude to Christian Loef for his invaluable review of the code samples presented in this article.

The AWS logo with the word "PARTNER" and the text "Advanced Tier Services" below that.

Licensed under CC BY-NC-SA 4.0