Seamless Scaling: Making the Move from GraphQL to GROQD

July 28, 2023
Max YingerMax Yinger

Here at Formidable, we’ve leveraged our open source query builder, GROQD, on quite a few of our client projects that use Sanity under the hood. After using it on clients big and small, as well as our own website, we’ve found that not only does it scale… but it scales well.

Note 💡 If you’re unfamiliar with Sanity, the GROQ query language or how our open source query builder, GROQD, fits into the grand scheme of things, check out our introductory article here.

In this article we want to share a couple key benefits around type safety, query composition & runtime validation that are leading our clients to choose GROQD as well as the journey we took to get here.

For the purposes of this article, let’s use the following Sanity v3 schema for a simple navigation component and see how we might interact with this data in a React server component.

import {defineType, defineField} from 'sanity' export const schemaTypes = [ defineType({ title: 'Page', name: 'page', type: 'document', fields: [ defineField({ title: 'Name', name: 'name', type: 'string', }), defineField({ title: 'Slug', name: 'slug', type: 'string', }), ], }), defineType({ title: 'Link', name: 'link', type: 'object', fields: [ defineField({ title: 'Text', name: 'text', type: 'string', validation: (Rule) => Rule.required(), }), defineField({ title: 'Page', name: 'page', type: 'reference', to: [{type: 'page'}], validation: (Rule) => Rule.required(), }), ], }), defineType({ name: 'nav', type: 'document', title: 'Nav', fields: [ defineField({ title: 'Links', name: 'links', type: 'array', of: [ { title: 'Link', name: 'link', type: 'link', }, ], }), ], }), ]

The Classic Conundrum

GraphQL with PropTypes is a classic pairing, we’ve worked on countless projects here at Formidable that use both.

Both approaches solve real world problems. GraphQL gives developers a simple straight forward contract to retrieve only the data our UI needs, while propTypes validate the data we are receiving at runtime.

It is also a very valid, and hotly debated question, as to whether or not to use propTypes in a codebase when transitioning to TypeScript; runtime validation is just as useful as build time type checks, and one is not made to replace the other. That being said, is that level of safety worth the effort an engineering team has to go through maintaining almost duplicate interface definitions?

Once maintaining a codebase that utilizes all three, it becomes apparent that there’s quite a level of redundancy:

import PropTypes from "prop-types"; import request from "graphql-request"; // Link: // - type // - component // - propTypes providing runtime validation type LinkData = { _key: string; text: string; page: { slug: string; }; }; function Link({ data }: { data: LinkData }) { return <a href={data.page.slug}>{data.text}</a>; } const linkPropType = PropTypes.shape({ _key: PropTypes.string, text: PropTypes.string, page: PropTypes.shape({ slug: PropTypes.string, }), }); Link.propTypes = { data: linkPropType, }; // Nav: // - type // - component // - propTypes providing runtime validation type NavData = { Nav: { links: LinkData[]; }; }; function Nav({ data }: { data: NavData }) { return ( <nav> {data.Nav.links.map((link) => { return <Link key={link._key} data={link} />; })} </nav> ); } Nav.propTypes = { data: PropTypes.shape({ Nav: PropTypes.shape({ links: PropTypes.arrayOf(linkPropType), }), }), }; export default async function Layout({ children }: React.PropsWithChildren) { // Declare our query here. Looks earily similar // to both our types and propTypes above const data = await request<NavData>( process.env.GQL_ENDPOINT as string, /* GraphQL */ ` query navQuery($id: ID!) { Nav(id: $id) { links { _key text page { slug } } } } `, { id: "nav", } ); return ( <> <Nav data={data} /> {children} </> ); }

Not only is this pattern a pain to work on as an engineer, but this approach is quite prone to interfaces getting out of sync. The contract between interface definitions becomes non-existent.

For instance, if a feature high in your component tree necessitates an update around the data it’s receiving, that means we then have to update:

  • The query we are requesting our data with.
  • The type interface for the component receiving that data
  • The propType interface for the components receiving that data
  • The type & propType interfaces of all components downstream of said component receiving an update

Failure to update any one of the above bullet points results in easily accruing tech debt. If you’ve been in any PropTypes codebase that has been around for while, then you are familiar with the wall of console errors from propType violations that (maybe) get cleaned up once or twice a year. There’s no reason to blame teams for those errors either, that sh*t is tedious.

“Say something once… Why say it again?”

Ideally, once we describe the shape of our data, it would be nice to derive all other interfaces off of it. This would create a strong contract, where it is guaranteed that types, validation and queried content are all in sync.

This ethos brings us to the second generation of projects we’ve typically worked on in the React space. This generation is marked with ditching propTypes all together, manually writing your GraphQL queries, then autogenerating types based on the GraphQL schema.

After running a codegen script on our previous example, and some slight alterations, we get something like this:

