How We Refreshed Our Vue 2 JavaScript Codebase With Some TypeScript Flavour

How we created and maintain a TypeScript / JavaScript hybrid Vue.js codebase for a smooth and progressive transition.

Early 2021, we wanted to push for some codebase updates as the project was initialised some years ago.

Moving to Vue 3 was judged to be too hasty so as an intermediate goal, we went for some various improvements, the main ones being TypeScript introduction and Webpack configuration simplification.

Prepare the transition to TypeScript

Before anything else, the codebase needed some preparation, mainly various cleaning as well as some minor updates.

Clean up dependencies

Dependencies were the first things we started to clean:

  • Remove unused dependencies.
  • If necessary, bump existing dependencies to the latest version and ensure the application is not broken. We only bumped Vue related dependencies.
  • Replace deprecated dependencies by their new version.
    In our case, node-sass has to be replaced by sass, styling code was not impacted.

Switch Webpack configuration to @vue/cli-service

The project Webpack configuration was a customized one but a quick glance reveals that it is an "ejected" version of a configuration generated by @vue/cli (formerly vue-cli at the time the project was initialised).

Getting back the pre-packaged Webpack configuration would avoid all the Webpack troubles as well as make other configurations easier such as Jest or Storybook.

  • Install @vue/cli-service and related dependencies:
    npm install --save-dev --save-exact \
      @vue/cli-plugin-babel \
      @vue/cli-plugin-eslint \
      @vue/cli-plugin-router \
      @vue/cli-plugin-unit-jest \
      @vue/cli-plugin-vuex \
      @vue/cli-service \
      @vue/eslint-config-prettier \
      @vue/test-utils \
      vue-template-compiler
    
  • Create a vue.config.js file and copy or adapt the Webpack customisation.
  • Make sure sass-loader data prepending is properly configured depending on loader version:
    • sass-loader^9.0.0: additionalData: "..."
    • sass-loader^8.0.0: prependData: "..."
    • sass-loader before 8: data: "..."
  • Rename environment variables with the VUE_APP_ prefix.

Ensure the application works before moving further. At this point, the old Webpack configuration is now useless.

  • Remove the Webpack specific dependencies as most of them are a dependency of @vue/cli-service.
  • Delete old custom Webpack configuration files.

Most likely due to some vue-loader update, we had to change /deep/ to ::v-deep in our scoped styling. Reference: StackOverflow answer

Clean tooling configuration

With @vue/cli-service ecosystem, tooling configuration can be simplified by relying on presets:

// babel.config.js
module.exports = {
  presets: ["@vue/cli-plugin-babel/preset"],
};

// jest.config.js
module.exports = {
  preset: "@vue/cli-plugin-unit-jest",

  // Override test files matching
  testMatch: ["**/*.spec.js"],

  // Add @tests path alias which is test files specific
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1",
    "^@tests/(.*)$": "<rootDir>/tests/$1",
  },
};

Clean more

Additionally, some miscellaneous cleaning was also done:

  • Refresh the public/ folder content.
  • Clean Storybook configuration.
  • Provide some minor changes for i18n configuration.

Move to a TypeScript friendly world

Now that @vue/cli-service does the heavy lifting for Webpack configuration, adding TypeScript is much easier.

Add TypeScript dependencies

When installing TypeScript, make sure that the TypeScript version is compatible with your current Vue ecosystem. We ended up picking a pre 4.0.0 version. If the latest TypeScript version works for your project, go for it.

npm install --save-dev typescript@^3.9.9

Add the various @types/* dependencies. For example, to add Jest typing:

npm install --save-dev @types/jest

Add the different TypeScript related tooling dependencies.

npm install --save-dev \
  @typescript-eslint/eslint-plugin \
  @typescript-eslint/parser \
  @vue/cli-plugin-typescript \
  @vue/eslint-config-typescript

Few changes are now needed before having a runnable application.

Add the tsconfig.json file

Copying the default tsconfig.json generated by @vue/cli for a TypeScript project can do. However, two additional entries were required:

  • allowJs: true to allow a hybrid TypeScript + JavaScript codebase
  • resolveJsonModule: true as we have some JSON import
Full tsconfig.json example
// tsconfig.json

{
  "compilerOptions": {
    "allowJs": true, // For a hybrid project
    "resolveJsonModule": true, // For allowing importing JSON
    "target": "esnext",
    "module": "esnext",
    "strict": true,
    "jsx": "preserve",
    "importHelpers": true,
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true,
    "baseUrl": ".",
    "types": ["webpack-env", "jest"],
    "paths": {
      "@/*": ["src/*"],
      "@tests/*": ["tests/*"] // Additional path alias
    },
    "lib": ["esnext", "dom", "dom.iterable", "scripthost"]
  },
  "include": [
    // .tsx extension is removed
    "src/**/*.ts",
    "src/**/*.vue",
    "tests/**/*.ts"
  ],
  "exclude": ["node_modules"]
}

