How to Build AWS Lambdas with TypeScript
Tags: typescript, javascript
Published: 2022-09-28 11:31:05

Serverless computing is an exciting alternative to hosting apps on the AWS cloud. In this four-part series, we’ll run through how to build AWS Lambdas with TypeScript, improve the dev experience, optimize it, and finally, use AWS Cognito for security.

In this take, I would like to take you on a journey to explore AWS Lambdas using TypeScript. We will build a pizza API, use Claudia to help deploy the app, and use the AWS CLI tool to set up a DynamoDB database.

But before we dive into the project, let’s quickly touch on what serverless is and why it’s often a great option for Node.

## What Is Serverless?

The concept of serverless computing isn’t exactly new — you only pay-per-use without renting or buying dedicated servers. A serverless app is not long-lived because there are no guarantees on the hardware itself. This means your code runs best when it spins up quickly, does its job, and then dies. The more lightweight the code, the less you pay.

This serverless birth and rebirth mantra is what pushes developers to come up with simpler ways to solve the problem.

## Why Node.js Works Well with Serverless

Node is a nice fit for serverless because it is designed to spin up quickly and runs anywhere, including phone hardware. Over the last decade, the JavaScript V8 engine has evolved to run as efficiently as possible, which is great for serverless.

## Initial Setup: Build a Pizza API and Your TypeScript Project

