Nuxt + TypeScript + Apollo: a bumpy road

Nuxt, TypeScript, and Apollo. You've probably heard awesome things about all three. So what would be more awesome than using the 3 of them together, right?

I must admit, I'm not a big fan of TypeScript, but I've been wanting to try using it with Nuxt for some time now. I've tried unsuccessfully a couple of times because the lack of documentation makes it difficult to use it in a real project. But recently a great opportunity came up: an internal project, no client that needs a final product right away, complete freedom to choose the technologies we want.

Install

Let's assume you already have your Nuxt project set up (if you don't, you can follow the instructions here). First step, as the guide says, is installing @nuxt/typescript-build and adding it in the buildModules section of the nuxt.config.js. No issues here! Optionally, you can install @nuxt/typescript-runtime if you want to use TypeScript for files that are not compiled by Webpack.

Linting

If you want linting (who doesn't?), you should install @nuxtjs/eslint-config-typescript and extend your ESlint configuration with it. The guide mentions that, if you already have your Nuxt project set up with @nuxtjs/eslint-config, you should remove it from your dependencies, but fails to mention to remove parser: 'babel-eslint' from your .eslintrc.js as well. I lost a lot of time because of that. First bump! My final ESlint config with Prettier looks like this:

    module.exports = {
      root: true,
      env: {
        node: true
      },
      extends: [
        '@nuxtjs',
        '@nuxtjs/eslint-config-typescript',
        'prettier',
        'prettier/vue',
        'plugin:prettier/recommended',
        'plugin:nuxt/recommended'
      ]
    }

I recommend disabling the default error overlay that appears when there is a lint issue, as it can be really annoying while developing the app. Instead, it's better and more practical to rely on CI tools or git hooks to make sure no linting errors leak to production, and the errors will still appear on the console and in your terminal. You can disable the overlay by adding this to build in your nuxt.config.js:

    build: {
      // ...
      hotMiddleware: {
        client: {
          overlay: false
        }
      }
    }

Components

Now, let's start building our app! There are 3 ways to build your components with TypeScript: the options API (most similar to Nuxt.js regular usage), the class API (might look more familiar if you are used to Angular), and the composition API (like the upcoming Vue 3.0's composition API).

My first approach was using the options API, as it's what I'm used to and I thought it would create less friction. Everything was more or less working like a regular Nuxt.js project (except having to add the .vue extension when importing components, which I had always skipped) until I had to use the asyncData function. If you are not familiar with it, it's like data, a function that allows us to set our component's state, but asynchronously. You can use both of them and they will merge, so if you set up the variable foo with data and bar with asyncData, you are able to use this.foo and this.bar in your component in the exact same way.

But sadly, that's not the case when using TypeScript. While TypeScript can infer correctly the types of data, computed, etc; that's not the case with asyncData . So the following code, which would be what a regular Nuxt developer might do, will raise an error:

    interface Test {
      foo: string
      bar: number
    }

    export default Vue.extend({
      asyncData(): Test {
        // this is syncronous to keep the example minimal
        return {
          foo: 'hi',
          bar: 1
        }
      },
      methods: {
        test() {
          console.log(this.foo) // error here Property 'foo' does not exist on type…
        }
      }
    })

If you want to make this work with the options API, the only way is to declare the state type in the data function as well:

    interface Test {
      foo: string
      bar: number
    }

    export default Vue.extend({
      data(): Test {
        return {
          foo: '',
          bar: 1
        }
      },
      asyncData(): Test {
        return {
          foo: 'hi',
          bar: 1
        }
      },
      methods: {
        test() {
          console.log(this.foo) // no error here!
        }
      }
    })

Needless to say, this makes both writing and reading the code cumbersome, and can lead to errors if you type data and asyncData differently. It kind of loses the point of using TypeScript.

The same code is a bit more readable if you use the class API, though:

    interface Test {
      foo: string
      bar: number
    }

    @Component({
      asyncData (): Test {
        return {
          foo: 'hi',
          bar: 1
        }
      }
    })
    export default class MyComponent extends Vue implements Test {
      foo = ''
      bar = 1

      test() {
        console.log(this.foo)
      }
    }

You still need the double typing, but at least it's a bit less cumbersome. This, along with the fact that there are many more examples online of people using the class API, made me ditch the options API in favor of this approach. I'd prefer to use the composition API since it seems that it's where Vue is headed, but I also found very little documentation and examples, and don't want to keep finding so many bumps!

Another annoying detail I've found is when working with props. In regular JavaScript, you can declare props with their type, set if they are required or not, and a default value, like this:

    export default {
      props: {
        foo: {
          type: String,
          required: true
        },
        bar: {
          type: Number,
          default: 1
        }
      }
    }

This is useful because you get actual errors in your console if you mess up and pass the wrong type. In order to get both errors on runtime and type checking with TypeScript, you need to double type again:

    export default class MyComponent extends Vue {
      @Prop({ type: String }) foo!: string
      @Prop({ type: Number, default: 1, }) bar!: number
    }

