Setting Up a Node 18 + TypeScript 5.1 + Serverless Framework (AWS Lambda) Project From Scratch

Albert Asratyan
9 min readAug 22, 2023

--

At the time of writing this article, the latest AWS-supported Node.js runtime is version 18 (and AWS SDK v3). This post is a tutorial on how to set up a clean TypeScript project with infrastructure as code for AWS Lambda from scratch.

The public repository for this guide is available here. The repository uses Node 18, TypeScript 5.1, Serverless Framework (AWS), esbuild, eslint, prettier, and husky.

Why Serverless Framework?

Being able to start prototyping fast without excessive infrastructure setup can be useful. Serverless Framework is based on a single .yml configuration file (even though it is possible to use a .ts configuration with some type safety). This lack of overhead allows for a quick setup with a full focus on development.

Within the AWS context, Serverless Framework is a minimal but heavily extensible framework that focuses on serverless function workflow compared to more all-purpose CDK/SST/Pulumi frameworks.

If you are familiar with serverless, you might wonder why I am not using serverless-bundle and why I am doing the whole setup from scratch. In short, serverless-bundle does not support TypeScript ≥5.0 yet. But in general, any production-grade Serverless TypeScript repository contains a lot of various libraries and modules. I think it is important to understand what components are usually included and how to set it up from just a simple package.json . The purpose of this tutorial is to show exactly that.

Prerequisites

If you want to replicate the code in this tutorial, you should have the following software installed locally:

  1. Node.js 18 — get here;
  2. AWS CLI — get here. You should configure the access outside of this tutorial.

Creating an empty TypeScript Project

Let’s initialize a basic project with the following package.json :

// ./package.json
{
"name": "serverless-typescript-template",
"private": true,
"version": "1.0.0",
"description": "A minimal example of Node 18 + TypeScript 5.1 + Serverless Framework + AWS",
"scripts": {
"test": "glob -c \"node --loader tsx --test\" \"./src/**/*.test.ts\""
},
"dependencies": {},
"devDependencies": {
"@types/node": "^18.0.0",
"typescript": "^5.1.6",
"tsx": "^3.12.7",
"glob": "^10.3.3"
}
}

Then, run npm install to fetch all of the devDependencies . We need the following devDependencies :

  1. typescript and @types/node — for adding basic TypeScript support;
  2. tsx — for adding support to run native Node.js tests in TypeScript files;
  3. glob — for adding support to provide glob (such as ./*.test.ts ) patterns to the npm testcommand. This is required for Windows. Unix-based systems should have this available out of the box.

Note that we have not added any script yet for compiling the TypeScript code. This is because we will let Serverless compile the code for us. However, you might want to have a manual compile step (something like tsc --no-emit) for static type-checking as a commit hook.

Next, we need to add a tsconfig.json :

// ./tsconfig.json
{
"compilerOptions": {
"lib": ["ES2022"],
"module": "ES2022",
"moduleResolution": "Bundler", // introduced in TypeScript 5.0
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"target": "ES2022",
"strict": true
},
"include": ["src/**/*.ts"],
}

Node 18 has 100% support of theES2022 library. More info here.

Note: "moduleResolution": "Bundler" is a new feature introduced in TypeScript 5.0 that allows TypeScript to relax module resolution rules on the assumption that the code bundler (in our case we will use esbuild ) will take care of it. You can read more on "moduleResolution": "Bundler" here.

Adding Serverless Framework

Now that we have the basic project, it is time to add the Serverless Framework. This part can be split into the following steps:

  1. Adding required dependencies;
  2. Adding the serverless.yml configuration file;
  3. Adding Lambda handler code and defining custom resources within serverless.yml

Let’s start by adding the following packages to devDependencies of package.json:

"@types/aws-lambda": "^8.10.119",
"serverless": "^3.34.0",
"serverless-esbuild": "^1.46.0",
"esbuild": "^0.17.19",
"serverless-offline": "^12.0.4",
"@serverless/typescript": "^3.30.1", // optional

