Gridsome und type-safety

Bessere Unterst├╝tzung f├╝r Entwickler bei statisch generierten Websites

geschrieben von Viliam Simko, Lesezeit 9 Minuten
Wir zeigen, wie ein Gridsome-basiertes Projekt so konfiguriert werden kann, dass es type-safe Entwicklung unter Verwendung von Typescript und Composition API unterst├╝tzt, obwohl gleichzeitig Vue 2 verwendet wird. ­čĹż Dieser Artikel ist nur in englischer Fassung verf├╝gbar.
Gridsome und type-safety

Motivation

After migrating our company homepage to Gridsome in 2019, I quickly realized how fragile refactorings could be. Renaming a field in a blog article would silently break layout of some dependent components. Regex-searches and repeated manual testing were the most used tools. This could have easily been prevented with compile-time type checking.

I already had the experience from previous projects, how migrating from Javascript to Typescript boosts confidence and willingness for change. If only there was a simple solution for Gridsome. I couldn't find satisfactory solution in official starter projects and thus wrote this blog post. My goal was to combine Gridsome, Typescript and Composition-API so that you can try it too. I'm also going to show some limitations we are still facing.

What is Gridsome?

For those who are new to Gridsome, it is one of many static site generators in the Javascript / Vue.js ecosystem. It is also a Jamstack framework meaning that it ties together Javascript, APIs and markup to build fast and secure websites. If you are a React developer, you may have heard about Gatsby, where Gridsome took a lot of inspiration. Unlike other static site generators, Gridsome and Gatsby feature a GraphQL data layer allowing unified access to multiple data sources.

Developing websites in Gridsome. Source: https://gridsome.org/docs/how-it-works/ Gridsome: How it works

Imagine you are building a blogging website. Your articles can be loaded from Markdown files or external content management systems (CMS). Thanks to the unified GraphQL API, you can query all the content together and render it using Vue templates.

Data sources in Gridsome are implemented as plugins and available on the project website. This plugin ecosystem is one of Gridsome's killer features. To name a few, there are plugins for Wordpress, Strapi, Contentful, MySQL databases, generic REST, GraphQL endpoints, and many more.

For simple websites, such as the one you are currently reading, I prefer to keep the Markdown content together with the rest of my code.

Many developers, however, like to keep their content stored in local Markdown files versioned together with the rest of the code. Typically, in development mode, each change is immediately visible in the browser due to the hot-reloading development server. Once the article is ready, you can build a new static version of the website either manually or within a CI-pipeline.

Statically generated websites can be easily deployed to the cloud and served for free through cloud providers such as Netlify or Vercel.

What makes Gridsome especially appealing to developers are the optimizations that Gridsome performs behind the scenes to make your page load as fast as possible. For example, code splitting, asset optimization, progressively loading images, link prefetching, smooth page transitions and more.

Thus, developers can focus on creating fast, beautiful websites without the need to become performance experts.

Developer experience with Gridsome

Using the official documentation you can have a running starter project in minutes. However, at the time of writing this article, the official way is to write code using Vue 2 and ES6 Javascript inside *.vue and *.js files. As your project grows and more people start contributing to the code, you'll realize the importance of traceability, autocompletion and type-checking.

Modern IDEs such as Visual Studio Code or Intellij IDEA already perform decent static code analysis - even for Javascript-based projects. For example, IntelliJ automatically infers types of variables based on their usage and extracts information from JSDoc annotations and *.d.ts files (Typescript Typings).

All these pieces of information help a lot, but the autocompletion tends to include many false positives due to imprecise type inference. There is also a significant part of the code which the IDE doesn't check.

After a few months of development, we realized that our Javascript code could have been written using Typescript in the first place because we already wrote so much JSDoc and Typings.

Typescript + Composition API

Adding basic Typescript support to Gridsome isn't that hard. There is already a plugin for that: gridsome-plugin-typescript. Our IDE, however, needs some way of automatically inferring types inside Vue components for reliable autocompletion.

We knew this can be achieved using the Composition API which plays nicely with Typescript. After some experimentation, we released gridsome-plugin-composition-api, a simple plugin that enables Composition API inside Vue2 components. (Consider this as a workaround until Gridsome switches to Vue3.)

Now, we can write Vue components that look as follows:

<script lang="ts">
import {defineComponent, PropType, computed} from "@vue/composition-api";

interface Item { // could be imported from other module
  id: string
  title: string
}

export default defineComponent({
  props: {
    items: { type:Array as PropType<Item[]>, required:true }
  },

  setup(props) { // IDE knows the type of props
    const titles = computed( () => props.items.map(item => item.title));
    return {
      titles // IDE knows this type as well
    }
  }
})
</script>

The main benefit is that our IDE now knows the proper types of all variables. We pay a small price in terms of build performance because all the code is compiled using tsc and type-checked.

We hope that newer versions of Gridsome will switch to something like Vite or Snowpack that use esbuild instead of tsc to speed up the compilation. Our IDE already does all the type-checking anyways, there is no need to do it twice.

Installation

So how to configure Gridsome with both plugins exactly?

First install Typescript support

# Add npm dependency
$ yarn add -D gridsome-plugin-typescript

Activate the plugin in gridsome.config.js:

plugins: [
  { use: "gridsome-plugin-typescript" }
]

Your tsconfig.json should look like this:

{
  "compilerOptions": {
    "target": "ES5",
    "module": "ES6",
    "strict": true,
    "moduleResolution": "node",
    "experimentalDecorators": false,
    "noImplicitReturns": true,
    "noImplicitAny": false,
    "outDir": "./built/",
    "allowJs": true,
    "esModuleInterop": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": [
    "./src/**/*",
    "./gridsome.*.js"
  ],
  "exclude": [
    "content",
    "node_modules",
    "static",
    "src/.temp",
    "src/assets",
    "dist"
  ]
}

Second, install Composition API

# Add npm dependency:
$ yarn add -D gridsome-plugin-composition-api

Activate the plugin in gridsome.config.js:

plugins: [
  { use: "gridsome-plugin-composition-api" }
]

Known Limitations

Here is a summary of issues we encountered in both VSCode and IntelliJ. The list is biased because we use IntelliJ as our main IDE. Some issues were already reported, and we expect them to be fixed soon.

Issue #1: Autocompletion for typed props in templates

In a typical Vue component, you would already describe types of component's props like this:

<script>
export default {
  props: {
    title: { type: String, required: true } // basic type definition
  }
}
</script>

This is fine, but if the property is an Object, we cannot further specify its structure. When using typescript (and composition API), we can achieve this using type: Object as PropType<TYPENAME>:

<template>
  <my-card :title="personName" :image="person.photo"/>
</template>

<script lang="ts">
import {defineComponent, PropType, computed} from "@vue/composition-api";
import {Person, formatName} from "@/models/Person";

export default defineComponent({
  props: {
    person: {type: Object as PropType<Person>, required: true} // better type definition for objects
  },

  setup(props) {
    const personName = computed(() => formatName(props.person.name));
    return {personName}
  }
});
</script>

Now you would expect that autocompletion of the person variable would work inside the template code (person.photo) as well as the setup() function (props.person.name). When using VSCode, both work as expected. In IntelliJ, unfortunately, autocompletion works only inside the setup function.

Issue #2: Auto-unwrapping of ComputedRefs in templates

Second issue is related to Ref and ComputedRef returned from the setup() function. In IntelliJ, they are not properly unwrapped. (This again works fine in VSCode).

Consider the following Vue/Typescript code. We return a person variable which is of type ComputedRef<Person>.

<script lang="ts">
import {defineComponent, computed, ComputedRef} from "@vue/composition-api";

interface Person {
  name: string
  age: number
}

export default defineComponent({
  setup() {
    const person: ComputedRef<Person> = computed(() => ({
      name: "John",
      age: 30
    }));
    
    return {person}
  }
})
</script>

If we now used person inside a Vue template, the autocompletion in IntelliJ would suggest person.value.name instead of person.name.

