Mastering Type Safety in AWS AppSync: Creating Typed Lambda Resolvers with TypeScript

Mastering Type Safety in AWS AppSync: Creating Typed Lambda Resolvers with TypeScript

In this article, I will show you how to generate TypeScript types of your GQL schema. We will then connect them to an AppSync Lambda resolver. This will give you better DX and type safety as you write your logic.

TLDR

Here is an example repository built with SST where you can see everything in action.

Use the following code to generate types with GraphQL Code Generator that your Lambda can use:

overwrite: true
schema:
  - "./graphql/schema.graphql"
  - "./appsync.graphql"
generates:
  ./packages/functions/graphql/types/resolvers-types.ts:
    plugins:
      - add:
          content: "/* eslint-disable */\n\nimport { AppSyncResolverEvent } from 'aws-lambda';"
      - typescript
      - typescript-resolvers
    config:
      contextType: ../lambda#Context
      customResolverFn: "(event: AppSyncResolverEvent<TArgs, TParent>, context: Context ) => Promise<TResult> | TResult"
      avoidOptionals:
        - defaultValue: true
      enumsAsTypes: true
      maybeValue: "T extends PromiseLike<infer U> ? Promise<U | null | undefined> : T | null | undefined"
      mappers:
        Me: Partial<{T}>
        User: Partial<{T}>

Use the types like this for your resolvers:

import { Resolvers } from "../types/resolvers-types";

export const userResolvers: Resolvers = {
  Query: {
    user: async ({ arguments: { id } }) => {
      logger.info("Fetching user", { id });

      const user = await getMyUser({ userId: id });

      if (!user) {
        throw new Error("User not found");
      }

      return user;
    },
  },
};

The Resolver type knows exactly what is in our schema and TS can use it to make suggestions:

Let's discuss this in detail and see how we can connect everything.

Lambda Resolvers in AppSync

Let's start with some basics. AWS AppSync is a managed GraphQL service provided by Amazon Web Services (AWS) that facilitates the development of flexible, scalable, and real-time applications. It lets developers quickly design, build, and deploy interactive GraphQL APIs. These APIs can manage and manipulate data across many sources efficiently. AppSync offers the possibility to connect different resources for data fetching and resolving, such as AWS Lambda, Amazon DynamoDB, Amazon OpenSearch, and many others, allowing for a highly scalable and flexible backend architecture.
You may already guess: these options are great for simple CRUD. But, adding more complex business logic with context almost always requires a Lambda resolver. Most of the time (and also because I hate to write VTL), I use a single Lambda function as my backend for AppSync, resolving all the types from there.

A problem I often encountered was typing and type safety within the lambda function. Some examples already show how to create strongly typed JS resolvers for AppSync. In this article, I want to show how we can create strongly typed lambda resolvers that can resolve all fields, if required, for your AppSync API.

Setting up the Environment

Getting everything to work requires a couple of packages. For the code generation, we need some packages from graphql-codegen:

These packages will create typings based on our GQL schema. The @graphql-codegen/typescript package generates the TS types, and the @graphql-codegen/typescript-resolvers generate the resolver mappings for our types and resolver function definitions.

To connect the types with our lambda, we need:

These packages provide the types for the AppSync event that will be sent once AppSync invokes the lambda for field resolving and some GQL-specific types needed in the generated TS types.

Lastly, we want to have all resolvers in a single variable while keeping the possibility to split code across various files:

This package merges resolver definitions imported from different files into a single resolver that holds all the resolvable fields we have defined for our schema.

Now that we have all the tools, we can provide codegen with the required information to get our typed AppSync resolver based on our schema.

Implementing a Type-Safe Lambda Resolver

Codegen is superb when it comes to customizability. We will make use of that here as well. The configuration that allows us to generate the types we need is the customResolverFn config key. Here, we can define what the resolver function should look like in the generated types. In our case, we want to make use of the existing AppSyncResolverEvent type that is provided by @types/aws-lambda:

export interface AppSyncResolverEvent<TArguments, TSource = Record<string, any> | null> {
    arguments: TArguments;
    identity?: AppSyncIdentity;
    source: TSource;
    request: {
        headers: AppSyncResolverEventHeaders;
        /** The API's custom domain if used for the request. */
        domainName: string | null;
    };
    info: {
        selectionSetList: string[];
        selectionSetGraphQL: string;
        parentTypeName: string;
        fieldName: string;
        variables: { [key: string]: any };
    };
    prev: { result: { [key: string]: any } } | null;
    stash: { [key: string]: any };
}

