AWS SAM and Typescript: building functions and layers

This post is to address some issues when working with SAM using Typescript and Lambda Layers.

Recently, esbuild support in AWS SAM CLI was made generally available, this makes it very easy and fast to build Typescript functions. Some of the pros of using esbuild are:

But there are also a few cons:

If you decide to use this new feature you will need to be careful as you will be building different parts of your code in different ways (different compilers or defaults), the following is one of the errors that prompted this post:

"errorType": "Runtime.UserCodeSyntaxError",
"errorMessage": "SyntaxError: Unexpected token 'export'",

The main takeaway is that currently, by using esbuild with SAM for λ functions, there is no way to change the format option, which is forced to be “cjs”. When building layers, for which at the moment we must use a different build system through a Makefile, we should always remember to transpile our code to use “commonjs” modules.

This post will provide an example of a working configuration to avoid some of the common pitfalls.

Setting up the project

SAM init

We will start from the official AWS SAM Typescript template

sam init --runtime nodejs16.x --app-template hello-world-typescript --name sam-typescript-functions-layers --package-type Zip --dependency-manager npm --no-interactive

Refactoring

We plan on having multiple λ functions, so let’s create a new top-level folder named functions/ and move the hello-world/ folder there. We plan to achieve a folder structure similar to the following:

sam-typescript-functions-layers/
├── functions/
│   └── hello-world/
└── layers/
    └── commmons/

Adding a Typescript Layer

All of our λ functions will need a way to build a response object, we can use a Layer to share the following code as a module:

export const responseBuilder = (
  payload: any,
  statusCode: number = 200,
  headers: any = { 'Content-Type': 'application/json' },
) => {
  return {
    statusCode,
    headers,
    body: JSON.stringify({ payload }),
  };
};

We will create a Typescript Layer with the following folder structure:

layers/commons/
├── index.ts
├── Makefile
├── package.json
└── tsconfig.json
  1. Put the responseBuilder code shown before into a new file named index.ts

  2. Add the following configuration files:

    // package.json
    {
      "description": "Lambda layer with common utils",
      "main": "index.ts",
      "name": "layer-commons",
      "version": "1.0.0",
      "devDependencies": {
        "@types/node": "^16.18.23",
        "typescript": "^5.0.3"
      },
      "scripts": {
        "build": "node_modules/typescript/bin/tsc"
      }
    }

    we added Typescript and Type Definitions for Node.js as dependencies, and a build script to use tsc as the transpiler.

    // tsconfig.json
    {
      "compilerOptions": {
        "strict": true,
        "target": "es2021",
        "preserveConstEnums": true,
        "resolveJsonModule": true,
        "noEmit": false,
        "sourceMap": false,
        "module": "commonjs",
        "moduleResolution": "node",
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "outDir": "./commons"
      },
      "exclude": ["node_modules", "**/*.test.ts"]
    }

    Important: "module" should be set either to "node16" or "commonjs", otherwise it will not be compatible with the code of our Lambdas built with esbuild. "noEmit" also needs to be set to false.

  3. Create a Makefile to be used by SAM for transpiling the Typescript code and placing it inside node_modules:

    .PHONY: build-LayerCommons
    
    build-LayerCommons:
      npm install
      npm run build
      mkdir -p "$(ARTIFACTS_DIR)/nodejs/node_modules"
      cp package.json package-lock.json "$(ARTIFACTS_DIR)/nodejs/" # for runtime deps
      npm install --production --prefix "$(ARTIFACTS_DIR)/nodejs/" # for runtime deps
      rm "$(ARTIFACTS_DIR)/nodejs/package.json" # for runtime deps
      cp -r commons "$(ARTIFACTS_DIR)/nodejs/node_modules"

    this will put the transpiled code inside the /nodejs/node_modules/ folder of the layer alongside with the needed dependencies, so we will then be able to import the layer code in our functions as a Node.js module.

  4. Add the Layer inside Resources: in the global template.yaml file

    # Inside Resources
    LayerCommons:
      Type: AWS::Serverless::LayerVersion
      Properties:
        ContentUri: layers/commons/
        CompatibleRuntimes:
          - nodejs16.x
        RetentionPolicy: Delete
      Metadata:
        BuildMethod: makefile

Using the Layer inside a Lambda function

  1. Reference the new layer in the λ functions that are using it:

    # Inside HelloWorldFunction.Properties
    Layers:
      - !Ref LayerCommons

    And then declare the js module inside the Layer as external, so it will be omitted from the final bundle of the λ function build:

    # Inside HelloWorldFunction.Metadata.BuildProperties
    External:
      - commons
  2. We can now import this module in our .js code with:

    import { responseBuilder } from 'commons';

We are now able to use the code from the Layer in our Lambda functions.

Further documentation:

Here are a few links worth reading about the topics of this post: