Gradual move to TypeScript. Running JavaScript and TypeScript side-by-side

TypeScript has a lot of advantages. However, it's not often that you are starting a brand new project. More often though you are working on an existing codebase and simply don't have luxury of rewriting everything from scratch. After all, we need to deliver new features and fix annoying bugs.

But we shouldn't despair - it's possible to gradually move your projects to TypeScript - one module at a time - and run TypeScript and JavaScript side-by-side.

Example Project

As an example we'll use a simple React & Webpack project. Initially, it'll look like the left-hand side of the diagram - just two modules (App and JsComponent). And gradually we'll move onto the right-hand side the picture where we'll have JavaScript and TypeScript components side-by-side.

modules architecture

Gradual Migration to TypeScript

And in order to keep those languages side-by side, we need to make sure that we can:

  • Import TypeScript modules from JavaScript ones
  • Import JavaScript modules into TypeScript code

If we can do that, we'll be able to move a project to TypeScript while working on features and bug fixes.

For example, if you have to add a new React component, you can write it straight in TypeScript. And you'll still be able to use inside other components written in JavaScript.

Initial Webpack setup

Since we use Webpack as a module bundler, I want to show its configuration for the pure JavaScript project. That will allow us to see what we started with and what we've added to enable the TypeScript support.

webpack.config.js

const HtmlWebPackPlugin = require("html-webpack-plugin");
const path = require("path");

module.exports = (env) => ({
  entry: {
    index: ["./src/index.jsx"],
  },
  output: {
    path: path.resolve("./dist/"),
  },
  devtool: env === "production" ? false : "source-map",
  module: {
    rules: [
      { enforce: "pre", test: /\.js$/, loader: "source-map-loader" },
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
      { test: /\.(ts|tsx)$/, loader: "awesome-typescript-loader" },
    ],
  },
  resolve: {
    extensions: [".js", ".jsx", ".ts", ".tsx"],
  },
  plugins: [new HtmlWebPackPlugin({ template: "./src/index.html" })],
});

As you can see it's a very simple setup. The main thing is the use of the babel-loader for loading of ES modules with JSX in them.

Import TypeScript modules into JavaScript ones

Okay, we're adding a new React component and have an opportunity to convert an existing one into TypeScript. This is a perfect example of importing TypeScript modules into JavaScript ones.

Let's start doing this by adding TypeScript and types for React to our project:

npm install --save-dev typescript @types/react

Next step would be to tsconfig.json to the root folder of the project to configure the TypeScript compiler. The important parts of the configuration are:

  • esModuleInterop: true - we won't be able to import the React module without it
  • sourceMap: true - enables source map for debugging

tsconfig.json

{
  "compilerOptions": {
    "outDir": "./dist/",
    "sourceMap": true,
    "noImplicitAny": true,
    "module": "commonjs",
    "target": "es6",
    "jsx": "react",
    "esModuleInterop": true,
    "strictNullChecks": true
  },
  "include": ["./src/**/*"],
  "rules": {
    "quotemark": [true, "single"]
  }
}

After this we can write our TypeScript component

src/ts-component/ts-component.tsx

import React from "react";

interface Props {
  myProp: number;
}

const TsComponent: React.FC<Props> = ({ myProp }) => (
  <div>
    Hi, I am a TypeScript component and I accept a number prop which is {myProp}
  </div>
);

export default TsComponent;

Now, if we try to import it from a JavaScript component:

src/js-component/js-component.jsx

import React from "react";
import TsComponent from "./ts-component/ts-component";

const JsComponent = () => (
  <>
    <div>
      Hi, I am a JavaScript component and I am calling a TypeScript component
    </div>
    <TsComponent myProp={123} />
  </>
);

export default JsComponent;

We'll get an error that Webpack is unable to resolve module ts-component/ts-component. Indeed, if we look at our Webpack configuration above, we'll see that we only instruct it to resolve .js and *.jsx files. Adding the .tsx to that list will not solve the problem - the babel-loader won't be able to parse the TypeScript syntax.

Luckily, we can solve this problem by adding the awesome-typescript-loader

So we just type

npm install --save-dev awesome-typescript-loader

in the terminal and add the following configuration to webpack.config.js:

webpack.config.js

module: {
rules: [
// existing rules...

    // new TypeScript configuration
    { test: /\.(ts|tsx)$/, loader: 'awesome-typescript-loader' },
    ]

},
resolve: {
// we also need to add \*.tsx here
extensions: ['.js', '.jsx', '.ts', '.tsx'],
}

Now, if we run the code - it'll work flawlessly!

Congratulations! We've added our first TypeScript module into an existing JavaScript project! And more importantly, it didn't require a rewrite of the whole project.

How did that work?

You may wonder how we've managed to import a module written in one language into another? Especially, given that language - TypeScript - has more features than the language we've imported it into - JavaScript.

That was possible thanks to Webpack loaders that transform an imported module on the fly into JavaScript. You can read more about them here.

But do you have to use Webpack? No! You can use whatever module bundler including Browserify. The main thing is to be enable transformation of imported modules on the fly into JavaScript.

What about debugging?

Can you still you the Chrome Debugger? Yes, you still can! First of all, the tsconfig.json we've created instructs the TypeScript compilier to produce source maps. Then the initial webpack.config.js already included source maps. In the Chrome Debugger you step straight into TypeScript code!

Importing a JavaScript Module into TypeScript

Let's have a look at the opposite example - importing a JavaScript module into TypeScript. In this case, I'm talking about importing an own module, as opposed to a 3rd party library, but some of the techniques are the same.

Also, if we're talking about importing an own JavaScript module into TypeScript, why would you do that in the first place? Well, if you have a large JavaScript codebase and you're working on a TypeScript module you may want to re-use your existing work in JavaScript without rewriting it in TypeScript.

Let's add a simple JavaScript component:

src/ts-component/another-js-component/another-js-component.jsx

import React from "react";
import PropTypes from "prop-types";

const AnotherJsComponent = ({ a, b }) => (
  <div>
    Hi, I am another JavaScript components. And these are my properties: a: {a}{" "}
    & b: {b}
  </div>
);

AnotherJsComponent.propTypes = {
  a: PropTypes.number.isRequired,
  b: PropTypes.string.isRequired,
};

export default AnotherJsComponent;

And we can simply import it into our TypeScript component:

src/ts-component/ts-component.tsx

import React from "react";
import AnotherJsComponent from "./another-js-component/another-js-component";

interface Props {
  myProp: number;
}

const TsComponent: React.FC<Props> = ({ myProp }) => (
  <>
    <div>
      Hi, I amm a TypeScript component and I accept a number prop which is{" "}
      {myProp}
    </div>
    <AnotherJsComponent a={1} b="foo" />
  </>
);

export default TsComponent;

It's all nice and easy, we don't need to change anything in the Webpack configuration. We can simply import JavaScript modules into the TypeScript code.

But are we missing anything?

Yes, we do miss type-safety! We don't know the types of the component properties we need to pass.

Is there a way to solve it without having to re-write AnotherJsComponent in TypeScript? Yes, we can provide our own type definitions while leaving the implementation of the JavaScript module intact.

That's exactly the approach that is used when dealing with 3rd party libraries - they either come with their own type definition files or we install them separately. In fact, we've already done that for React when we called npm install --save-dev @types/react.

What do we need to do to create our own type definition?

First, we need to create a file in the same folder as the imported module - another-js-component.d.ts and place the following contents there:

src/ts-component/another-js-component/another-js-component.d.ts

import React from 'react';

interface Props {
  a: number;
  b: string;
}

declare const AnotherJsComponent: React.FC<Props>;

export default AnotherJsComponent;

Don't Start with Renaming .js files into .ts

Some migration guides recommend you to start with renaming your .js files into .ts. That may sound reasonable, after all isn't TypeScript a superset of JavaScript? And should not existing JavaScript code be valid TypeScript code, as well?

Well, not so fast. Imagine, you have the following module:

const add = (a, b) => a + b;

export default add;

If we just change the file extention to .ts, we will get a TypeScript error. It will tell us that the input arguments a and b has an implicit type of any. And that will be completely reasonable. After all, it's the main purpose of TypeScript to provide static typing.

Now imagine if you're doing it on the whole codebase - it'll be full of TypeScript errors. Of course, you can set "noImplicitAny": false in your tsconfig.json. But that will not help you to prevent errors in legit TypeScript files.

Code

All the code is available on GitHub.

Conclusion

  • You don't have to move your projects to TypeScript in one big bang
  • Instead, you can move one module at a time
  • It's easy to import TypeScript code into JavaScript and vice versa
  • You can provide static types around JavaScript modules without having to rewrite them
  • Thanks to types definitions files
  • You also don't have to use Webpack. All you need is to be able to transform on the fly imported TypeScript modules into JavaScript
Mike Borozdin (Twitter)
2 November 2019

The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way. My personal thoughts tend to change, hence the articles in this blog might not provide an accurate reflection of my present standpoint.

© Mike Borozdin