Getting Started With Serverless and TypeScript

If you're doing any "serverless" development on AWS Lambda, you should definitely check out the similarly named Serverless framework. Serverless makes it much easier to build manageable applications with this sort of architecture, taking away a lot of the painful things you would otherwise need to manage and automate yourself.

As you write these event-driven systems, a lot of your functions end up looking like data pipelines - your function receives some input, processes or transforms it as appropriate, and outputs something that might be consumed by a user or even another function. These functional, stateless systems really benefit from having some static type checking, which is where TypeScript can really shine. In this post I'll show how to get started writing a basic Serverless application using TypeScript, and even layer in things like linting and testing that you'd ultimately want in any real application.

Initial Project Setup

If you don't already have Serverless installed, go ahead and do so via NPM:

npm install serverless -g

Once that is installed you will be able to use the serverless command (or optionally sls or slss as shorthand) to invoke different commands for the framework. To start, let's create a new Node app:

sls create -t aws-nodejs

This will create a few files for you:

  • handler.js: the function's implementation
  • serverless.yml: the function's configuration
  • event.json: sample function input for local testing

This is obviously a JavaScript implementation, so now we can start replacing it with TypeScript. First, go ahead and install a few NPM packages:

npm i --save-dev typescript webpack ts-loader serverless-webpack

If you're familiar with using Webpack on other platforms, such as the web where it's most common, then this should feel pretty familiar. Serverless provides a nice extensibility model, which helps enable us to leverage the same Webpack model we use elsewhere. Next, create a webpack.config.js file to configure Webpack:

var path = require('path');

module.exports = {
  entry: './handler.ts',
  target: 'node',
  module: {
    loaders: [
      { test: /\.ts(x?)$/, loader: 'ts-loader' }
    ]
  },
  resolve: {
    extensions: ['.ts', '.js', '.tsx', '.jsx', '']
  },
  output: {
    libraryTarget: 'commonjs',
    path: path.join(__dirname, '.webpack'),
    filename: 'handler.js'
  },
};

This is boilerplate Webpack, passing TypeScript files through the TypeScript compiler and outputting a JavaScript file. We can also add a tsconfig.json file to configure TypeScript:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs"
  },
  "exclude": [
    "node_modules"
  ]
}

With that ready to go, let's update serverless.yml:

service: serverless-typescript-demo

provider:
  name: aws
  runtime: nodejs4.3

plugins:
  - serverless-webpack

functions:
  hello:
    handler: handler.hello

    events:
     - http:
         path: hello
         method: get

There are a few things going on in here:

  • service defines the name for this service
  • plugins defines the plugins we want to use, which in our case is the Webpack plugin
  • functions lists out all functions for this service
  • hello is defined as a function, specifies its handler method, and also connects a GET /hello HTTP endpoint to the function

With the plugin added, running the serverless command will now show you a few new commands added by the Webpack plugin. Before we do that, the last thing we need to do is create the TypeScript version of the handler. Rename handler.js to handler.ts, and replace the contents with:

export function hello(event, context, callback) {
  callback(null, { 
    message: 'Hello from TypeScript!', 
    event 
  });
};

The function's handler receives three parameters here:

  • event: the payload for the event that triggered the function
  • context: information about the context in which the function is running
  • callback: a callback that must be invoked by the function to say that the function has completed, and also specify success or failure

In this case we simply return a successful invocation, where the response payload contains a message string and also relays the event that it received.

You can go ahead and run this locally by running:

sls webpack invoke -f hello -p event.json

Go ahead and replace the default contents of event.json with something that matches the API Gateway event format:

{
  "method": "GET",
  "headers": {
    "host": "localhost:8000",
    "user-agent": "curl/7.49.1",
    "accept": "*/*"
  },
  "body": {},
  "path": {},
  "query": {
    "foo": "bar"
  }
}

You can also simulate API Gateway locally by running:

sls webpack serve

