Code-first GraphQL with Pothos

19 Sept 2022

Pothos is a plugin based GraphQL schema builder for TypeScript.

Pothos comes with a huge collection of plugins, but there's nothing needed to get started to benefit from type-safety than Pothos itself.

We'll be using GraphQL Yoga as our server. We'll pass the schema Pothos generates to Yoga, and serve that!

Let's first install Yoga and instantiate a default server.

npm install -E @graphql-yoga/node graphql

Now inside server.ts we'll import and invoke createServer, as well as calling .start():

import { createServer } from "@graphql-yoga/node";

const server = createServer({});

server.start();

Now let's install Pothos:

npm install @pothos/core

Then inside of server.ts we'll import and instantiate SchemaBuilder:

import SchemaBuilder from "@pothos/core";

const builder = new SchemaBuilder();

Then where we call createServer we'll pass important schema value. For this we can use .toSchema() function provided by Pothos schema builder:

const server = createServer({
  schema: builder.toSchema(),
});

Before we begin implementing any types, or "resolvers" for our GraphQL schema, we'll create some TypeScript types!

We'll be building a simple shopping cart API that responds to fetching a cart by ID, and adding items to the cart!

type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};

type Cart = {
  id: string;
  items?: CartItem[];
};

With these types we can tell Pothos about the different "objects", and "scalars" to expect from our schema. Here we assign the types for Cart and CartItem to the TypeScript types, and configure the scalar ID to use string type for its accessors:

const builder = new SchemaBuilder<{
  Objects: {
    Cart: Cart;
    CartItem: CartItem;
  };
  Scalars: {
    ID: { Input: string; Output: string };
  };
}>();

Now we'll add some dummy data for our API in the shape of carts above:

const CARTS = [
  { id: "1", items: [{ id: "1", name: "My item", price: 1000, quantity: 1 }] },
];

Now we'll define the objectType for our Cart type. We want to "expose" the ID of items as well as the items in the cart.

builder.objectType("Cart", {
  fields: (t) => ({
    id: t.exposeString("id"),
    items: t.field({
      type: ["CartItem"],
      resolve: (cart) => cart.items ?? [],
    }),
  }),
});

For items above we tell Pothos that this will be a GraphQL list by using the ["CartItem"] value.

We'll next create the objectType for the CartItem object. Similar to above we'll expose the actual values from the CARTS[x].items array above:

builder.objectType("CartItem", {
  fields: (t) => ({
    id: t.exposeString("id"),
    name: t.exposeString("name"),
    quantity: t.exposeInt("quantity"),
  }),
});

You'll notice we used exposeInt for the quantity field which tells Pothos this field is of the GraphQL scalar Int.

Finally we'll create the queryType for fetching a cart by id. We'll specify the field cart on the queryType, set it as nullable, set a required argument, and resolve data from the CARTS array where the id passed to the arguments match:

builder.queryType({
  fields: (t) => ({
    cart: t.field({
      nullable: true,
      args: {
        id: t.arg.id({ required: true }),
      },
      type: "Cart",
      resolve: (_, { id }) => {
        const cart = CARTS.find((c) => c.id === id);

        if (!cart) {
          throw new Error(`No cart with ID: ${id}`);
        }

        return cart;
      },
    }),
  }),
});

That's it! That's all we need to now run our GraphQL Yoga server:

npm run dev # ts-node-dev server.ts

Now we can head to http://localhost:4000/graphql and execute the following query to return our cart by ID:

{
  cart(id: "1") {
    id
    items {
      id
      name
      quantity
    }
  }
}

We can also explore the Documentation Explorer to see our query, and types. You'll notice there's no descriptions (because we haven't added any), but we can see which fields are nullable and non-nullable.

It would be useful for our shopping cart API to have a subTotal for the cart that returns the amount and formatted amount for all items in the cart, as well as the same values for cart items (for both the individual item, and line total — quantity x price).

We'll use the special objectRef method from Pothos to do this:

const Money = builder.objectRef<number>("Money").implement({
  fields: (t) => ({
    amount: t.int({ resolve: (amount) => amount }),
    formatted: t.string({
      resolve: (amount) =>
        new Intl.NumberFormat("en-US", {
          style: "currency",
          currency: "USD",
        }).format(amount / 100),
    }),
  }),
});

Now we can use this inside of the types we previously created for Cart, and CartItem:

builder.objectType("Cart", {
  fields: (t) => ({
    id: t.exposeString("id"),
    items: t.field({
      type: ["CartItem"],
      resolve: (cart) => cart?.items ?? [],
    }),
    subTotal: t.field({
      type: Money,
      resolve: (cart) =>
        cart.items?.reduce(
          (acc, item) => acc + item.price * item.quantity,
          0
        ) ?? 0,
    }),
  }),
});
builder.objectType("CartItem", {
  fields: (t) => ({
    id: t.exposeString("id"),
    name: t.exposeString("name"),
    quantity: t.exposeInt("quantity"),
    lineTotal: t.field({
      type: Money,
      resolve: (item) => item.price * item.quantity,
    }),
    unitTotal: t.field({
      type: Money,
      resolve: (item) => item.price,
    }),
  }),
});

Finally to finish our shopping cart API we'll add a mutation using only code to add items to our CARTS array, and return the new items, quantities, and sub totals.

There's a few things we'll need to do there:

  • Define the input type for the new mutation addItem
  • Set the response type as Cart
  • Create the "resolver" that returns the updated cart with items

Before we begin we'll install a plugin by Pothos that lets us wrap our arguments with input:

npm install -E @pothos/plugin-with-input

Then let's update our instance of SchemaBuilder with our newly installed plugin:

const builder = new SchemaBuilder<{
  Objects: {
    Cart: Cart;
    CartItem: CartItem;
  };
  Scalars: {
    ID: { Input: string; Output: string };
  };
}>({
  plugins: [WithInputPlugin],
});

Now we can continue to create our mutationType:

builder.mutationType({
  fields: (t) => ({
    addItem: t.fieldWithInput({
      input: {
        cartId: t.input.id({ required: true }),
        id: t.input.id({ required: true }),
        name: t.input.string({ required: true }),
        price: t.input.int({ required: true }),
        quantity: t.input.int({ defaultValue: 1, required: true }),
      },
      type: "Cart",
      resolve: (_, { input: { cartId, ...input } }) => {
        const cart = CARTS.find((c) => c.id === cartId);

        if (!cart) {
          throw new Error(`No cart with ID: ${cartId}`);
        }

        return {
          id: cartId,
          items: [...cart?.items, input],
        };
      },
    }),
  }),
});

That's it!