Let’s go over these packages:

  1. @types/aws-lambda — for type definitions for Lambda events/responses;
  2. serverless — core Serverless Framework package;
  3. serverless-esbuild and esbuild — for compiling TypeScript code via Serverless CLI;
  4. serverless-offline — for testing AWS Lambda locally;
  5. Optional: @serverless/typescript — if you want to define the Serverless stack in a .ts config file rather than a .yml file. This tutorial will use a .yml config

Again, run npm install to fetch the newly added dependencies. Now we can create a serverless.yml file at the root level:

# ./serverless.yml
service: serverless-typescript-template

plugins:
- serverless-esbuild # used for compiling/packaging the Typescript code
- serverless-offline # used for local execution

provider:
name: aws
runtime: nodejs18.x
region: eu-west-1
stage: ${opt:stage}
timeout: 30 # repository wide Lambda timeout
environment: # repository wide Lambda env variables
STAGE: ${self:provider.stage}

custom:
customVariable1: Hello, World!

functions:
# Serverless Framework specific syntax
Function1:
handler: ./src/lambda1.handler
events:
- httpApi:
path: /api/Function1
method: GET
environment:
CUSTOM_VAR: ${self:custom.customVariable1}

Function2:
handler: ./src/lambda2.handler
timeout: 10 # override global setting
memorySize: 2048 # override default 512 Mb
events:
- schedule:
rate: cron(0 12 * * ? *) # triggers every 12 hours
enabled: true

This config defines an AWS CloudFormation stack called serverless-typescript-template with two Node.js 18 Lambda functions. Function1 triggers on GET requests at /api/Function1of the API Gateway endpoint associated with this stack (which is created automatically by Serverless). Function2 just triggers on schedule — every 12 hours.

Their handlers point to handler function within ./src/lambda1.tsand ./src/lambda2.ts files, respectively. Let’s create these files:

// ./src/lambda1.ts
import type {
Context,
APIGatewayProxyStructuredResultV2,
APIGatewayProxyEventV2,
Handler,
} from "aws-lambda";

export const handler: Handler = async (
_event: APIGatewayProxyEventV2,
_context: Context
): Promise<APIGatewayProxyStructuredResultV2> => {
console.log("Hello, Lambda 1!");

return {
statusCode: 200,
body: process.env.CUSTOM_VAR,
};
};
// ./src/lambda2.ts
import type { Handler, Context, ScheduledEvent } from "aws-lambda";

export const handler: Handler = async (
_event: ScheduledEvent,
_context: Context
) => {
console.log("Hello, Lambda 2!");

return {
statusCode: 200,
};
};

As you have noticed, these are TypeScript source files and we don’t have an explicit build step anywhere. serverless.yml uses the serverless-esbuild plugin for compiling and bundling TypeScript code automatically (thus "moduleResolution": "Bundler" in tsconfig.json).

Note: the GitHub example repository splits the single YAML config file into multiple individual files, each with its own responsibility. Check it out if you want to have some inspiration on how you can structure your IaC configuration files.

Now that the handler code is in place, we can try to run the Lambdas locally — thanks to the serverless-offline plugin. You can invoke the lambdas using serverless invoke local -f Function1 -s dev and serverless invoke local -f Function2 -s dev . Moreover, serverless-offline also allows to simulate an API Gateway endpoint locally. Run serverless offline -s dev . You should see the following output:

serverless offline -s dev output

Function1 is accessible at localhost:3000/api/Function1 , and even Function2 is locally scheduled!

Deployment

You need to have a configured AWS profile before deployment. Then, the app can be deployed to AWS via serverless deploy -s <your_stage> . This command will trigger a CloudFormation stack deployment. The stack name will be equal to the service variable at the top of the serverless.yml succeeded by the stage name.

Extras

This section will cover adding various useful things, such as testing, linting, and formatting. These are secondary, yet very important parts of any project. Feel free to skip to the conclusion.