(The ! tells TS that the variable will never be null or undefined, as it comes from the parent component, otherwise it would complain since it's not initialized)

I understand fixing those things might be really hard, as TypeScript with Vue and Nuxt is not a core feature like it is with Angular, so this is in no way trying to diminish the hard work done by the Nuxt team. Just a heads up to not expect the robustness you might be used to when working with pure TypeScript or Angular, at least for now.

Apollo

The next and final step, if you are working with GraphQL, is installing @nuxtjs/apollo and add it as a module in your nuxt.config.js. You also need to add an apollo object with your configuration. You can find all the options in the docs, but the only required field is httpEndpoint, so you'll likely end up with a configuration that looks like this:

    {
      // ...
      modules: [
        // ...
        '@nuxtjs/apollo'
      ],
      apollo: {
        clientConfigs: {
          default: {
            httpEndpoint: 'https://myapi.com/graphiql'
          }
        }
      },
    }

You will also need to add "vue-apollo/types" to the types array in your tsconfig.json.

Now, let's finally write some queries, right? I prefer to have all the queries in a .graphql file than use the gql template tags. However, if we try to import them to our Vue components, we'll see that TypeScript does not recognize them as modules. It's time to install GraphQL Code Generator! Apart from recognizing the modules we'll also need to get the type of our queries results, so we'll need to install a few packages:

    npm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-graphql-files-modules @graphql-codegen/typescript-operations

Now we'll need to create a codegen.yml with our config. You might want to adjust the documents and the generated types path to match your project structure:

    overwrite: true
    schema: "https://myapi.com/graphiql"
    documents: "apollo/**/*.graphql"
    generates:
      types/graphql.d.ts:
        - typescript-graphql-files-modules
      types/types.ts:
        - typescript
        - typescript-operations

Finally, add a script to your package.json to generate the types, and run it:

    "generate-types": "graphql-codegen --config codegen.yml"

Now we can finally add our queries to the components! I also spent some time trying to figure out how to add the Apollo object to the component. I found some examples that used a getter, but that didn't work for me. After trial and error I found that adding it to the decorator was the way to go:

    import VideosQueryGQL from '~/apollo/queries/videos.graphql'

    @Component({
      apollo: {
        videos: {
          query: VideosQueryGQL,
          variables: {
            order: 'popular',
            perPage: 5
          }
        }
      }
    })
    export default class IndexPage extends Vue {}

With this, I can use videos in the template without any issue (so far I haven't managed to enable type checking in the template), but when using it on our component logic it will raise an error, as the decorator is not capable of modifying the component type. So, again, to make this work we'll need to define videos in our component as well (that's why we generated the types for our queries!). Since we're typing things, we can also add the type of our query variables, to ensure we are sending the right types and required fields:

    import { VideosQuery, VideosQueryVariables } from '~/types/types'
    import VideosQueryGQL from '~/apollo/queries/videos.graphql'

    @Component({
      apollo: {
        videos: {
          query: VideosQueryGQL,
          variables: {
            order: 'popular',
            perPage: 5
          } as VideosQueryVariables
        }
      }
    })
    export default class IndexPage extends Vue {
      videos: VideosQuery | null = null

      get watchedVideos() {
        // now we can use this.videos and have it type checked!
        return this.videos ? this.videos.filter(video => video.watched) : null
      }
    }

Testing

Now, how good is a project without tests, right? I could write a whole post on testing (I probably will), but for now, I'm just gonna leave some tips on how to properly configure tests in TypeScript. If you already configured your project with tests, we just need to tweak a bit the configuration. We'll install @types/jest and ts-jest, and add the latter as a preset, and add ts to the moduleFileExtensions array.

Here's the full configuration:

    module.exports = {
      preset: 'ts-jest',
      moduleNameMapper: {
        '^@/(.*)$': '<rootDir>/$1',
        '^~/(.*)$': '<rootDir>/$1',
        '^vue$': 'vue/dist/vue.common.js'
      },
      moduleFileExtensions: ['ts', 'js', 'vue', 'json'],
      transform: {
        '^.+\\.js$': 'babel-jest',
        '.*\\.(vue)$': 'vue-jest'
      }
    }

Now you're ready to start writing tests! I choose to do my tests with Vue Testing Library, which follows a more pragmatic approach and prevents you from testing implementation details, so you focus on confidence rather than on code coverage (that's why there is no collectCoverage in the config).


Phew! It's not been easy, but we finally have a project set up that works. We'll probably find more bumps along the way, but I'm confident there will be a way to overcome them. Still, I wouldn't recommend using Nuxt with TypeScript to everyone. Where Vue and Nuxt shine over other frameworks is on the ease of use and agile development. Using TypeScript takes a big part of that away, partly because TypeScript itself makes development slower (in exchange of other things), but mostly because the integration does not offer the smoothness we are used to in Vue Happy Land. Let's hope that once Vue 3.0 is released, TypeScript support will be more of a first-class citizen and the road will be easier to drive in.

Cover photo by Godwin Angeline Benjo