Until this is fixed, you can use the following temporary workaround:

import {reactive, computed} from "@vue/composition-api";

export function fixedComputed<T>(getter: () => T): T {
  return reactive(computed(getter)) as T;
}

Issue #3: Autocompletion in nested components

As demonstrated in the following example with Vue dynamic slots (v-slot="{item}"), IntelliJ does not offer autocompletion for the item variable even though articles are properly typed. This is a known issue WEB-41084.

<my-list :items="articles" v-slot="{item}">
  <my-list-item
    :image="item.previewImage"
    :title="item.title"
    :timeToRead="item.timeToRead"
  />
</my-list>

Issue #4: Untyped GraphQL queries

In Gridsome, you can access data from the GraphQL layer using inlined graphql queries (written inside special tags <page-query> or <static-query>). Query results from the queries are available in Vue templates through the $page and $static variable globally provided on the Vue instance.

The problem is that our IDE doesn't know the type of $page and thus cannot autocomplete the person variable. What's even worse, if we changed our query somewhere in the future and forget to update the template, we would generate the wrong output without realizing it. This is actually a very common type of error we encountered in our projects.

In the following example, we generate a list of photos for all persons. The <g-image> built-in component outputs an optimized progressive image (see docs/images).

Notice the wrong use of person.image instead of person.photo highlighted in the code snippet:

<template>
  <Layout>
    <g-image v-for="{node:person} in $page.allPerson.edges"
      :key="person.id"
      :src="person.image"      :alt="person.title"
    />
  </Layout>
</template>

<page-query>
query {
  allPerson {
    edges {
      node {
        id
        name
        photo (quality:90 blur:6)      }
    }
  }
}
</page-query>

At the moment, we are not aware of any Gridsome plugin that would generate types for these GraphQL queries. We are considering to write one because all the information about the types is readily available in this Gridsome data layer.

Issue #5: Untyped $page and $static

As already mentioned, it is currently not possible to specify types for $page and $static global variables. There is, however, a workaround which requires a bit of additional boilerplate code. We have created two utility functions usePageQuery<QueryType>() and useStaticQuery<QueryType>() that can be used inside the setup() function.

// src/lib/vue-utils.ts
import {computed, ComputedRef, getCurrentInstance} from "@vue/composition-api";

export function useStaticQuery<T>(): ComputedRef<T> {
  return computed(() => (getCurrentInstance() as unknown as { $static: T }).$static);
}

export function usePageQuery<T>(): ComputedRef<T> {
  return computed(() => (getCurrentInstance() as unknown as { $page: T }).$page);
}
<!-- src/templates/Article.vue -->
<template>
  <div>
    <h1>{{ article.title }}</h1>
    <div v-html="article.content" />
  </div>
</template>

<page-query>
query ($path: String!) {
  article (path: $path) {
    title
    content
  }
}
</page-query>

<script lang="ts">
import {computed, defineComponent} from "@vue/composition-api";
import {usePageQuery} from "@/lib/vue-utils";
import {Article} from "@/models/Article";

export default defineComponent({
  setup() {
    const $page = usePageQuery<{article:Article}>(); // query type specified here
    const article = computed(() => $page.value.article);
    return {article} // type is ComputedRef<Article>
  }
})
</script>

Issue #6: Require vs Import

When migrating a Gridsome project to Typescript, there are two Javascript files that cannot be converted ÔÇÉ gridsome.server.js and gridsome.config.js. They use CommonJS "require" syntax instead of ES6-style imports. Luckily any complex functionality doesn't belong there anyway or can be refactored into separate plugins.

Conclusion

You should now be able to use Typescript in your Gridsome projects and get a better developer experience than what the standard Gridsome installation currently provides.

The only major remaining task is to generate types from inlined GraphQL queries. The best would be to publish it as a new plugin or as a part of the existing typescript plugin. All the other issues mentioned in this article have a workaround or are going to be fixed by IDE plugin developers.

We hope you found the information in this article useful. Should you have any questions or comments, feel free to contact us at: info@sparkteams.de