Create shims file

Only the src/shims-vue.d.ts is created because our project was using Storybook and adding JSX typing would conflict with Storybook typings.

// src/shims-vue.d.ts

declare module "*.vue" {
  import Vue from "vue";
  export default Vue;
}

Update tooling configuration

If the Babel configuration is already using @vue/cli-plugin-babel/preset, no change is required. Otherwise, please use the preset:

// babel.config.js

module.exports = {
  presets: ["@vue/cli-plugin-babel/preset"],
};

Similarly, use the @vue/cli-plugin-unit-jest/presets/typescript-and-babel preset in the Jest configuration:

// jest.config.js

module.exports = {
  preset: "@vue/cli-plugin-unit-jest/presets/typescript-and-babel",

  // Override test files matching
  testMatch: ["**/*.spec.js"],

  // Add @tests path alias which is test files specific
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1",
    "^@tests/(.*)$": "<rootDir>/tests/$1",
  },
};

Regarding ESLint, there is a trick here. As the codebase will have both JavaScript and TypeScript Vue components, the ESLint configuration must support both at the same time. However, ESLint cannot apply JavaScript rules to JavaScript Single File Components (SFC) and TypeScript rules to TypeScript SFC based on <script lang="ts"> or <script> definition.

Consequently, Vue SFC will have the JavaScript rules applied and the TypeScript linting will be applied to TypeScript files only.

To write a Vue component in TypeScript, we went for an external TypeScript file:

<template>
  <!-- Template here -->
</template>

<script lang="ts" src="./MyComponent.ts"></script>

<style lang="scss" scoped>
/* Styling here */
</style>

Also, Vue linting is upgraded from essential to the stricter recommended preset.

Complete ESLint configuration
// .eslintrc.js

module.exports = {
  root: true,
  env: {
    node: true,
  },

  extends: [
    "plugin:vue/recommended", // stricter than vue/essential
    "eslint:recommended",
    "@vue/prettier",
  ],
  parserOptions: {
    ecmaVersion: 2020,
  },
  rules: {
    "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
    "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
  },
  overrides: [
    // To support a hybrid TypeScript / JavaScript Vue codebase, only
    // TypeScript files will have TypeScript linting. Once all components
    // are written in TypeScript, this override will no longer be necessary.
    {
      files: ["*.ts", "*.tsx"],
      extends: [
        "plugin:vue/essential",
        "eslint:recommended",
        "@vue/typescript/recommended",
        "@vue/prettier",
        "@vue/prettier/@typescript-eslint",
      ],
      rules: {
        // @ts-ignore comment is required for the transition period
        "@typescript-eslint/ban-ts-comment": "off",
      },
    },
    // Jest / Testing files
    {
      files: ["**/*.spec.{j,t}s?(x)", "jest.setup.js", "tests/**.*{j,t}s"],
      env: {
        jest: true,
      },
    },
  ],
};

Change the main.js to main.ts

If renaming the main.js into main.ts is enough, you are very lucky. In practice, some // @ts-ignore are to be expected. This is why, the ban-ts-comment rule has been disabled in the ESLint configuration above.

At this stage:

  • The application should run and build without error.
  • The existing unit tests, being written in JavaScript importing JavaScript components, should still pass.
  • Linting can fail as stricter rules are now applied.

Find the proper timing

All these changes look nice on the paper but in our reality, timing was a big challenge. Such a large scale change impacted all on-going development so we had to find some little time window between two big features implementation to slot in this breaking change.

What we did:

  • A (massive) pull request was created to include all the changes mentioned above.
  • The pull request was reviewed by multiple peers.
  • Once approved, we had to determine the proper timing to merge the giant pull request.
  • Until merging, the pull request has to be regularly updated to ensure that released features would not have conflict with the new setup.
  • Merge the giant pull request.
  • Deploy the application with the new configuration on some testing environment to ensure no regression is found.
  • Have everyone clean up their node_modules folder and re-install with npm install --no-save to ensure that the package-lock.json would not be modified. Indeed, we did only bump Vue related dependencies.
  • Be happy that we can now write code in TypeScript...and realise that it was only the beginning as we have to figure out how to manage a hybrid TypeScript / JavaScript Vue.js codebase.

Progress with a JavaScript / TypeScript hybrid codebase

