Skip to content

Instantly share code, notes, and snippets.

@cometkim
Last active May 4, 2022 11:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cometkim/42085959ccbe6e8ffd0d46dd1cfc2176 to your computer and use it in GitHub Desktop.
Save cometkim/42085959ccbe6e8ffd0d46dd1cfc2176 to your computer and use it in GitHub Desktop.
GatsbyJS typed query generation RFC

RFC: Gatsby Typed Query

Motivation

As a maintainer of graphql-plugin-typegen, I have been thinking about an ergonomic way to provide GraphQL types in the Gatsby project for a long time.

In the first version of the plugin, users always had to directly import the type definitions from the generated files.

import * as React from 'react';
import { graphql } from 'gatsby'
import type { PageProps } from 'gatsby'
import type { IndexPageQuery } from '~/generated/gatsby-types';

export const query = graphql`
  # This triggers codegen
  query IndexPage { ... }
`

const IndexPage: React.FC<PageProps<IndexPageQuery>> = ({ data }) => { /* ... */ };

For improving DX in the gatsby develop, I added an experimental feature called "auto-fixing" that automatically inserts the generated type in the proper place, but it didn't work completely until the used type was imported once. However, inserting import statements automatically is a way more difficult.

I switched to use a global namespace called GatsbyTypes. Using namespaces are generally discouraged, but it's considered safe because the file contain no runtime code at all.

import * as React from 'react';
import { graphql } from 'gatsby'
import type { PageProps } from 'gatsby'

export const query = graphql`
  # This triggers codegen
  query IndexPage { ... }
`

// Works without explicit import
// Can be automatically inserted in the future.
const IndexPage: React.FC<PageProps<GatsbyTypes.IndexPageQuery>> = ({ data }) => { /* ... */ };

Users don't need to explicitly add import statements, they are automatically tracked and auto-fix can make projects code complete.

But relying on implicit namespaces is not a pretty solution. Are there any better options?

Well, the Gatsby's query compiler extracts queries by tracking graphql tags from files. depending on the tagged template litaral, I can't get any type information of the argument, so there's no room for hacking anymore.

If Gatsby is not using tagged template literals for it, there is another approach.

I thought it would be a good time to try, as experiments on typegen support in the Gatsby core is started.

How it looks like

Page Queries

import { definePageQuery, type PageProps } from 'gatsby';

// Use GraphQL magic comment for highlight instead of tagged template literal
const pageQuery = definePageQuery(/* GraphQL */ `
  site {
    siteMetadata {
      title
    }
  }
`);

type IndexPageProps = PageProps<typeof pageQuery>;

const IndexPage: React.FC<IndexPageProps> = ({ data }) => {
  // data is fully typed by a generated phantom type.
};

Static Queries

import { useStaticQuery } from 'gatsby';

const Header: React.FC = () => {
  // Use GraphQL magic comment for highlight instead of tagged template literal
  const staticData = useStaticQuery(/* GraphQL */ `
    query IndexPage {
      site {
        siteMetadata {
          title
        }
      }
    }
  `);
  
  // data is fully typed by a generated phantom type.
}

Detailed Design

The feature shoud be backed by Gatsby core, since it needs behavior change of the query compiler.

We can modify the FileParser to extract Gatsby queries from files by definePageQuery or useStaticQuery reference so we can extract query string, locations, and bindings to create. However, this can get more precise semantic than checking of all graphql tag.

After extraction, the behavior is the same as the existing query compiler.

For type definition of definePageQuery we can introduce a new approach using TypeScript's declaration merging & phantom type.

First, initial definition of definePageQuery is

// This is phantom type utility, has no runtime semantic.
export type PageQueryType<T> = Pick<{ data: T }, never>;

// This can restore phantom type T from a given PageQueryType.
export type PageQueryData<T> = T extends PageQueryType<infer U> ? U : unknown;

// Emtpy at initial
export interface DefinePageQuery {
  (query: string): PageQueryType<unknown>;
}

export const definePageQuery: DefinePageQuery;

After Gatsby extracted all query and schema, Gatsby might generates additional TypeScript code for IntelliSense support.

On that generated code, we can stitches the type definition by declaration merging.

// name can be derived from the operation name in the GraphQL query. Should be unique.
type IndexPageQuery = `query {\n site\n    {\n ...`;

type IndexPageQueryData = {
  site: null | {
    siteMetadata: null | {
      title: null | string,
    },
  },
};

declare module 'gatsby' {
  export interface DefinePageQuery {
    (query: IndexPageQuery): PageQueryType<IndexPageQueryData>;
  }
}

If user include this generated declaration file in their project, the types related to the page query will start to be inferred automatically.

See prototype in the TypeScript Playground

Pros & Cons

Props:

  • Types are just working as codegen tasks done.
  • Users don't need to add imports and bindings manually.
  • As it reuses function call semantic, it's open to extension.
  • We can try without breaking the existing codes and type definitions.
  • By avoiding the graphql tag, users can get chance to use Relay in a Gatsby project.

Cons:

  • Using magic comment instead of graphql tag is a bit ugly.
  • It feels unfamiliar compared to the existing syntax.
  • A new version of the query compiler is required. It needs to be rewritten in a more complex form and kept with the previous version for compatibility.

Prior arts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment