Migration to TypeScript in Webpack + Vue 3 setup

Intro

MartialMatch.com (initially known as panel-rejestracyjny.pl for the Polish audience) is my web application that helps organize various martial arts events (BJJ, MMA, etc.).

Below is the story of my migration of the frontend codebase from JavaScript to TypeScript.

Context

I started development in 2013. In the beginning, it was a mix of CakePHP and jQuery code. Everything was working fine, but the codebase was getting bigger and harder to maintain. Around 2016, I decided to start writing new frontend components in Vue 2. That was the framework I was familiar with from my 9-5 job at Egnyte.

Back then, I didn’t use any bundler. I just included Vue 2 via a CDN in the HTML served by PHP, and that was it. My next obvious choice was to use a JS bundler. I chose Webpack, which was pretty popular back then (2017).

Then, single-file components in Vue 2 were a game changer for me. All the related logic could be nicely encapsulated in one file. Later, more advanced state management was added with Vuex (currently Pinia).

But it was all written in JavaScript — until last week.

I decided to begin migrating the codebase to TypeScript. On a daily basis, at Lokalise, we work with TypeScript, and I got used to it. Strict typing really helps when refactoring code and avoiding runtime bugs. At this point, my codebase is quite large (for a solo developer), and there’s a lot of logic in the frontend. Having strict types will make my life easier — especially when I need to read the code I wrote a few years ago 😅.

Size of the project

Running npx cloc . for frontend gives following output:

-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Vuejs Component                312           3132            303          37586
TypeScript                     215            873            291           9805
SCSS                            76            553             16           2849
JSON                             2              0              0           1295
CSS                              2              0              0           1177
JavaScript                       2              0              2             12
-------------------------------------------------------------------------------
SUM:                           609           4558            612          52724
-------------------------------------------------------------------------------

I’m the only contributor so for me it’s a lot of code to maintain. 😉

Mistakes I made along the way

The idea of migration was already in my head in May 2024.

I had my first attempt back then:

  • Started with the Webpack config.
  • Added ts-loader to dependencies.
  • Found a tsconfig.json file on the internet and copied it.

After running npm run build, I got a lot of errors, and ESLint was also complaining a lot. Somehow, I felt that PHPStorm was not showing the correct errors.

In the meantime, while researching on the internet, I discovered that Webpack is not necessarily the best choice for a JS bundler nowadays.

I felt demotivated and decided that migrating to Vite should be the right first step. So, I abandoned the work and postponed it.

Vite

As mentioned above, I believed that Vite was a must-have for a correct TS setup in my project.

When I finally found some time to tackle it during the weekend, it quickly turned out that Vite is a completely different world compared to Webpack. I had to learn about the new concepts that Vite follows and then adapt them to my clunky Webpack setup.

The same story happened again: after a whole day of researching and trying to make it work, I was only getting more errors in the terminal. I felt like I wasn’t getting anywhere, so I decided to stop, as I needed to keep up with the features I was developing.

Another attempt for bundler migration: Rollup

A month or two later (October 2024), I had another attempt to change the bundler (I still believed that Webpack was not the right choice). This time, I decided to try Rollup. I found a nice article about it and decided to give it a try. The setup seemed pretty easy. But I ended up in the same trap — my Webpack setup was too complex. I would need to dedicate more time to make it work than I initially wanted.

I wasted another day and decided to postpone it again. The bad news was that I still couldn’t have strict typing in my frontend codebase. 😭

Going back to Webpack

A week ago (December 2024), I had a chat with a friend of mine, who is also a developer. He told me that I should stick with Webpack. He uses it in his projects and said, “it just works.” He also shared with me an example Webpack configuration and a tsconfig.json file. I decided to give it another try.

Adding dependencies

Start with ts-loader & typescript:

npm install --save-prod ts-loader typescript @types/jquery

In my case, they are production dependencies due to a Docker setup that, during the CI build, fetches only production packages. In a few places, I rely on jQuery, so I also need to install its types.

ESLint

I’m using eslint in my project, so I also need to install @typescript-eslint/parser and @typescript-eslint/eslint-plugin:

npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin

package.json

"dependencies": {
+  "@types/jquery": "^3.5.32",
   ...
+  "ts-loader": "^9.5.1",
+  "typescript": "^5.7.2",
},
"devDependencies": {
+  @typescript-eslint/parser
+  @typescript-eslint/eslint-plugin
}

TypeScript configuration

tsconfig.json