import request from "graphql-request"; import type { NavQueryQuery } from "@/gql/graphql"; import { graphql } from "@/gql"; // Link: // - type // - component function Link({ data, }: { data: NonNullable<NonNullable<NavQueryQuery["Nav"]>["links"]>[number]; }) { if (!data?.page?.slug) return null; return <a href={data?.page?.slug}>{data?.text}</a>; } // Nav: // - type // - component function Nav({ data }: { data: NavQueryQuery }) { if (!data?.Nav?.links) return null; return ( <nav> {data.Nav.links.map((link) => { return <Link key={link?._key} data={link} />; })} </nav> ); } // query & source for autogenerated types const navDocument = graphql(/* GraphQL */ ` query navQuery($id: ID!) { Nav(id: $id) { links { _key text page { slug } } } } `); // all together now export default async function Layout({ children }: React.PropsWithChildren) { const data = await request(process.env.GQL_ENDPOINT, navDocument, { id: "nav", }); return ( <> <Nav data={data} /> {children} </> ); }

This approach was a notable step forward and had its benefits. It started laying out a single source of truth to describe the data we are passing around in our application. It created a stronger contract between our queries and types; and our team was no longer managing duplicate interfaces (sometimes). All this being said, this approach doesn’t come without its caveats. Notably:

Autogenerated types kinda blow.

Typically the ease we get with autogeneration comes with the cost of not being tailor fit to our needs. Autogenerated types are typically verbose and cumbersome; they often require lots of finesse to make them usable as our component interfaces. For example, the autogenerated type from the code excerpt above results in this 🤢:

// @/gql/graphql export type NavQueryQuery = { __typename?: "RootQuery"; Nav?: { __typename?: "Nav"; links?: Array<{ __typename?: "Link"; _key?: string | null; text?: string | null; page?: { __typename?: "Page"; slug?: string | null } | null; } | null> | null; } | null; };

If our team wasn’t the most experienced with TypeScript, this didn’t turn out well. Oftentimes it resulted in simply casting the data returned from the query, then redeclaring all other types for component interfaces down the component tree; which negates having a single source of truth to describe our data.

No more runtime validation?

That’s right. Since we ditched propTypes all together, we no longer had a running process that validated the data received was the shape we expected it. All we were doing was basically taking a snapshot of the schema at build time and saying “This is how the data should look”. This left no assurances that our types were accurate if the backend changed since the last build and started to send malformed data.

More build steps. More mayhem.

With types generated based of schema defined on our backend, our frontend now had an implicit dependency on our backend. This added an extra step to CI and an additional layer of complexity to the release process. This complexity in this process got compounded with the Sanity build step that generated the GraphQL api off the latest schema. Typically, the harder the release process got, the less frequent we were cutting releases. This meant more resources dedicated to releases and less resources dedicated to application code.

So what have we been up to lately?

The broader React and TypeScript community has pretty much unanimously converged on a tool for TypeScript first schema validation, and that tool is Zod. If you haven’t checked Zod out yet, please do. It is a query language agnostic schema validation tool and is the gold standard in modern applications for good reason.

While Zod unifies runtime validation with build time type safety, we still have the question of how do we tie that validation schema with our query schema?

Here at Formidable, we found ourselves asking the same question and decided to build a solution on top of Zod & GROQ to handle it. Our open sourced query builder is aptly named... GROQD 🎉.

GROQD is basically all the flexibility of GROQ with the composability and type safety of Zod. It is zero build step Query Composition, Type Safety & Runtime Validation all in one place. The earlier mentioned example would now look something like this:

import { q, type Selection, type TypeFromSelection } from "groqd"; import { runQuery } from "@/utils"; // Link: // - query, type & validation encompassed in groqd selection // - component const linkSelection = { _key: q.string(), href: q("page").deref().grabOne("slug", q.string()), text: q.string(), } satisfies Selection; type LinkData = TypeFromSelection<typeof linkSelection>; function Link({ data }: { data: LinkData }) { return <a href={data.href}>{data.text}</a>; } // Nav: // - query, type & validation encompassed in groqd selection // - component const navSelection = { links: q("links").filter().grab(linkSelection), } satisfies Selection; type NavData = TypeFromSelection<typeof navSelection>; function Nav({ data }: { data: NavData }) { return ( <nav> {data.links.map((link) => { return <Link key={link._key} data={link} />; })} </nav> ); } // Tie it all together export default async function Layout({ children }: React.PropsWithChildren) { // schema validation will run on the result // of this fetch request. This ensures // our types are valid at runtime. const data = await runQuery( q("*").filterByType("nav").grab(navSelection).slice(0) ); return ( <> <Nav data={data} /> {children} </> ); }

Key Takeaways

The usage of GROQD on several of our projects has given us the ability to easily perform TypeScript transitions, simplify build processes, and reduce code all while increasing type safety and code quality.

The ability to perform joins and stitch together documents with GROQD, combined with Zod’s ability to transform data while validating it, has essentially eliminated our need to augment data within our applications before passing around for broader consumption. This has also allowed us to tighten the types associated with our queries and made it possible to receive complex data types, like unions, tuples & literals directly from our query calls.

Browser=Simple, Theme=Light.png

Using the concept of Low Maintenance Types , with content first types provided by GROQD, has allowed us to easily update and maintain features with little overhead.

If you are on a project using Sanity as your CMS, be sure check out GROQD and see if it might be a good fit for you.