This will allow you to hit http://localhost:8000/hello to trigger your function. If you run sls deploy Serverless will deploy your function out to AWS and provide you with the endpoint that you can hit there as well.

Add Some Types

That's all well and good, but technically everything there was untyped. Let's go ahead and add some basic types here in a new file named models.ts:

export interface IResponsePayload {
  message: string;
  event: any;
}

export interface IQueryParameters {
  foo: string;
}

export interface IEventPayload {
  method: string;
  query: IQueryParameters;
}

export interface ICallback {
  (error: any, result: IResponsePayload): void;
}

Now we have some really basic types around the function's input and output, so let's update the handler to take advantage of that:

import { ICallback, IEventPayload } from './models';

export function hello(event: IEventPayload, context, callback: ICallback) {
  callback(undefined, {
    message: `Method: ${event.method}, Param: ${event.query.foo}`,
    event: event
  });
}

If you go ahead and invoke the function again you should see basically the same output as before, except that the message will reflect what you provide via the query string ?foo=. If you tweak the format string for message to try and use event.query.bar, you'll see the TypeScript compile step fail, just the way we want. These are obviously trivial types, but as you start to build out larger systems that deal with real events and data, these types can save you from a lot of mistakes that are otherwise easy to make.

Add Linting

If you're like me, you like to make sure you have some linting rules set up for all your JavaScript and TypeScript projects. In the spirit of setting this up like a real world project, let's layer that in. For this example I'll use the TSLint config we use at Olo:

npm i --save-dev tslint tslint-config-olo

We can add a tslint.json file to specify we'd like to use this config:

{
  "extends": "tslint-config-olo"
}

Finally, we'll add an NPM command named lint in package.json to run TSLint:

"scripts": {
  "lint": "tslint *.ts"
},

Now you can run npm run lint to lint all the TypeScript files in the current folder.

Add Tests

Finally, no application is complete without some tests, so let's set some up. You're welcome to use your favorite testing frameworks, of course, but for this example I'm going to use Karma, Mocha, and Chai:

npm i --save-dev mocha chai karma karma-typescript-preprocessor2 karma-webpack karma-chai karma-mocha phantomjs-prebuilt karma-phantomjs-launcher

Next, go ahead and create karma.conf.js which will configure the test runner and Webpack:

var webpackConfig = require('./webpack.config');

module.exports = function(config) {
  config.set({
    basePath: '',
    frameworks: ['mocha', 'chai'],
    files: ['tests.ts'],
    preprocessors: {
      'tests.ts': ['webpack']
    },
    webpack: {
      module: webpackConfig.module,
      resolve: webpackConfig.resolve
    },
    reporters: ['progress'],
    colors: true,
    logLevel: config.LOG_INFO,
    browsers: ['PhantomJS'],
    singleRun: true
  });
}

We can also install some typings for Mocha and Chai:

typings i --save dt~mocha --global
typings i --save chai

Next, let's add another NPM command to run the tests:

"scripts": {
  "test": "karma start"
},

Finally, we can add a basic test file named tests.ts:

import { hello } from './handler';
import * as chai from 'chai';
const expect = chai.expect;

describe('hello function', () => {
  it('processes the query string', done => {
    const requestEvent = {
      method: 'GET',
      query: {
          foo: 'bar'
      }
    };

    hello(requestEvent, {}, (err, result) => {
      expect(err).to.be.undefined;
      expect(result.event).to.equal(requestEvent);
      expect(result.message).to.equal('Method: GET, Param: bar');

      done();
    });
  });
});

Now if you run npm run test you should see one successful test.

Summary

This only scratches the surface of what you can do with Serverless, especially when paired with TypeScript, but hopefully you can start to see the potential here. Admittedly the linting and testing pieces are somewhat tangential here, but I really wanted to show how easy it is to get started building out a real-world application on top of Serverless while being able to leverage modern technologies like TypeScript and Webpack. The full source for this example is available on GitHub.