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

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

AppSync Resolvers in TypeScript, without Lambda

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.