/Notes

TypeScript, GraphQL and Gatsby

A core component of Gatsby is GraphQL. It is used for discovery of pages to statically build and in general to bring arbitrary data into pages. There is a significant gap in the out-of-box type coverage for GraphQL query results which encourages the creation of interfaces/types, which isn't very DRY and doesn't address the lack of type coverage problem very well (very prone to mistakes, may not get updated when schema is changed particularly when interfaces span across multiple components). There are ways to improve on this thankfully.

Before You Start

GraphQL queries (much like a default export in an ES Module) don't need a name, but you will want to add one. The type generation later on uses the query name to inform the generated type name. As an example a query named PostQuery will get a nice predictable type name of PostQuery.

As an aside, including that optional name in default exports helps when debugging. Preact (and perhaps React) dev tools in particular will show a rather unhelpful "anonymous" where these default export components are used.

Generating Types

First up, we need to get our hands on the Gatsby GraphQL schema.

  1. Install get-graphql-schema

    npm i --save-dev get-graphql-schema
    yarn i --save-dev get-graphql-schema
    pnpm i --save-dev get-graphql-schema
  2. Start the Gatsby development server, and wait for it to be ready

    node_modules/.bin/gatsby develop
  3. Extract the schema from /___graphql

    node_modules/.bin/get-graphql-schema http://localhost:8000/___graphql > schema.graphql

You should now have lengthy (4789 lines for this site) GraphQL schema file. The first few lines will likely look something like;

"""Provides default value for input field.""" 
directive @default(value: JSON!) on INPUT_FIELD_DEFINITION

"""Add date formatting options."""
directive @dateformat(formatString: String, locale: String, fromNow: Boolean, difference: String) on FIELD_DEFINITION

"""Link to node by foreign-key relation."""
directive @link(by: String! = "id", from: String, on: String) on FIELD_DEFINITION

type TagYamlGroupConnection { 
  totalCount: Int!
  edges: [TagYamlEdge!]!
  nodes: [TagYaml!]!
  pageInfo: PageInfo!
  field: String!
  fieldValue: String
}

input TagYamlSortInput {
  fields: [TagYamlFieldsEnum]
  order: [SortOrderEnum] = [ASC]
}

With the schema on hand, our next step is to generate types.

  1. Install ts-graphql-plugin

    npm i --save-dev ts-graphql-plugin
    yarn i --save-dev ts-graphql-plugin
    pnpm i --save-dev ts-graphql-plugin
  2. Modify tsconfig.json to include the plugin (and its configuration)

    {
       "compilerOptions": {
           // ...
           "plugins": [
               // ...
               {
                   "name": "ts-graphql-plugin",
                   "schema": "./schema.graphql",
                   "tag": "graphql"
               }
           ]
       },
       // ...
    }
  3. Generate the types

    node_modules/.bin/tsgql typegen

Types will be placed close to where they are used, e.g.

─┬src
 └┬templates
  ├┬__generated__
  │└─post-query.ts
  └─post.tsx