This represents how AppSync will invoke our Lambda function. We can see different parameters provided in the event. The definition is generic, so we can customize it to specific fields that should be resolved with the incoming arguments, if any.

A simple typed resolver could now look like this:

export const QueryUserResolver = async (
  ctx: AppSyncResolverEvent<{ id: string }, null>
) => {
  const {
    arguments: { id },
  } = ctx;
  logger.info("Fetching user", { id });

  const user = await getMyUser({ userId: id });

  if (!user) {
    throw new Error("User not found");
  }

  return user;
};

As you can see, we have to type things manually, and if the argument in our schema changes, the definition here needs to be updated and updated.

We can now tell codegen to use this type as our base type for the parameters of our resolver function when generating the types:

plugins:
 - add:
 content: "/* eslint-disable */\n\nimport { AppSyncResolverEvent } from 'aws-lambda';"
 - typescript
 - typescript-resolvers
config:
 customResolverFn: "(event: AppSyncResolverEvent<TArgs, TParent>, context: TContext ) => Promise<TResult> | TResult"

The resulting resolver function looks like this:

// ../types/resolvers-types
export type ResolverFn<TResult, TParent, TContext, TArgs> = (event: AppSyncResolverEvent<TArgs, TParent>, context: TContext ) => Promise<TResult> | TResult

With this, we can tell our resolvers exactly how the incoming event will be shaped based on AppSync and have all possible resolvers auto-generated with all required information, such as the possible arguments.

Let’s See the Result And How We Can Use It

To get some gist about how this could look like, let's assume a GQL schema that holds a single User type with some fields and a query to get a user by their ID:

type User {
  id: ID!
  firstName: String!
  lastName: String!
}

type Query {
  user(id: ID!): User
}

After running graphql-codegen --config codegen.yml, our types are ready and can be used in our lambda code like this:

// ../resolvers/user.ts
export const userResolvers: Resolvers = {
  Query: {
    user: async ({ arguments: { id } }) => { // The Resolver type already defines what can be resolved and which arguments are provided 
      logger.info("Fetching user", { id });

      const user = await getMyUser({ userId: id });

      if (!user) {
        throw new Error("User not found");
      }

      return user;
    },
  },
};

The Resolvers function holds all the different possible resolvers we can define. This includes Query-resolvable fields for the user type, like firstName.

By using the type definition, we can get autocomplete suggestions for what is possible. Furthermore, all the arguments are also strongly typed.

Finally, to bring everything together and use it to resolve fields, we create a dynamic mapping of all our resolvers and resolve the correct field based on the incoming information provided by AppSync:

// lambda handler file 
import { mergeResolvers } from "@graphql-tools/merge";
import { AppSyncResolverEvent, Context } from "aws-lambda";
import { userResolvers } from "./resolvers/users";
import { ResolverFn, Resolvers } from "./types/resolvers-types";

// Create one single resolver variable that holds all the resolvers
// We have defined in different files 
// Add any other resolve definitions in the array 
const resolvers = mergeResolvers([userResolvers]);

export const handler = async (
  event: AppSyncResolverEvent<unknown, unknown>, // We do not know the incoming types
  context: Context
) => {
  const { fieldName, parentTypeName } = event.info; // the field and parent name: e.g user and Query

  logger.debug("Received event", { event });

  try {
    const typeHandler = resolvers[parentTypeName as keyof Resolvers]; // Use the keys of our resolver to check if we have a handler for the type

    if (fieldName in typeHandler) {
      // we do not know of which type the field here, as the type is dynamic based on the requested query/mutation
      const resolver: ResolverFn<unknown, unknown, Context, unknown> =
        typeHandler[fieldName as keyof typeof typeHandler];
      if (resolver) {
        return await resolver(event, context); // If a resolver is found in the mapping, use it to get the field 
      }
    }
    throw new Error(`Resolver not found for ${fieldName}, ${parentTypeName}`);
  } catch (e) {
    const error = e as Error;
    logger.error("Error happened", { error });
    throw error;
  }
};

That's it. You now have connected your schema to your lambda and can get correct typings everywhere in your code, which improves DX significantly 🎉

Conclusion

With the little trick of providing a custom resolver function for the typescript-resolvers plugin of codegen, we can combine the existing definition for an AppSync event with the generated types based on our schema and use it to strongly type our lambda resolver.

That's it.

See you next time 👋