About five months ago, I got thrown head first into the world of serverless when I joined the team at fleet.space to build cloud infrastructure to support their nano-satellite constellation and industrial IoT network.
I really struggled to find a comprehensive guide on how to build new services with TypeScript, so here I am writing the one I wish I had. We won’t go through any example code — we’ll just focus on building up a robust base template you can reuse for all your services.
The first thing we’re going to do is install the Serverless Framework. I find it has better support than the official AWS SAM templates.
First, lets install Serverless as a global dependency on our system via npm i -g serverless. Next, we’ll create a project, mkdir typescript-serverless. Inside that directory, let’s scaffold out a new Serverless template with the following command:
sls create --template aws-nodejs-typescript
This will generate a bare-bones template with TypeScript, but it’s missing a lot of powerful configuration I use over and over again. So let’s set some of these things up. If you don’t use VS Code, go ahead and delete the pesky VS Code directory this template initialises, and then run npm i to install the base dependencies of the Serverless Framework.
Serverless Plugins
The Serverless Framework has one advantage over SAM in that there are many community plugins built around it to help you do things (and you can build your own, if needed). It’s very extensible. Plugins I use in almost every service are:
This plugin allows you to define IAM permissions at a function level instead of the default project level. If only one function needs to touch DynamoDB then we don't need to give them all access.
Multi-region deploys are essentially free in the serverless world (compared to containers/ec2 anyway). The only complexity is keeping DynamoDB in sync. This can be done with a global table.
This one I don't always use these days but it's only a dev dependency and handy to have. It will let you invoke your lambda API locally.
This is a sneaky risk with serverless, especially if you deploy frequently. Lambda will version each function you deploy and there is a hard storage limit to how many you can store. This plugin will prune old versions that aren't needed and prevent that subtle error hitting your production environment.
npm i -D serverless-iam-roles-per-function serverless-create-global-dynamodb-table serverless-offline serverless-prune-plugin
We’ll also add aws-sdk and aws-lambda
npm i aws-sdk aws-lambda.
Lambda Powertools
One thing I really struggled with when I stepped into the world of serverless was observability and traceability. Debugging across service boundaries and even infrastructure within boundaries (Lambda > SQS > Lambda > Kinesis > Lambda > DynamoDB, etc.) was a pain. Thankfully I came across a great set of powertools for Lambda that are a must-have in any service.
@dazn/lambda-powertools-cloudwatchevents-client
@dazn/lambda-powertools-correlation-ids
@dazn/lambda-powertools-logger
@dazn/lambda-powertools-pattern-basic
@dazn/lambda-powertools-lambda-client
@dazn/lambda-powertools-sns-client
@dazn/lambda-powertools-sqs-client
@dazn/lambda-powertools-dynamodb-client
@dazn/lambda-powertools-kinesis-client
I just import all of these and let webpack tree shaking worry about getting rid of what I’m not using.
npm i @dazn/lambda-powertools-cloudwatchevents-client @dazn/lambda-powertools-correlation-ids @dazn/lambda-powertools-logger @dazn/lambda-powertools-pattern-basic @dazn/lambda-powertools-lambda-client @dazn/lambda-powertools-sns-client @dazn/lambda-powertools-sqs-client @dazn/lambda-powertools-dynamodb-client @dazn/lambda-powertools-kinesis-client
Linting
The next important thing I can’t live without in a codebase is linting. ESLint is one of the biggest crutches I use everyday. Let’s configure it to work with TypeScript and the Serverless Framework. We’ll need the following dev dependencies.
npm i -D eslint eslint-config-airbnb-base typescript-eslint eslint-plugin-import eslint-import-resolver-alias eslint-plugin-module-resolver @typescript-eslint/eslint-plugin @typescript-eslint/parser
Now, we need to create an .eslintrc.json config file to define our rules. I like the following rules. This gist also includes some alias mapping for some module aliases we’ll set up at the end and some Jest config we’ll set up in a second.
I’ll also tweak my tsconfig file to add inlineSource, esModuleInterop, sourceRoot, and baseUrl. The following gist also pre-populates some module aliasing information we’ll set up later. You can comment out anything in paths for now, if you want.
Testing
I’ll write tests for every service so it makes sense to configure a test runner in our base template. I personally like Jest, so we’ll set that up.
Once again, we’ll need to fill the the black hole of our node_modules with some npm dev dependencies.
npm i -D jest babel-jest @babel/core @babel/preset-env @babel/preset-typescript
Make sure that Jest is configured as a plugin in your .eslintrc.json and that you set jest/globals under env (if you copied the gist above, you’ll have this in there already).
We need to create a Babel .config for Jest to work.
At this point, we should check to see if Jest is working and if it’s configured correctly. Lets create a tests directory and add an example test. Create a test file, and let’s add a dummy test, tests/example.test.ts.
describe('who tests the tests?', () => {
it('can run a test', () => {
expect.hasAssertions();
expect(1).toBe(1);
});
});
If you’re using WebStorm, you can hit Ctrl+Shift+R to run this test straight from your IDE. Otherwise, let’s go update our package.json to add a test script (and a lint and TS compile check while we’re there).
In your package.json file, update the scripts section to include the following:
"scripts": {
"test": "NODE_ENV=test ./node_modules/.bin/jest --ci --verbose",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"buildtest": "tsc --noEmit"
},
I generally run all of these in CI/CD pipelines to prevent bad code from hitting production. Now, you can run npm run test from your console to run the test suite. Hopefully, your test suite runs and passes. Ideally, your IDE won’t be throwing linting errors at you in your example.test.ts file either.
While we’re here, run npm run lint, and let’s see if there are any linting errors with the default template. You’ll likely get some errors with webpack.config and the handler.ts file that was autoscaffolded. Let’s clear these up.
At the top of your webpack.config file, add */* eslint-disable @typescript-eslint/no-var-requires */*
and uncomment out the Fork TS Checker Webpack Plugin defaults that came with the template. That should solve that file.
For the handler.ts file, just go remove the unused context-function signature from the hello function.
Code goes in /src
One convention I like is putting all our domain logic inside a /src directory and leaving the root for the config (and /tests). Create a /src directory, and move the handler.ts file inside of the /src directory.
If you decide to adopt this convention, you’ll need to go to serverless.yml and update the path for the handler to src/handler.hello.
Let’s configure our Serverless plugin while we’re in the serverless.yml file.
service:
name: typescript-serverless
...
plugins:
- serverless-offline
- serverless-webpack
- serverless-iam-roles-per-function
- serverless-create-global-dynamodb-table
- serverless-prune-plugin
...
At this point, you should be able to run sls offline in your terminal and have a clean compile and build launching a Serverless offline endpoint.
➜ typescript-serverless git:(master) ✗ sls offline
Serverless: Bundling with Webpack...
Time: 398ms
Built at: 27/02/2020 11:24:42 pm
Asset Size Chunks Chunk Names
src/handler.js 6.33 KiB src/handler [emitted] src/handler
Entrypoint src/handler = src/handler.js
[./src/handler.ts] 316 bytes {src/handler} [built]
[source-map-support/register] external "source-map-support/register" 42 bytes {src/handler} [built]
Serverless: Watching for changes...
Serverless: Starting Offline: dev/us-east-1.
Serverless: Routes for hello:
Serverless: GET /hello
Serverless: POST /{apiVersion}/functions/typescript-serverless-dev-hello/invocations
Serverless: Offline [HTTP] listening on http://localhost:3000
Serverless: Enter "rp" to replay the last request
Hopefully, you see this. You should be able to visit localhost:3000 and see a list of available API endpoints. If you go to /hello, things should see a dump of the APIGatewayProxyEvent we return in src/handler.ts.
import { APIGatewayProxyHandler } from 'aws-lambda';
import 'source-map-support/register';
export const hello: APIGatewayProxyHandler = async (event) => ({
statusCode: 200,
body: JSON.stringify({
message: 'Go Serverless Webpack (Typescript) v1.0! Your function executed successfully!',
input: event,
}, null, 2),
});
Serverless Config
Now that we have a working API Gateway endpoint, let’s configure a few more options in the Serverless Framework.
Set up X-Ray tracing for functions
Set some default .env variables
Lock the version of Serverless
Set stage and region configs with defaults
Set up global DynamoDB plugin
Set up automatic pruning for lambda versions (we'll retain the last 3 versions only)
service:
name: typescript-serverless
custom:
webpack:
webpackConfig: ./webpack.config.js
includeModules: true
serverless-iam-roles-per-function:
defaultInherit: true *# Each function will inherit the service level roles too.
globalTables:
regions: # list of regions in which you want to set up global tables
- us-east-2 # Ohio (default region to date for stack)
- ap-southeast-2 # Sydney (lower latency for Australia)
createStack: false
prune: # automatically prune old lambda versions
automatic: true
number: 3
plugins:
- serverless-offline
- serverless-webpack
- serverless-iam-roles-per-function
- serverless-create-global-dynamodb-table
- serverless-prune-plugin
provider:
name: aws
runtime: nodejs12.x
frameworkVersion: ‘1.64.1’
stage: ${opt:stage, 'local'}
region: ${opt:region, 'us-east-2'}
apiGateway:
minimumCompressionSize: 1024 *# Enable gzip compression for responses > 1 KB
environment:
DEBUG: '*'
NODE_ENV: ${self:provider.stage}
AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1
tracing:
lambda: true
iamRoleStatements:
- Effect: Allow
Action:
- xray:PutTraceSegments
- xray:PutTelemetryRecords
Resource: "*"
functions:
hello:
handler: src/handler.hello
events:
- http:
method: get
path: hello
Under provider, we set a default stage and region and some global .env variables for the service. We also set up X-Ray tracing so we can easily debug our service once it’s deployed.
We also locked our version of the Serverless Framework. I’ve not done this before and had deploy pipelines break when Serverless bumps up a version and breaks one of our plugins. At the time of writing, this the version is 1.64.1.
Finally, we set the stage and region configs with some defaults of local and us-east-2. These are set as CLI arguments (optional) during deploy.
Lastly, we configured some global IAM role statements for the service. (These are the only global roles we’ll set. Everything else we’ll set at a per-function level).
Note: You’ll want to comment out the global tables for your initial multi-region deploy. This plugin may not work as you expect. The reason this may fail a deploy is outlined here.
Module Aliasing
The last thing to configure is module aliasing. It really blows my mind people are building Node apps in 2020 without using module aliasing. Relative import paths are too fragile for me, so let’s set up some aliases.
We are going to set three defaults (src, test, and queries) just so someone can come in and know how to set them up in the future. We’ll also use an import from queries in our example handler to make sure TypeScript compiles and resolves correctly.
Now this is a bit messy, but let’s go set this up. It’s worth the effort.
First, let webpack know about module aliases and updating the resolve object.
resolve: {
extensions: ['.mjs', '.json', '.ts'],
symlinks: false,
cacheWithContext: false,
alias: {
'@src': path.resolve(__dirname, './src'),
'@queries': path.resolve(__dirname, './queries'),
'@tests': path.resolve(__dirname, './tests'),
},
},
Next, we’ll let our .tsconfig know about the module aliasing by updating the compilerOptions path (we can also refer to the tsconfig gist earlier).
"paths": {
"@src/*": ["src/*"],
"@queries/*": ["queries/*"],
"@tests/*": ["tests/*"]
}
Lastly, we’ll let ESLint know about it so we don’t get pesky linting errors when we alias. (Again, this was done in the earlier gist if you just copied that.)
"settings": {
"import/resolver": {
"alias": {
"map": [
["@src", "./src"],
["@tests", "./tests"],
["@queries", "./queries"]
],
"extensions": [
".ts",
".js"
]
}
}
}
OK, time to make sure this is configured correctly.
Lets go create a /queries directory and add queries/exampleQuery.ts to validate our aliasing. We’ll make this module as simple as can be to test the compilation still works.
export const echo = (sound: string): string => sound;
We’ll just take a parameter and return it straight back. We’ll get a compile time error if this doesn’t work.
Now in src/handler.ts, lets import this module with the alias we set up and try to use this in our response. Let’s update the message in our response.
import { APIGatewayProxyHandler } from ‘aws-lambda';
import { echo } from ‘@queries/exampleQuery';
import 'source-map-support/register’;
export const hello: APIGatewayProxyHandler = async (event) => ({
statusCode: 200,
body: JSON.stringify({
message: echo(‘Module aliasing is really the best’),
input: event,
}, null, 2),
Using the aliasing makes refactoring so much simpler. Your IDE should also be smart enough to autoimport with aliases (WebStorm is anyway).
And that’s about all there is to it. You should be good to start writing code and build out your services with all the goodness of TypeScript, ESLint, and Jest. I’ll cover how to use some of the powertools and how to set up SQS, SNS, Kinesis, and DynamoDB in a future post.
You can find this entire starter template on my GitHub.
For information on how to deploy this project to your AWS account by configuring IAM roles, read here.
Top comments (9)
I found this article incredibly helpful! Been using Serverless framework for a while but this will be my first time incorporating typescript.
Quick question: what is the
queries
directory for? Seems like it is code that could live insrc
. Is there a reason for keeping that separate?That's a mistake on my part. Should be src/queries
Great article and opened my eyes to a couple of really useful serverless plugins.
I'm curious, have you managed to get local debugging of lambdas working in serverless-offline in a webpack bundled TypeScript application?
I'm using VSC, which I guess from your comment about aliasing you're not. But I just cannot get the debugger to step through my TS files, despite having sourcemaps set up. Only setting the breakpoint in the compiled JavaScript beneath .webpack works.
What about serverless-plugin-typescript, serverless.com/plugins/serverless-..., would it make mush easier this configuration?
serverless-plugin-typescript seems to be dead since there has not been an update since 13 months ago. Correct me if I am wrong.
Great article Michael, congrets!
Now when you use sls create --template "aws-nodejs-typescript", this create a serverless.ts instead of serverless.yml. Somebody know how to create a yaml file instead of .ts?
Its a update, with this new format you canuse typescript types the config file, its cool but for now there are less information that using .yml
Nice post! FWIW serverless-bundle plugin handles a lot of the setup for you!
Nice article Michael !!! I noticed you are using middleware, how do you go about typing your event, context when middleware is modifying it. This is one area where I'm struggling with Typescript.