Achieving end-to-end type safety in a modern JS GraphQL stack – Part 2

Achieving end-to-end type safety in a modern JS GraphQL stack – Part 2

Welcome back! This article is the second and last part of the Achieving end-to-end type safety in a modern JS GraphQL stack series. Read the first part if you haven't yet!


Svelte

I won't go into the details of why Svelte, but I like Svelte a lot. It feels great writing Svelte code. And did I mention that it also offers type safety?

The easiest way to set up a new Svelte website is with SvelteKit; let's go back to the packages directory and create a new SvelteKit project:

# Create a Svelte app in the `packages/app` directory
# You will have a few choices prompted:
#  - Template: Skeleton project
#  - Type checking: TypeScript
#  - Prettier, ESLint, etc.: Not needed, do as you wish
yarn create svelte@latest app

# Install the dependencies
cd $_ && yarn install

This creates a bunch of new files in the packages/app directory. Let's take a look at the most important ones.

  • src
    • routes
      • +page.svelte: this is the index page of the website and also a Svelte component
  • package.json: the package manifest, with the dependencies and scripts
  • svelte.config.js: this is the Svelte configuration file

The package.json comes with a few scripts out of the box:

  • dev: starts the development server
  • build: builds the application for production
  • check: checks the code for type errors (yay!)

You can run svelte dev to see the hello world, but we need a missing piece before we can do anything useful: the GraphQL client.

GraphQL Zeus

You can think of GraphQL Zeus as Prisma for the frontend: it writes GraphQL queries out of JavaScript objects and produces the proper return types.

In the packages/app directory, add the following dependencies:

# Install GraphQL Zeus
yarn add --dev graphql-zeus

# Mark the API as a dev dependency
yarn add --dev api@workspace

# Gitignore the generated files
echo 'src/zeus/' >> .gitignore

Let's update packages/app/package.json:

{
  "script": {
    // Build the GraphQL client right before the rest of application
    "build": "zeus ../api/build/schema.graphql ./src --es && yarn check --threshold warning && vite build"
    // There's also `yarn check` in there to catch type errors
  }
}

Run yarn build, and you should see a new src/zeus/ directory with a bunch of files. Let's put all the pieces together!

Typed Board

How do we create a message board out of all of this? Keep reading – we're almost there!

We have a few things to add to packages/app for everything to work:

src/lib/zeus.ts

Zeus is a powerful tool, but it was not made to be used for server-side rendering. We will solve this by creating a small wrapper around the generated client:

import type { LoadEvent } from "@sveltejs/kit";
import { Thunder, type ValueTypes } from "../zeus/index";

/** A function that allows using Zeus with a custom `fetch` function. */
const thunder = (fetch: LoadEvent["fetch"]) =>
  Thunder((query, variables) =>
    fetch("http://localhost:4000/graphql", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query, variables }),
    })
      // Errors are not properly handled by this code, but you get the idea
      .then((response) => response.json())
      .then(({ data }) => data)
  );

/** A nice wrapper around the unfriendly `thunder` above. */
export const query = async <Query extends ValueTypes["Query"]>(
  query: Query,
  { fetch }: { fetch: LoadEvent["fetch"] }
) => thunder(fetch)("query")(query); // That's a lot of parentheses

/** Same, but for mutations. */
export const mutate = async <Mutation extends ValueTypes["Mutation"]>(
  mutation: Mutation
  // No need for a custom fetch function here, since mutations are
  // never sent during server-side rendering
) => thunder(fetch)("mutation")(mutation);

It will make our GraphQL queries much nicer to write.

src/routes/+page.ts

This file provides the data to the +page.svelte component, both on the client (on browser navigation) and on the server (when using server-side rendering).

import { query } from "$lib/zeus";
import type { PageLoad } from "./$types";

/** This gets called on both the server and the client to provide page data. */
export const load: PageLoad = async ({ fetch }) =>
  // Perform a GraphQL query
  query(
    {
      // Query the `posts` field with all three columns
      posts: {
        // It looks a bit like Prisma, doesn't it?
        id: true,
        title: true,
        body: true,
      },
    },
    // Giving fetch allows server-side rendering
    { fetch }
  );

src/routes/+page.svelte

<script lang="ts">
  import { invalidateAll } from "$app/navigation";
  import { mutate } from "$lib/zeus";

  // SvelteKit magic: forward `load` function return type 🪄
  import type { PageData } from "./$types";

  // `export` means that `data` is a prop
  export let data: PageData;

  // Variables bound to the form inputs
  let title = "";
  let body = "";

  /** Sends the `createPost` mutation and refreshes the page. */
  const createPost = async () => {
    await mutate({ createPost: [{ body, title }, { id: true }] });
    await invalidateAll();
    title = body = "";
  };
</script>

<main>
  <h1>Typed Board</h1>
  <form on:submit|preventDefault={createPost}>
    <h2>New post</h2>
    <p>
      <label>Title: <input type="text" bind:value={title} required /></label>
    </p>
    <p>
      <label>Body: <textarea bind:value={body} rows="5" required /></label>
    </p>
    <p style:text-align="center">
      <button type="submit">Post!</button>
    </p>
  </form>
  <!-- `data.posts` is fully typed! -->
  {#each data.posts as post}
    <article>
      <!--
        Our tools tell us that `post` is of type
        `{
          id: string;
          title: string;
          body: string;
        }`
        If you remove `title: true` from `+page.ts`, you will see an error below
      -->
      <h2>{post.title}</h2>
      <pre>{post.body}</pre>
    </article>
  {/each}
</main>

<style>
  /* Let's add a bit of CSS magic... */
  :global(body) {
    font-family: system-ui, sans-serif;
    background-color: #fff6ec;
  }

  main {
    max-width: 60rem;
    margin: 2rem auto;
  }

  article,
  form {
    background-color: #fff;
    overflow: hidden;
    margin-block: 1rem;
    padding-inline: 1rem;
    box-shadow: 0 0 0.5rem #3001;
    border-radius: 0.25rem;
  }

  label {
    display: flex;
    gap: 0.25rem 0.5rem;
    flex-wrap: wrap;
  }

  input {
    flex: 1;
  }

  textarea {
    width: 100%;
    resize: vertical;
  }

  button {
    padding: 0.25em 0.5em;
  }
</style>

And that's it! We now have a working message board with end-to-end type safety. If you make a type error in this project, the compiler will catch it at build time. Huh, I am missing the most critical part...

A screenshot of the finished message board
Here is our finished message board!

Wrapping up

It's time to set up the whole check my types build scripts. Thanks to Yarn 4, that's a matter of only one command. Add the following scripts in the root package.json:

{
  "name": "typed-board",
  "packageManager": "yarn@4.0.0-rc.22",
  "private": true,
  "workspaces": ["packages/*"],
  // Add these scripts to build the two packages in the right order:
  "scripts": {
    "build": "yarn workspaces foreach --topological-dev -pv run build",
    "dev": "yarn workspaces foreach -piv run dev"
  }
}

This build command triggers all the packages' build commands in the correct (topological) order, and they are all set up to catch type errors. I also added a dev command that starts all the dev servers in parallel, for convenience.

# Launch the whole build pipeline and check the code 👀
yarn build

# Launch all the dev servers at once
yarn dev

And this concludes this unusually long article. I hope you enjoyed it and that you will find it helpful. If you have any questions, feel free to ask them in the comments below or where you found this article.

Food for thoughts

💡 Wanna learn more about code quality? Read our blog article "GraphQL errors: the Good, the Bad and the Ugly".