Adding tests

We have already added support for running tests in package.json , so let’s add our first test for the lambda1.ts handler file. Let’s create a file ./src/lambda1.test.ts :

// ./src/lambda1.test.ts
import assert from "node:assert";
import test from "node:test";
import { handler } from "./lambda1";
import { APIGatewayProxyEventV2, Callback, Context } from "aws-lambda";

process.env.CUSTOM_VAR = "test_value";

test("synchronous passing test", async (_t) => {
const testEvent = {} as APIGatewayProxyEventV2;
const testContext = {} as Context;
const testCallback = {} as Callback;
const response = await handler(testEvent, testContext, testCallback);
assert.strictEqual(response.statusCode, 200);
});

Try npm test to see it in action.

Note: this test file uses the inbuilt Node tester module. It is experimental in Node 18, but stable in Node 20. When AWS adds support for Node 20 runtime (and they will!), you won’t need to do any code updates :)

Adding eslint

Let’s start by initialising eslint in the repository by running npm init @eslint/config . Follow the screenshot for the exact configuration choices:

Installing eslint

Note: if unclear, the first question was answered with To check syntax and find problems , and the second — with JavaScript modules (import/export) . This should be enough to get eslint working.

Now let’s add a script to package.json that will lint the whole repository for us: "lint": "eslint --ext .ts --fix ." . If you run the command now (npm run lint), a couple of errors will be thrown:

eslint complains about unused variables

Personally, I think it is OK to have unused variables in the code, as long as they are marked with a preceding underscore _ . Let’s do that! We can add a custom rule to our .eslintrc.js :

// add it to line 31 within "rules" parameter:
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],

And now running npm run lint should execute successfully!

Adding prettier

Now let’s add prettier — a tool for enforcing code format across the repository. We will need to manually install the required devDependencies package via:

npm install --save-dev eslint-config-prettier
npm install --save-dev eslint-plugin-prettier
npm install --save-dev prettier

After that, we will need to update the array values of “extends” and "plugins” properties in .eslintrc.js with the value of “prettier”:

Adding prettier to “extends”. You need to do the same for “plugins” (not shown on the screenshot)

Now we can add a new script command to package.json for running prettier :

"prettier": "prettier --write --ignore-unknown ."

Running npm run prettier should retouch the repository now! You can add custom prettier rules based on your preferences. All of the options are provided here.

Adding commit hooks (husky)

The last step is to automate compiling, testing, lining, and formatting, because who has the time to run these steps manually? There is a neat npm library called husky that does exactly this by automatically running scripted actions (in our case —testing, building, linting, and formatting), whenever a specific git action happens (such as running a git commit command). Adding git hooks to our repository will ensure that we commit and push only quality code. To add it to our repository, run:

npx husky-init
npm install

This will create a folder ./.husky/ in our repository that will store all of our git hooks. Let’s define some pre-commit actions in ./.husky/pre-commit:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm test
npm run build # a new command, runs "tsc --noemit"
npm run lint
npm run prettier

Note: make sure to add a new buildcommand to package.json: tsc --noemit .

And now, when you try to commit any changes, the repository will always run the hooks — unless specified otherwise by git commit --no-verify .

I think this covers it all!

Conclusion

Let’s sum it up. We went from having a basic package.json to a production-ready backend Serverless Framework TypeScript repository with automatic testing, type checking, lining, and formatting. I hope that this tutorial has shown that most projects get bulky for a reason and that if you ever have the need — you now have this guide to help you to set a project up.

What’s next?

If you want to get this one step further, a common thing to do is to set up a CI/CD pipeline that deploys the code on each push to the remote. Some common examples are GitHub Actions and BitBucket Pipelines. But this is something that I leave for you to explore :)

Let me know if I missed anything and follow for more TypeScript/serverless content!

--

--

Albert Asratyan

Software Engineering Consultant @ Netlight / Certified AWS Solution Architect