Home > Essays

GraphQL and Typescript Types

I've identified three distinct classes of types in a GraphQL service.

  1. Data coming through the front door (your GraphQL API)
  2. Data from other services (RPC data)
  3. Data structures used to translate between types (1) and (2)

GraphQL gives you strong type guarantees out of the box with its schema. If you pair that with something like Nexus, typescript's type system is aware of these types and can do type-checking on your arguments and the values you return from your resolvers.

Likewise, typescript gives you strong guarantees about (3). Where the type system falls short is handling data from other services. Unless you are using a strongly typed protocol for RPC (i.e. talking to another GraphQL service, gRPC, or apache-thrift) the structure of the data you get back from an RPC call is unknown. Most of us use JSON for RPC, and typescript doesn't have a built-in JSON type (JSON.parse returns any).

Originally I used any for these types, which turned off type checking. But I observed several cases where the payload coming over RPC didn't match my expectations and I hadn't properly sanitized the data, causing an exception to be thrown.

Next, I tried using unknown for these types. But type narrowing an unknown object is a big pain.

Finally, I settled on a custom set of JSON types (borrowed from niedzielski):

      
export type JsonPrimitive = string | number | boolean | null
export type JsonValue = JsonPrimitive | JsonObject | JsonArray
export type JsonObject = { [key: string]: JsonValue }
export type JsonArray = JsonValue[]
      
    

These types allow me to use type narrowing on the object and will raise type errors if I don't properly verify the incoming data I'm working with. This makes it easy to know that the types I'm getting over the wire match up correctly with the types I'm sending back out in GraphQL. To wrap things up, here is an example resolver:

      
import { JsonObject } from "../json-types";
import { request } from "undici";
import { objectType, idArg, queryField } from "nexus";
import type { NexusGenObjects } from "./nexus-typegen";

const User = objectType({
  name: "User",
  definition(t) {
    t.id("id");
    t.string("name");
    t.string("email");
  },
});

const getUserById = queryField("getUserById", {
  type: User,
  args: {
    id: idArg(),
  },
  resolve: async (_, args) => {
    const { id } = args;
    const { statusCode, body } = await request(
      `http://localhost:3000/user/${id}`
    );
    if (statusCode !== 200) {
      return null;
    }

    const remoteUser = (await body.json()) as JsonObject;
    const localUser: NexusGenObjects["User"] = { id };

    localUser.name = [
      remoteUser.firstName,
      remoteUser.middleName,
      remoteUser.lastName,
    ]
      .filter((s) => s)
      .join(" ");

    if (typeof remoteUser.emailAddress === "string") {
      localUser.email = remoteUser.emailAddress;
    }

    return localUser;
  },
});
      
    

What do you think?

How do you handle untrusted data in Typescript? Do you have a favorite pattern for sanitizing JSON input? I'd love to hear from you: [email protected]