Realistically, a codebase, cannot be fully converted into TypeScript overnight. So we needed to find a way to write new code in TypeScript while maintaining the existing JavaScript codebase. Whenever we have the chance to convert some JavaScript code, we would of course do it but not all opportunities allow this kind of extra workload.

Write class style components

Options API is somehow limited when it comes to typing support. Although it could work for simple components, it became complicated when Vuex getters and actions get involved.

We decided, like many Vue 2 developers, to go with Vue class style component with vue-property-decorator:

npm install vue-class-component vue-property-decorator

Note that vue-property-decorator re-exports Vue and Component (see the index.ts) so that all imports come from vue-property-decorator:

import { Component, Prop, Vue } from "vue-property-decorator";

@Component()
export default class MyComponent extends Vue {
  @Prop({ type: Number, required: true })
  readonly count!: number;
}

Adopt vuex-module-decorators to type Vuex

To be frank, our Vuex structure was not very glorious at that time and there were multiple payload errors when dispatching actions. Consequently, having fully typed modules was not satisfying enough as we wanted type checking when calling actions as well.

Thanks to The State of Typed Vuex: the Cleanest Approach article by @Caleb Viola, we went for vuex-module-decorators:

npm install vuex-module-decorators

We had a file to "re-export" the modules to make our life easier:

// src/stores/modules/index.ts

import { getModule } from "vuex-module-decorators";

import MyModule from "@/store/modules/my-module.ts";

type Store = Parameters<typeof getModule>[1];

export function myModule(store: Store): MyModule {
  return getModule<MyModule>(MyModule, store);
}

// Add all other module re-exports below

// -------------------

// In some component file
import { Component, Prop, Vue } from "vue-property-decorator";

import { myModule } from "@/store/modules";

@Component()
export default class MyComponent extends Vue {
  async mounted() {
    await myModule(this.$store).someAction();
  }
}

Note that we did not directly import the store definition from @/store and defined type Store = Parameters<typeof getModule>[1];. This is for testing reasons so that we can pass mocked store as an argument.

No change for router & plain files (API wrappers, utilities...)

Apart from components and Vuex, we did not encounter specific issues for other files such as router-related or utilities files. As allowJs is true in the tsconfig.json, it was OK to have TypeScript files importing JavaScript files and vice-versa. The typical example is an index.js importing both TypeScript and JavaScript files.

Update unit testing

Two points have to be noted when it comes to unit testing:

  1. As components now have a MyComponent.vue and MyComponent.ts files, we have to make sure than component import explicitly specify the file extension:
// SomeComponent.spec.ts

import { mount, Wrapper } from "@vue/test-utils";
import SomeComponent from "./SomeComponent.vue";

let wrapper: Wrapper<SomeComponent>;
  1. Some testing files can be written in TypeScript while the tested file remains in JavaScript. To do so, a little configuration update was required to make it work:
// jest.config.js

module.exports = {
  preset: "@vue/cli-plugin-unit-jest/presets/typescript-and-babel",

  // Override test files matching
  testMatch: ["**/*.spec.(j|t)s"],

  // Add @tests path alias which is test files specific
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1",
    "^@tests/(.*)$": "<rootDir>/tests/$1",
  },

  globals: {
    "ts-jest": {
      babelConfig: true, // inherited from preset
      tsConfig: "<rootDir>/tsconfig.test.json", // for JS / TS cross import
    },
  },
};

With:

// tsconfig.test.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs"
  }
}

Fine tune linting check

Now that stricter linting rules are enforced, we want to have only newly added and modified files to be checked on each pull request. Rather than refactoring the whole codebase in one-go, we decided to go for a progressive approach.

Obviously, some files will hardly be touched again so we have to specially tackle the linting errors in those files at some point.

Using GitHub actions, we rely on dorny/paths-filter to restrict the linting check to the desired files set:

# .github/workflows/<your workflow file>.yaml

# ...
- name: Get list of modified / added files
  uses: dorny/paths-filter@v2
  id: filter
  with:
    list-files: shell
    filters: |
      to_lint:
        - added|modified: '**/*.?(js|ts|vue)'

- name: Lint modified / added files only
  if: ${{ steps.filter.outputs.to_lint == 'true' }}
  run: npm run <lint script> -- --max-warnings=0 $(./ignore ${{ steps.filter.outputs.to_lint_files }} | xargs -0) "src/main.ts"
# ...

To run the linting check locally, some magic command, taken from 8 Git Tips to Improve Code Review, could help:

git diff --name-status <target branch>... | awk '/^[A|M].*\.(js|ts)$/ {print $2}' | xargs npm run <lint script>