{
    "compilerOptions": {
        "baseUrl": ".",
        "resolveJsonModule": true,
        "module": "esnext",
        "declaration": false,
        "noImplicitAny": false,
        "target": "esnext",
        "importHelpers": true,
        "allowJs": true,
        "allowSyntheticDefaultImports": true,
        "allowUnreachableCode": false,
        "allowUnusedLabels": false,
        "forceConsistentCasingInFileNames": true,
        "noEmitOnError": false,
        "noFallthroughCasesInSwitch": true,
        "noImplicitReturns": true,
        "pretty": true,
        "sourceMap": true,
        "strict": true,
        "moduleResolution": "node",
        "outDir": "dist"
    },
    "include": [
        "./app/Frontend/**/*.ts",
        "./app/Frontend/**/*.vue"
    ],
    "exclude": ["node_modules"],
    "files": [
        "./app/Frontend/vue-shims.d.ts",
        "./app/Frontend/globals.d.ts"
    ]
}

To sum up:

  • files - point to your source code
  • noImplicitAny - set to false, initially you will get a lot of errors because of that (later more about it)

Type declarations

I had to create some type declarations for Vue. I created following file:

vue-shims.d.ts

// Tell the TypeScript compiler that importing .vue files is OK
// Source: https://github.com/vuejs/core/issues/2627#issuecomment-799364296
declare module "*.vue" {
    // NOTE: ts-loader
    import { defineComponent } from "vue";

    const component: ReturnType<typeof defineComponent>;
    export default component;
}

All kinds of globals that I use in window object are defined in globals.d.ts file, just for the record:

declare global {
    interface Window {
        panelBundles: Record<string, any>;

        panelLocale: string;
        panelConstants: Record<string, string | number>;
        panelCsrfToken: string;
        jQuery: any;
        $: any;
        Vue: any;
        emptyTableAlert: (tableElementId: string, alertElement: any, showWithTableElements: any[]) => void;

        modalRegistry: number[];
    }
}

Webpack config update

webpack.config.js:

module.exports = {
    devtool: "source-map",
    entry: {
-       globals: "./app/Frontend/Global/globals.js",
+       globals: "./app/Frontend/Global/globals.ts",
-       polyfill: "./app/Frontend/Global/polyfill.js",
+       polyfill: "./app/Frontend/Global/polyfill.ts",
-       panel: "./app/Frontend/panel.ts",
+       panel: "./app/Frontend/panel.ts",
-       panelSuperadmin: "./app/Frontend/panelSuperadmin.ts",
+       panelSuperadmin: "./app/Frontend/panelSuperadmin.ts",
-       panelUser: "./app/Frontend/panelUser.ts",
+       panelUser: "./app/Frontend/panelUser.ts",
-       panelScoreboard: "./app/Frontend/panelScoreboard.ts",
+       panelScoreboard: "./app/Frontend/panelScoreboard.ts",
-       bulma: "./app/Frontend/Bulma/index.ts",
+       bulma: "./app/Frontend/Bulma/index.ts",
-       bulmaScripts: "./app/Frontend/Bulma/bulmaScripts.ts",
+       bulmaScripts: "./app/Frontend/Bulma/bulmaScripts.ts",
    },
    output: {
        path: __dirname + "/public/js/",
        chunkFilename: `[chunkhash].bundle.js?${Date.now()}`,
        filename: "[name].bundle.js",
        publicPath: baseUrl.get() + "/js/",
    },
    resolve: {
        modules: ["node_modules"],
-       extensions: [".js", ".vue"],
+       extensions: [".js", ".vue", ".ts"],
        fallback: { url: require.resolve("url/") },
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: "babel-loader",
            },
            {
                test: /\.vue$/,
                loader: "vue-loader",
                options: {
                    compilerOptions: {
                        whitespace: "preserve",
                    },
                },
            },
+            {
+                test: /\.ts$/,
+                exclude: /node_modules/,
+                loader: "ts-loader",
+                options: {
+                    appendTsSuffixTo: [/\.vue$/],
+                },
+            },
            {
                test: /\.css$/,
                use: ["vue-style-loader", "css-loader"],
            },
            {
                test: /\.scss$/,
                use: [
                    "vue-style-loader",
                    MiniCssExtractPlugin.loader,
                    {
                        loader: "css-loader",
                    },
                    {
                        loader: "sass-loader",
                        options: {
                            sourceMap: true,
                            // options...
                        },
                    },
                ],
            },
            //...
        ],
    },
    //...
};

//...
  • resolve.extensions - Tiny change but very important, it tells webpack to look for .ts files.
  • module.rules - Standard definition of ts-loader for Vue framework was added.

.eslintrc.js