The final step is to prevent Gatsby from attempting to build pages for any generated types which appear under src/pages/*, which is highly likely if any of these pages use a page query.

To do this, explicitly add gatsby-plugin-page-creator plugin to gatsby-config.js as follows to override with the additional needed guards.

module.exports.config = {
    // ...
    plugins: [
        // ...
        {
            resolve: "gatsby-plugin-page-creator",
            options: {
                path: path.join(__dirname, "src", "pages"),
                ignore: [ "__generated__/*" ],
            }
        },
        // ...
    ],
    // ...
};

With everything in place, the generated types can be used like as follows;

// src/templates/post.tsx
import * as React from "react"; 
import { PostQuery } from "./__generated__/post-query.js";

interface PageTemplateProps {
    data: PostQuery;
}

export default function PostTemplate(props: PageTemplateProps) {
    const post = props.data.post;

    // Page query is responsible for discovering its own content (mostly) so this is something that
    // needs to be guarded against. Generated types will reflect this ({ ... } | null | undefined).
    if (!post) {
        // Ideally the actual 404 page would be used
        return <div>
            Error retrieving post data
        </div>;
    }

    return <main>
        <h1>{post.title || "Title not set in frontmatter"}</h1>
        <section dangerouslySetInnerHTML={{ __html: post.html }} />
    </main>;
}

export const query = graphql`
    query PostQuery($slug: String) {
        post: markdownRemark(fields: { slug: { eq: $slug } }) {
            html
            frontmatter {
                title
            }
        }
    }
`;

Fragments

Sometimes you'll find yourself repeating a given query structure (e.g. locating related, next, and previous posts). GraphQL fragments address this specific issue, and ts-graphql-plugin will helpfully create a reusable type for this purpose.

Extending the previous example;

// src/templates/post.tsx
import * as React from "react"; 
import { Link } from "gatsby";
import { PostQuery } from "./__generated__/post-query.js";
import { PostQuery, PostContext } from "./__generated__/post-query.js"; 

interface RelatedPostProps {
   post: PostContext;
}

function RelatedPost(props: RelatedPostProps) {
   const post = props.post;
   return <Link to={`/post/${post.fields?.slug || "SLUG_MISSING"}`}>
       <article>
          <h2>{post.frontmatter?.title || "Title not set in frontmatter"}</h2>
          <p>{post.excerpt || ""}</p>
      </article>
   </Link>;
}

interface PageTemplateProps {
    data: PostQuery;
}

export default function PostTemplate(props: PageTemplateProps) {
    const post = props.data.post;
   const relatedPosts = props.data.relatedPosts.edges;

    // Page query is responsible for discovering its own content (mostly) so this is something that
    // needs to be guarded against. Generated types will reflect this ({ ... } | null | undefined).
    if (!post) {
        // Ideally the actual 404 page would be used
        return <div>
            Error retrieving post data
        </div>;
    }

    return <main>
        <h1>{post.frontmatter?.title || "Title not set in frontmatter"}</h1>
        <section dangerouslySetInnerHTML={{ __html: post.html }} />
       {relatedPosts.map((edge, i) => <RelatedPost key={i} post={edge.node} />)}
    </main>;
}

export const query = graphql`
   fragment PostContext on MarkdownRemark {
       excerpt
       fields {
           slug
       }
       frontmatter {
           title
       }
   }

    query PostQuery($slug: String, $tags: [String]) {
        post: markdownRemark(fields: { slug: { eq: $slug } }) {
            html
            frontmatter {
                title
            }
        }
       relatedPosts: allMarkdownRemark(
           filter: {
               frontmatter: {
                   tags: { in: $tags }
               }
               fields: { slug: { ne: $slug } }
           }
           limit: 4
       ) {
           edges {
               node {
                   ...PostContext
               }
           }
       }
    }
`;

Limitations

Fragments are injected into queries on the fly by Gatsby and consequently are incompatible. This issue also extends to the GraphiQL IDE which Gatsby includes, which will hopefully supported in the future. Thankfully most of these fragments are simple, and can be found in the respective package's source (e.g. gatsby-transformer-sharp).

An Idealistic View of the Future

Plugins like ts-graphql-plugin which we have used here are able to add support for extended grammers within tagged templates. Lets suppose it was possible to infer the query types directly from the query on the page with no additional help.

To make this happen the hypothetical TypeScript plugin would need to hook into Gatsby to learn the schema (and ideally available fragments as well), and enhance the return type of import("gatsby").graphql such that the type can be extracted but importantly not mislead (it should be crystal clear that attempting to use the result is incorrect).

So what could this look like? Building off the first example again...

// src/templates/post.tsx
import * as React from "react"; 
import { PostQuery } from "./__generated__/post-query.js";

interface PageTemplateProps {
   data: PostQuery;
   data: Parameters<typeof query>[0];
}

export default function PostTemplate(props: PageTemplateProps) {
    const post = props.data.post;

    // Page query is responsible for discovering its own content (mostly) so this is something that
    // needs to be guarded against. Generated types will reflect this ({ ... } | null | undefined).
    if (!post) {
        // Ideally the actual 404 page would be used
        return <div>
            Error retrieving post data
        </div>;
    }

    return <main>
        <h1>{post.title || "Title not set in frontmatter"}</h1>
        <section dangerouslySetInnerHTML={{ __html: post.html }} />
    </main>;
}

export const query = graphql`
    query PostQuery($slug: String) {
        post: markdownRemark(fields: { slug: { eq: $slug } }) {
            html
            frontmatter {
                title
            }
        }
    }
`;

Where the return type of the graphql tagged template function about is something like as follows.

type Query = (result: PostQuery) => void;
type PostQuery = {
    post: ({
        html: string | null;
        frontmatter: ({
            title: string | null;
        }) | null;
    }) | null;
};

Not too far fetched all-in-all, most of what is needed already exists. The only major component left is extracting the schema from the Gatsby site without needing to first start the development server (which technically you can do without).