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.
- Prepare the transition to TypeScript
- Move to a TypeScript friendly world
- Find the proper timing
- Progress with a JavaScript / TypeScript hybrid codebase
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-sasshas to be replaced bysass, 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-serviceand 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.jsfile and copy or adapt the Webpack customisation. - Make sure
sass-loaderdata 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-loaderupdate, we had to change/deep/to::v-deepin 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: trueto allow a hybrid TypeScript + JavaScript codebaseresolveJsonModule: trueas 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_modulesfolder and re-install withnpm install --no-saveto ensure that thepackage-lock.jsonwould 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:
- As components now have a
MyComponent.vueandMyComponent.tsfiles, 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>;
- 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>