-extends: ["eslint:recommended", "plugin:vue/recommended"],
+extends: ["eslint:recommended", "plugin:vue/recommended", "plugin:@typescript-eslint/recommended"],
    parser: "vue-eslint-parser",
    parserOptions: {
-       parser: "@babel/eslint-parser",
+       parser: "@typescript-eslint/parser",
        sourceType: "module",
        allowImportExportEverywhere: true,
    },

Really straight forward change, no surprises here.

Renaming all extensions from .js to .ts

ChatGPT was helpful here. I generated a bash script that iterated over all files in the project and renamed them from .js to .ts.

#!/bin/bash

# Check if directory is provided as an argument
if [ -z "$1" ]; then
  echo "Usage: $0 <directory>"
  exit 1
fi

# Assign the directory to a variable
dir="$1"

# Check if the directory exists
if [ ! -d "$dir" ]; then
  echo "Error: Directory '$dir' does not exist."
  exit 1
fi

# Find and rename .js files to .ts
find "$dir" -type f -name "*.js" | while read -r file; do
  new_file="${file%.js}.ts"
  mv "$file" "$new_file"
  echo "Renamed: $file -> $new_file"
done

echo "All .js files have been renamed to .ts in the directory '$dir'."

Running the build

After all the changes, I ran npm run build, and it worked! 🎉

I mean… it produced a lot of errors, but they were manageable. Despite having Vue 3 and using the Composition API, which works very well with TypeScript, I still have old code that uses the Options API with mixins. Mixins, in particular, are not very TypeScript-friendly.

That’s why I decided to add @ts-ignore / @ts-nocheck / @ts-expect error to all the places where:

  • I have Vue 2 mixin.
  • I have some jQuery code. Actually… errors shown by Webpack got my attention to the dead code that should be deleted long time ago.

The rest of the files didn’t need to be changed. Due to noImplicitAny set to false in tsconfig.json, I didn’t have to add types to all the variables in the project. I promised myself that every time I touch such untyped code in the future, I will add types to it.

Most of the reported errors were about invalid assignments of variables (e.g., making a variable a number and then attempting to concatenate it with a string).

Refactoring Vue components to TS

Vue components are not a problem now. To make a Vue component work with TypeScript, the <script> tag definition needs to be changed to <script lang="ts">. I decided to rewrite all components from the “back office” module to TypeScript.

Most of the changes I had to make in the components included:

//Changing the way of defining props
- const props = defineProps(['eventId']);
+ const props = defineProps<{ eventId: number }>();

//Defining refs
- const event = ref(null);
+ const event = ref<Event | null>(null);

I also use Pinia for state management, changes in the store were also needed:

//State definition, only complex types need to be defined, rest of the types are inferred.
state() {
    return {
        fightId: 0,
        eventId: 0,
        timeOfEachRoundInSeconds: 0,
        roundInProgress: 0,
        breakInProgress: false,
-       scoreboardConfiguration: {},
+       scoreboardConfiguration: {} as ScoreboardConfiguration,
        timerClass: "timer-before-start",

        major1: 0,
        major2: 0,
        penalties1: 0,
        penalties2: 0,

-       pointsHistory1: [],
-       pointsHistory2: [],
+       pointsHistory1: [] as Array<PointsHistoryEntry>,
+       pointsHistory2: [] as Array<PointsHistoryEntry>,
    };
},

At this point, Webpack was still reporting some errors, such as warnings for accessing properties of an object that could be null. Unfortunately, there was a mismatch between the reported line number in the terminal and the actual position in the file. I still didn’t fix it; I’m not sure if it’s worth the effort. PHPStorm initially had trouble recognizing all the errors. I also knew that VS Code comes with nice, out-of-the-box support for TypeScript. So, I briefly reviewed the changed files using it. To my surprise, it correctly found all the problems.

Summary

Code was ready to deploy. E2E and FE unit tests found a few errors that I missed. The fixes were quick. Now, the development experience will be much better.

I dedicated around 1-2 hours in the evening for a week, so somewhere between 7-14 hours. I’m happy that I finally did it. I could have done it in May, but hey… better late than never. 😎

My next steps will be:

  • Every time I touch a non-TS Vue component, I’ll refactor it. GitHub Copilot will be very helpful here, especially when it’s an old component with the Options API + mixins. It does all the boring work, and then I just need to make a few adjustments. After that, it’s a matter of adding lang="ts" and fixing the TypeScript errors.
  • The same rule will apply to .ts files. Getting rid of the @ts-ignore comments will be my priority, along with adding types to avoid implicit any. Also, GitHub Copilot is helpful when adding types to function arguments. One prompt explaining, for example, “eventId should be a number, userId should be a number,” etc., will be enough to apply types everywhere in the file.
  • Maybe a new JS bundler? 🤔

Contents