Writing Object-Oriented Typescript Code for AWS Lambda
It’s been a couple months since I entered the world of AWS Lambda. I condensed my little experience in this tutorial post for all of you not familiar with it.
Podés leer este post en español aquí.
It’s been a couple months since I entered the terrifying world of AWS Lambda. At that time I only had about three months of total job experience, most of which I spent praying that there would always be just enough documentation to help me solve any problem. If you are familiar with AWS Lambda, I hope you got a good laugh.
And if you’re not familiar, you’ve come to the right place! I condensed my little experience in this post so I could buy you a couple more days’ worth of peace of mind. Here you will find a walkthrough from downloading everything Serverless related to creating a Typescript class that reuses code in its handler methods.
Enjoy!
Installation
For this tutorial we are going to use the Serverless Framework. Before writing this post I had never really searched if there are alternatives to it (besides coding directly in the AWS Lambda dashboard), and even now I didn’t find anything that caught my eye. This framework has covered anything that we’ve needed for my current project.
The first two steps from the little tutorial box in the Serverless Framework page are the perfect place to start.
# Step 1. Install serverless globally
$ npm install serverless -g
# Step 2. Create a serverless function
$ serverless create --template hello-world
Once you run the serverless create
command, you’ll notice a few files pop in your directory. We’ll check them out in a minute. First there are a couple more things that you’ll need to install:
Serverless Offline Plugin. This will make Step 3 of the Serverless Framework tutorial unnecessary for now, as it will allow us to run our app on our local machine.
As we want to use Typescript, we will also need these dependencies:
Typings for AWS Lambda: Normally when you incorporate a JS library into a Typescript project, you will also need suitable types for all the Classes, Interfaces and Methods the library provides. This is the case with AWS. Not only you will have access to a lot of information about the things you use, but you won’t have to resort to bypassing TypeErrors with @ts-ignore like I did.
Serverless Plugin Typescript. Under normal circumstances, we would have to transpile our Typescript code into Javascript in order for our application to be able to run it. This implies remembering to transpile every time you change something. This plugin makes our Typescript files readable by the Serverless Framework, which saves us minor headaches.
Now it’s time to check out those files.
The Serverless File
The serverless.yml
file basically serves as the identification papers of our app: its name, the provider it uses (AWS Lambda in our case), the functions it contains… everything will be registered here. Most of the time you spend playing with this file will be to add more functions. But for now, the helloWorld
one is enough.
serverless. Read the docs
# https://serverless.com/framework/docs/
# Serverless.yml is the configuration the CLI
# uses to deploy your code to your provider of choice
# The `service` block is the name of the service
service: serverless-hello-world
# The `provider` block defines where your service will be deployed
provider:
name: aws
runtime: nodejs6.10
# The `functions` block defines what code to deploy
functions:
helloWorld:
handler: handler.helloWorld
# The `events` block defines how to trigger the handler.helloWorld code
events:
- http:
path: hello-world
method: get
cors: true
One thing to make clear is that this file is paradigm-agnostic. This is an object-oriented tutorial, but that doesn’t mean we’ll have to change the way this file is written. Think of it as the interface that AWS Lambda expects from our code.
The handler file
This is the default handler that the Serverless Framework makes for us. There are two things about it that are worth noticing:
- It receives an event, a context and a callback.
- It returns an object with a statusCode number, a headers object, and a body string.
'use strict';
module.exports.helloWorld = (event, context, callback) => {
const response = {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*', // Required for CORS support to work
},
body: JSON.stringify({
message: 'Go Serverless v1.0! Your function executed successfully!',
input: event,
}),
};
callback(null, response);
};
import {APIGatewayEvent, APIGatewayProxyResult, Context} from "aws-lambda";
export async function helloWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
message: 'Go Typescript',
input: event
})
}
}
I went ahead and wrote a very-similar version of it in Typescript. With these two side by side, I want to show you a couple things. Firstly, you must have noticed that the callback
parameter is no more. It turns out that AWS Lambda calls it for us implicitly, so there is no need to include it ourselves. And secondly, the typings involved.
You’ll notice that my event is an APIGatewayEvent
. Ninety percent of the functions I’ve written have an event of this type. It means that AWS Lambda will only trigger this function when an HTTP request is made to it. Specifically, a GET request to the hello-world path, which is the one that is specified for this function in the serverless.yml file.
Eventually, you may use other event types, such as SNSEvent
1 or S3Event
2, depending on the case.
The return type of the function is also subject to change. I’m returning an APIGatewayProxyResult
here, but with SNS functions for example you have to return void3.
Now that the callback was removed, the signature and return type will stay the same for the duration of this post, so I want to take this opportunity to write a small test.
A single test
I’m going to use Jest for this, just because I think the framework’s name is neat.
import {helloWorld} from "./handler";
import {APIGatewayEvent, Context} from "aws-lambda";
test('helloWorld handler returns a 200 statusCode and an inputted event', async () => {
const event = {
body: 'Test Body'
} as APIGatewayEvent
const context = {} as Context
const result = await helloWorld(event, context)
const parsedResultBody = JSON.parse(result.body)
expect(result.statusCode).toBe(200)
expect(parsedResultBody.input).toStrictEqual(event)
})
This test is the one we’ll rely on for transforming our handler function into a Class. No matter what we do, we should end up with something that makes this test pass too.
Building our class
Regardless of the approach we choose to structure our code, be it functional or object-oriented, it is unavoidable that we export something at the end, or else our Serverless file won’t be able to use it. In this new world where all of our methods need a Handler
instance in order to be called, we have to provide said instance ready at all moments. The best way we’ve found to do this is declaring a handler
constant, a single instance of our Handler class. Then we declare exported constants; each of them will contain a method from the handler
instance.
import {APIGatewayEvent, APIGatewayProxyResult, Context} from "aws-lambda";
class Handler {
public async helloWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
message: 'Go Typescript!',
input: event
})
}
}
}
export const handler = new Handler()
export const helloWorld = handler.helloWorld
The test passes and everything is fine. We can serve the application and we’ll see that it works without modifying the Serverless file, just like I told you.
But our class is kind of lacking in content. We're not seeing how we can take advantage of this structure to reuse code yet. So let's add another handler method:
import {APIGatewayEvent, APIGatewayProxyResult, Context} from "aws-lambda";
class Handler {
public async helloWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
message: 'Hello world',
input: event
})
}
}
public async goodbyeWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
message: 'Goodbye world!',
input: event
})
}
}
}
export const handler = new Handler()
export const helloWorld = handler.helloWorld
export const goodbyeWorld = handler.goodbyeWorld
This creepily-named new handler method will serve just fine to illustrate my point. It returns exactly the same response, except for the message.
Looking at both methods side by side, it doesn't make much sense to repeat most of the response object. We can make this better by creating a ResponseBuilder object that gives us the desired response depending on the message we want to display.
import {APIGatewayEvent} from "aws-lambda";
export class ResponseBuilder {
public getResponse(message: string, event: APIGatewayEvent) {
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
message: message,
input: event
})
}
}
}
import {APIGatewayEven, APIGatewayProxyResult, Context} from "aws-lambda";
import {ResponseBuilder} from "./ResponseBuilder";
class Handler {
private responseBuilder: ResponseBuilder
constructor() {
this.responseBuilder = new ResponseBuilder()
}
public async helloWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
return this.responseBuilder.getResponse('Hello world', event)
}
public async goodbyeWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
return this.responseBuilder.getResponse('Goodbye world', event)
}
}
export const handler = new Handler()
export const helloWorld = handler.helloWorld
export const goodbyeWorld = handler.goodbyeWorld
The code looks much prettier now! There is a little issue, however. You'll find out about it by running your helloWorld test:
Context binding
If you call an instance method inside a handler you’re likely to get an error like the one above. It is a well known side effect of passing functions as value. Because they are being used in isolation through dynamic binding, they lose the calling context. Luckily, this can be easily solved with context binding:
export const handler = new Handler()
export const helloWorld = handler.helloWorld.bind(handler)
With this we are basically telling our method that every time this
appears in the code, it should refer to the instance we’ve defined. This will ensure that they are always associated to the right context.
If you don’t like this approach, you can also write the handler methods as arrow functions:
public helloWorld = async (event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> => {
return this.responseBuilder.getResponse('Hello world', event)
}
public goodbyeWorld = async (event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> => {
return this.responseBuilder.getResponse('Goodbye world', event)
}
And that is it. Whatever you build shouldn’t give you more trouble than this little example. As a parting gift from this post, I leave you a guide to figuring out the Deploying of your app.
Thanks for reading!
1. SNSEvent: https://docs.aws.amazon.com/lambda/latest/dg/with-sns-example.html.
2. S3Event: https://docs.aws.amazon.com/lambda/latest/dg/with-s3.html.