I've identified three distinct classes of types in a GraphQL service.
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;
},
});
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]