Skip to main content

Command Palette

Search for a command to run...

Improving Developer Experience with TypeScript: How to Write Strongly Typed AppSync Resolvers

AppSync Resolvers in TypeScript, without Lambda

Updated
6 min read
Improving Developer Experience with TypeScript: How to Write Strongly Typed AppSync Resolvers
B

👨‍💻 Sofware Engineer · 💬 Consultant · 🚀 Indie Maker · ⚡️ Serverless

🔧 serverless-appsync-plugin · serverless-appsync-simulator · middy-appsync

🐈 and 🍵

AWS AppSync is a fully managed service that simplifies the process of building GraphQL APIs by handling the heavy lifting of securely connecting to data sources like AWS DynamoDB, AWS Lambda, and more. With AppSync, developers can easily create scalable and real-time applications.

In a previous article, I showed how you can bundle AppSync JavaScript resolvers and improve your Developer Experience by writing reusable code. In this issue, we'll see how we can take it one step further: write AppSync JavaScript resolvers in TypeScript.

TypeScript is a superset of JavaScript that adds static typing and other features to the language. Together with GraphQL, which is also typed, they are the perfect duo.

Here is what we'd like to achieve:

  • Auto-generation of TypeScript types from a GraphQL SDL (Schema Definition Language)

  • Use the generated types along with generic AppSync types in resolver handlers

  • Transpile and bundle TypeScript into valid AppSync JavaScript code (APPSYNC_JS runtime)

  • Deploy an AppSync API with the TypeScript CDK

💡 You can find the complete code used in this article on GitHub

TypeScript Codegen

With the help of the GraphQL codegen tool, you can easily generate TypeScript types from GraphQL schemas. I previously wrote about how you can generate TypeScript types for AppSync Lambda resolvers. The good news is that the process is exactly the same; so I won't go into details here. If you want to know more, please refer to that article.

TL;DR;

Given this GraphQL schema and this codegen file, we obtain the generated types after running the following command:

graphql-codegen

Side note: An alternative option is to use the Amplify CLI.

💡 Tip: I recommend that you commit the generated type files to the repository. Just don't forget to re-generate them when your schema changes!

Writing Resolvers In TypeScript

Out of the box, the AppSync utils package (@aws-appsync/utils) comes with a very convenient set of types that we can use as a base for our resolvers.

One of them, and probably the most relevant one, is Context, which provides the definition for the first argument of the request and response handler functions (usually named context or ctx).

This type takes arguments to specify the shape of the different use case-specific properties, namely: args (or arguments), stash, prev, source and result. We can use it combined with the types we generated earlier from the schema to unlock all the benefits of type-safety and IntelliSense.

Let's take an example. This is the createPost resolver from my demo project.

import { Context, DynamoDBPutItemRequest, util } from '@aws-appsync/utils';
import { createItem } from '../lib/helpers';
import { Post, MutationCreatePostArgs } from '../types/appsync';

export function request(
  ctx: Context<MutationCreatePostArgs>,
): DynamoDBPutItemRequest {
  // add timestamps
  const item = createItem(ctx.args.post);

  return {
    operation: 'PutItem',
    key: {
      id: util.dynamodb.toDynamoDB(util.autoId()),
    },
    attributeValues: util.dynamodb.toMapValues({
      publishDate: util.time.nowISO8601(),
      ...item,
    }),
  };
}

export function response(ctx: Context<MutationCreatePostArgs, object, object, object, Post>) {
  return ctx.result;
}

The usage of Context<MutationCreatePostArgs> in the request handler's argument provides us with a fully typed context object, where args corresponds to the mutation's input type generated from the SDL (in this case it's MutationCreatePostArgs).

Additionally, note that the request handler returns the DynamoDBPutItemRequest type, which is also provided by the AppSync utils package and defines the schema for a DynamoDB resolver's PutItem request.

Finally, in the response handler, I used Context<MutationCreatePostArgs, object, object, object, Post> to specify the type of the result property.

You'll find type declarations for all the available Data Sources, built-in utilities, and more!

Transpiling and Bundling

Writing AppSync resolvers in TypeScript greatly improves Developer Experience, but you can't just send TypeScript code to AppSync. Just like when you write Lambda functions in TypeScript, you need to transpile them to JavaScript first. For AppSync resolvers, the code also needs to be bundled.

Unfortunately, as of writing these lines, the CDK is still incapable of transpiling and bundling code automatically. Hopefully, this will improve over time, but for now, we'll have to do it manually.

For that, we're going to use esbuild. If you read the previous article about bundling AppSync JavaScript resolvers, the command is almost the same, except that in this case, esbuild will also take care of transpiling TypeScript to JavaScript.

Let's review the command:

esbuild src/resolvers/*.ts \
  --bundle \
  --outdir=build \
  --external:"@aws-appsync/utils" \
  --format=esm \
  --platform=node \
  --target=esnext \
  --sourcemap=inline \
  --sources-content=false
  • src/resolvers/*.ts takes every file ending in *.ts in the src/resolver folder as entry points.

  • --bundle bundles every entry point into one single file.

  • --outdir=build is where the output is written. Each entry file will generate one output.

  • --external:"@aws-appsync/utils" ignores the AppSync utils package from the bundle (It is provided by AppSync at runtime).

  • --format=esm generates the output as ES modules (this is an AppSync requirement).

  • --platform=node AppSync runs in an environment similar to NodeJS (i.e. not a browser)

  • --target=esnext AppSync uses the latest ES module version.

  • --sourcemap=inline AppSync supports source maps, but they must be inline in the bundled file.

  • --sources-content=false Do not include content in the source map.

ℹ️ Source maps are optional, but they are useful if you want to see references to your source files in logs and runtime error messages.

Deploying

Using the esbuild command works and is fine for testing and debugging purposes, but ideally, we'd like the code to be bundled automatically at deployment time. In this demo, I'm going to use the CDK (the TypeScript version of course).

Luckily, esbuild comes with a JavaScript API. We're going to use it in order to pre-build the resolvers directly from the CDK stack. Let's first write a helper function:

export const bundleAppSyncResolver = (entryPoint: string): Code => {
  const result = esbuild.buildSync({
    entryPoints: [entryPoint],
    external: ['@aws-appsync/utils'],
    bundle: true,
    write: false,
    platform: 'node',
    target: 'esnext',
    format: 'esm',
    sourcemap: 'inline',
    sourcesContent: false,
  });

  return Code.fromInline(result.outputFiles[0].text);
};

The function takes a file path and calls esbuild's buildSync method with the same parameters as the command we executed earlier. We also add write: false, which just asks esbuild to return the code as a value, instead of writing it to disk. Finally, we return the transpiled and bundled code inline.

We can now use it in our stack definition:

// createPost resolver function
const createPost = new appsync.AppsyncFunction(this, 'CreatePost', {
  name: 'createPost',
  api,
  dataSource: postsDS,
  // transpile, bundle and pass the code inline
  code: bundleAppSyncResolver('src/resolvers/createPost.ts'),
  runtime: appsync.FunctionRuntime.JS_1_0_0,
});

You can find the complete code of the stack on GitHub.

Deploying the stack works as usual:

cdk deploy

ℹ️ Inline code is limited to 4KiB. If you hit that limit, you can pre-budle your code using the esbuild CLI, and point to the generated js file: Code.fromAsset('build/resolvers/createPost.js').

Conclusion

By using TypeScript to write AppSync resolvers, developers can benefit from static typing and enjoy IntelliSense capabilities without the need of relying on Lambda functions. The use of codegen effortlessly provides all the types that are specific to the AP's schema, which can later be used in combination with built-in AppSync types. And with the help of esbuild, the code can easily be transpiled and bundled into valid APPSYNC_JS runtime.

S

Is there no way to completely fix the problem? I don't want such troubles to happen while the process is being done.

J
Janusz2y ago

Interesting article, although the statement that "AppSync uses the latest ES module version" is simply untrue and can lead to issues when working on something more than hello world. AWS docs clearly say that APPSYNC_JS supports only a subset of ES 6 functionality, and ES 6 = ES2015, after 2015 we had 17, 18, 19, 20, 21, 22, 23 & 24, so it's doesn't even come close to ESNEXT, which basically means "take latest".

Unless we get real implementation of APPSYNC_JS target for ESBuild we can only try to make things work. My suggestion would be to:

  1. Switch target to ES6
  2. Add one more step after the code gets transpiled that runs eslint configured with restricted AppSync syntax to make sure everything is up to the standard after transpilation

Unfortunately it's not a seamless experience as you can still stumble upon functionality that will transpile to something that can't work, but you would at least get clear error during build step when it happens.

3
B

Thanks for all the details Janusz.

That statement looks misleading, indeed, and I could clarify this point.

However, the AppSycn documentation recommends esnext as the target. (Well, they don't expressly recommend it but this is what they show in the examples).

https://docs.aws.amazon.com/appsync/latest/devguide/resolver-reference-overview-js.html#additional-utilities

When I first experimented with TS resolvers (even before the aforementioned doc was written), I experienced issues with most non-esnext targets, even those that were supposed to be compatible. esnext was the only one that worked every time.

I am not a pro at transpiling and EsBuild, but my experience showed that using "esnext" as the target ensures that the transpiler will usually not try to "transform" any of your code. When using TypeScript, it merely removes any typing and keeps everything else as-is. It also bundles everything in a single file. Which is exactly what I want.

It is the responsibility of the developer to ensure that the code will be APPSYNC_JS compatible after that.

To avoid bad/incompatible code, I use the eslint plugin at development time. And if for any reason my code would still be incompatible, I would also get a warning from CloudFormations/AppSync's API.

with that said, I'd be happy to hear your experience with using other targets (es6) if you have tried it.

Thanks for commenting!

6
J
Janusz2y ago

Hey, thanks for the answer!

Recently I've been considering different approaches for my new typescript lambda projects and some of those considerations revolved around choices of commonjs / module project type, ES lib & build targets, node version, ts, prettier & eslint configurations and then compatibility of all of the above with DI, validation & testing libraries, CDK deployments etc. As you probably know, dealing with this stuff can push relatively sane person to the edge of insanity, so I may've been a bit sensitive about the topic when I saw "ESNEXT" recommendation in your article. So I'm sorry if I sounded hostile / attacky!

For now I was only considering AppSync as one of the options for new project we're working on within the company, so I just started playing with it and naturally landed in APPSYNC_JS, but before going through overview I went straight to compatibility and found this: https://docs.aws.amazon.com/appsync/latest/devguide/resolver-util-reference-js.html and then your article which immediately got me triggered as something was off.

Nevertheless it seems like you're right, and since AWS is posting those sorts of examples we may assume it's recommended way, even though it may seem to potentially contradict their own documentation (at least to my understanding, I may not be expert enough to be the judge here).

Off topic curiosity: beside looking into AppSync I also started checking out VTL mapping for both AppSync & API Gateway and man- if you didn't see documentation wild west then this is the place, ton of undocumented behaviors & limitations which answers to can be found everywhere but official docs.

Thanks for clarifying and have a good day, Cheers!

D

Thank you for the tutorial, really amazing. I am getting __filename is not defined error on deploy. Any thoughts?

B

It's hard to tell without any more context / code. when does this error show up?

D
Dev Dev2y ago

Thanks for the tutorial I just tried this out in my CDK Typescript project and it deploys fine, but when I try calling the endpoint Appsync throws "Unable to transform the template"

import esbuild from 'esbuild';

export function bundleResolver(entryPoint: string): string { const result = esbuild.buildSync({ entryPoints: [entryPoint], bundle: true, write: false, platform: 'node', target: 'esnext', format: 'esm', sourcemap: 'inline', sourcesContent: false, });

if (result?.outputFiles && result?.outputFiles?.length > 0 && result?.outputFiles[0]?.text) { return result.outputFiles[0].text; } else { throw new Error('Bundling failed: No output files generated'); } }

B

Hi,

I have never seen that error. The first thing I would do is to double-check what code was generated and if it looks correct.

I might be wrong, but "Unable to transform the template" looks like a VTL error. Make sure you use the code property in the CDK and enable JS runtime.