_The entire sample code for this project is available on [GitHub](https://github.com/beautifulcoder/typescript-aws-lambda). You can also follow along below — we will guide you step-by-step._

To start building your pizza API, open a console and type:

```shell
> mkdir typescript-aws-lambda
> cd typescript-aws-lambda
> mkdir part-1 && cd part-1
> mkdir pizza-api && cd pizza-api
```

Inside your new folder, execute `npm init` to create the skeleton `project.json` file. Be sure to give it a meaningful name, like `pizza-api`, as it will become the name of your Lambda function once it deploys to AWS.

Note that `pizza-api` is now the root folder, where you will perform the remaining work.

Next, we'll install [Claudia](https://claudiajs.com/). Claudia is a severless tool that will make it easy for us to deploy our project to AWS Lambda and API Gateway. Install all project dependencies:

```shell
> npm i typescript claudia --save-dev
> npm i @aws-sdk/client-dynamodb claudia-api-builder slugify --save
```

These are both dev and project dependencies. A general good practice is to keep them separate in the `package.json` file.

Now, initiate a TypeScript config for this project. There are two options:

1. `tsc --init` if you have it installed globally.
2. Or, my preference is to do `node_modules/.bin/tsc --init` from the root folder.

Once you have a `tsconfig.json` file, enable `outDir` and set it to `./dist` in the JSON file.

Unfortunately, Claudia does not include type definitions for TypeScript. This problem can be solved by simply defining your own types in the code repo. To do so, create a folder under the root folder `@types/claudia-api-builder`, then an `index.d.ts` file. This instructs the compiler to find type definitions for `claudia-api-builder` under the same codebase.

Put this in `index.d.ts`:

```typescript
declare module "claudia-api-builder" {
  export class ApiResponse {
    constructor(response: any, headers: any, code: number);
  }

  export default class ApiBuilder {
    get(route: string, handler: Function, options: any = {}): void;
    post(route: string, handler: Function, options: any = {}): void;
    put(route: string, handler: Function, options: any = {}): void;
    delete(route: string, handler: Function, options: any = {}): void;
    head(route: string, handler: Function, options: any = {}): void;
    patch(route: string, handler: Function, options: any = {}): void;
    registerAuthorizer(name: string, config: any): void;
  }
}
```

Luckily, the code Claudia exposes is relatively trivial. You create an `ApiBuilder`, then declare GET/POST endpoints in the API. Claudia takes care of the rest of the work in AWS.

The `ApiResponse` is also included. This response class is useful for custom API responses, but there is actually a sleeker way of handling responses (so we won’t use this response class).

## AWS Configuration

Before we can start playing with Claudia and AWS Lambdas, you will need three things: the [AWS CLI tool](https://aws.amazon.com/cli/), an [IAM user identity](https://docs.aws.amazon.com/IAM/latest/UserGuide/id.html#id_iam-users), and programmatic access via a key and secret.

First, download the AWS CLI tool to create a profile folder under the user’s home folder. In Windows, for example, this folder is under `C:\Users\<user>\.aws`. Inside are two files: `config` and `credentials`.

Then, create an AWS account, log in, and make a note of the region. You will want to pick one closest to your physical location. For example, `us-east-1` or `eu-central-1`. Open up the `config` file and set the region accordingly.

For example:

```text
[default]
region = us-east-1
output = json
```

Note that the CLI tool uses the `default` profile in this config file. You can have many profiles, but that is beyond the scope of this guide.

Next, create a user in IAM and assign programmatic access via an access key and secret. Be sure to set the correct group/role, so that you are allowed to create Lambdas and configure the AWS gateway.

This is what your `credentials` file should look like:

```text
[default]
aws_access_key_id = ACCESS_KEY
aws_secret_access_key = SECRET_ACCESS_KEY
```

To test the credentials, simply run:

```shell
> aws sts get-caller-identity
```

With this, the `aws` CLI tool should be able to access the AWS cloud under your account. This is a one-time setup, and you will not have to revisit this configuration. The CLI tool does a good job of staying out of the way without eating your local machine.

## Build Your Initial API with TypeScript

Now, let’s move on to the fun part! Create a single endpoint in AWS to test your capabilities in spinning up new Lambdas. This will have a simple greeting response.

Create a file named `api.ts` in the root folder and put this in place:

```typescript
import Api from "claudia-api-builder";

const api = new Api();

api.get("/", () => ["Welcome to AWS"]);
```

There are a couple of things going on here: a route and a callback handler with the proper JSON response. Claudia makes building APIs somewhat easy because it automates the rest of the configuration in AWS. The route translates into an entry in the AWS gateway, and the handler is the actual Lambda function.

You can think of a Lambda as a single event that fires in the AWS cloud. An event can be triggered by pretty much anything — for example, a database change or a JWT authorizer. In this case, the AWS gateway picks up an HTTP request, and it too fires an event to execute your Lambda function.

To automate AWS deployments in Claudia, you will need to run the `claudia` command. Open up the `package.json` file and put this in the `scripts` section:

```json
{
  "precreate": "tsc",
  "preupdate": "tsc",
  "create": "claudia create --region us-east-1 --api-module dist/api",
  "update": "claudia update --cache-api-config apiConfig",
  "type-check": "tsc"
}
```

The `pre` prefix guarantees the latest successful build before Claudia deploys. This is a nice technique for developers who are new to TypeScript and forget to run the compiler.

Be sure to specify the correct region that is closest to you in the command line argument.

With this in place, execute `npm run create` and let Claudia deploy. Claudia uses a zip deploy via `npm pack` to upload artifacts into the AWS cloud. If everything goes well, there should be an endpoint in the output that you can copy-paste into a browser.

Something like this:

```json
{
  "url": "https://API_GATEWAY_ID.execute-api.us-east-1.amazonaws.com/latest"
}
```

## Connect to DynamoDB and Use Claudia

The next step is to talk to DynamoDB. This requires two things: a role with access to the database and a table on AWS.

The command to create a role fails without a policy document file in `roles/dynamodb.json`. Create a file under this path and define the role access:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "dynamodb:Scan",
        "dynamodb:DeleteItem",
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem"
      ],
      "Effect": "Allow",
      "Resource": "*"
    }
  ]
}
```

To set up a role, run this command via the AWS CLI tool:

```shell
> aws iam put-role-policy --role-name pizza-api-executor --policy-name PizzaApiDynamoDB --policy-document file://./roles/dynamodb.json
```

Next, create the necessary table. We will use a `url` field as the key in the table. This is the resource locator field used to call the API and is part of the URL (for example, `/Pepperoni-Pizza`).

```shell
> aws dynamodb create-table --table-name pizzas --attribute-definitions AttributeName=url,AttributeType=S --key-schema AttributeName=url,KeyType=HASH --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 --region us-east-1 --query TableDescription.TableArn --output text
```

Be sure to specify the correct region, the one closest to you.

Create a TypeScript file — `api.ts` — in the root folder. Then, fill out the import/export code:

```typescript
import Api from "claudia-api-builder";
import {
  DynamoDBClient,
  GetItemCommand,
  PutItemCommand,
  ScanCommand,
} from "@aws-sdk/client-dynamodb";
import slugify from "slugify";

// The rest of the code goes here

export = api;
```

Lastly, build the GET/POST endpoints in `api.ts`. I’ll leave the endpoint that returns all pizzas using a table scan as an exercise for you. You can find the full sample code in the [GitHub repo](https://github.com/beautifulcoder/typescript-aws-lambda).

```typescript
api.post(
  "/pizzas",
  async (request: any) => {
    const { body } = request;

    if (
      !body ||
      !body.name ||
      !body.ingredients ||
      !Array.isArray(body.ingredients) ||
      body.ingredients.length === 0
    ) {
      throw new Error("To make a pizza include name and ingredients");
    }

    const pizzaItem = {
      url: { S: slugify(body.name) },
      name: { S: body.name },
      ingredients: { SS: body.ingredients }, // type-sensitive
    };

    await client.send(
      new PutItemCommand({
        TableName: "pizzas",
        Item: pizzaItem,
      })
    );

    console.log("Pizza is saved!", pizzaItem);
    return pizzaItem;
  },
  {
    success: 201,
    error: 400,
  }
);

api.get(
  "/pizzas/{url}",
  async (request: any) => {
    const url = request.pathParams.url;

    const res = await client.send(
      new GetItemCommand({
        TableName: "pizzas",
        Key: { url: { S: url } },
      })
    );
    const pizzaItem = res.Item;

    if (pizzaItem === undefined) throw new Error("Pizza not found!");

    return pizzaItem;
  },
  {
    success: 200,
    error: 404,
  }
);
```

To update the Lambda in AWS, run the `npm run update` command. Claudia keys off a `claudia.json` file and updates the Lambda with the latest code.

Each method takes three parameters: route, handler, and options. The `options` parameter at the end specifies the status code in the HTTP response. For example, for a GET, it throws an exception when the pizza is missing. Claudia handles this exception automatically and turns it into a '404 Not Found' response. Similarly, with POST, you get a '400 Bad Request' response when an exception gets thrown or '201 Created' when a new resource is available.

The request parameters contain either the `body` of the request or the `pathParams` values. These parameters come from the event fired by the AWS gateway, and Claudia makes it easy for you to consume both of them in your Lambda function.

## Test Out Your AWS Lambda with CURL

Drumroll, please!

Now we’ll use CURL to test our Lambda. Send a POST request first to make a pizza with a name and ingredients:

```shell
> curl -X POST -i -H "Accept-Type: application/json" -H "Content-Type: application/json" -d "{\"name\":\"Pepperoni Pizza\",\"ingredients\":[\"tomato sauce\",\"cheese\",\"pepperoni\"]}" https://API_GATEWAY_ID.execute-api.us-east-1.amazonaws.com/latest/pizzas
```

Feel free to play around with this. If you forget to add a name or include ingredients in the body, the test will fail. DynamoDB is type-sensitive, so even if you try to put a list of numbers in the ingredients, it fails the request.

Finally, fire a GET to get your tasty pizza:

```shell
> curl -X GET -i -H "Accept-Type: application/json" -H "Content-Type: application/json" https://API_GATEWAY_API.execute-api.us-east-1.amazonaws.com/latest/pizzas/Pepperoni-Pizza
```

## Up Next: Improve the Dev Experience

In this post, we explored how to effortlessly build AWS Lambda functions using TypeScript with Claudia.

Next in the series, we’ll look at ways to improve the developer experience, so your code is easier to work with and more robust.

Until then, happy coding!

**P.S. If you liked this post, [subscribe to our JavaScript Sorcery list](https://blog.appsignal.com/javascript-sorcery) for a monthly deep dive into more magical JavaScript tips and tricks.**

**P.P.S. If you need an APM for your Node.js app, go and [check out the AppSignal APM for Node.js](https://www.appsignal.com/nodejs).**