07 December 2017

Written by Jon Bevan

Why?

Because all the cool kids are doing it.

Because types.

Specifically: code that is written in a language that permits the declaration of types gives the programmer very useful information about how the program is supposed to work.

Additionally, and perhaps more importantly, we can now get compile-time assurance that our application works with data that is in the correct shape/type, and therefore avoid a whole class of runtime bugs.

This is primarily the story of how we enabled usage of Typescript in our ES2017 project, rather than the story of how we converted the existing ES2017 code to TS.

Starting point

  • ~5k lines of ES2017 code
  • ~7k lines of JSX/React code
  • Webpack config with long-term caching and a load of 'common chunks'
  • Handful of Karma tests
  • Code coverage in Sonar

Additionally, we didn't want to have to convert all the code in one big bang. Unlike these guys.

We had been debating whether to use Flow or Typescript for a while, but the catalyst was when a colleague embraced Typescript on a more internal project and was able to sit down with us and help us through the initial migration/adoption process.

Interestingly I didn't really know about some of the differences in approach until after we'd completed this steps outlined below.

First steps

The first changes we made were configuration changes. We added Typescript and ts-loader as dependencies of our application:

yarn add --dev typescript ts-loader

We copied the tsconfig.json file from my colleague's internal project and modified it a bit:

{
  "compilerOptions": {
    "module": "esnext",
    "target": "es2015",
    "lib": ["es2017", "dom", "dom.iterable"],
    "sourceMap": true,
    "allowJs": true,
    "jsx": "react",
    "rootDirs": [
      "app"
    ],
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true
  },
  "exclude": [
    "node_modules",
    "build"
  ]
}

And then we updated our Webpack config to load the new files:

/* webpack.config.js */
resolve: {
    extensions: ['.ts', '.js', '.jsx'],
    modules: [
        'node_modules'
    ],
},
module: {
    rules: [
        {
            test: /\.tsx?$/,
            exclude: /node_modules/,
            use: [
                {
                    loader: 'ts-loader'
                }
            ]
        },
...

And finally we renamed a file that contained constants only to be .ts instead of .js - we chose this file because it would be valid Typescript immediately but was also used by other JS files so it was a good candidate for proving in a simple way that our build pipeline still worked as expected and we could use TS files from within our JS files.

External Dependencies

Thankfully when we next ran our webpack build everything still worked! Encouraged by our great success we picked a file that had an external module (jwt-decode) dependency, to ensure we could load external type definitions.

We renamed the file, and got some TS compiler errors because the type definitions didn't exist. Thankfully my colleague knew we could just try adding the following dependency to our project:

yarn add --dev @types/jwt-decode

That helped, but then we had to actually modify our file that used that library to fix the compile-time bugs - in particular there were a few places in our code where we could have had trouble accessing properties on a null value.

Linting

We added tslint and tslint-loader and a tslinting config file, and updated our webpack config to use those:

/* webpack.config.js */
module: {
    rules: [
        {
            test: /\.tsx?$/,
            exclude: /node_modules/,
            enforce: 'pre',
            use: [
                {
                    loader: 'tslint-loader'
                }
            ]
        },
        {
            test: /\.tsx?$/,
            exclude: /node_modules/,
            use: [
                {
                    loader: 'ts-loader'
                }
            ]
        },
...

Nothing to report

Software is never this straightforward. We were thoroughly impressed with the whole process so far and how painless it had been.

So we went for lunch.

A long dark afternoon

Once we got back from lunch, we tried migrating to typescript-fsa and all hell broke loose. I don't really remember all the various errors but they were incomprehensible and numerous and I did not understand them at all. I'm pretty sure, according to my browser history, that I ended up looking through this...

Not cool Typescript, not cool. I saved my work on a branch and left it alone.

Three or four weeks later

I was disappointed that our migration to TS had started off so well and then ended up in a big bag of fail. It took several weeks to get around to picking up the task again.

I reverted the changes trying to get typescript-fsa working, and instead I took the approach of switching Babel out of our webpack build entirely and embracing ts-loader completely.

I also did some more reading on the various compiler options that we'd copied blindly from my colleague. It turns out that there are a couple of really important ones to get right...

module, target and lib

It turns out that our initial config wasn't quite right and we now have this configuration instead:

/* tsconfig.json */
"module": "es2015",
"target": "es5",
"lib": ["es2017", "dom", "dom.iterable"]

I'll talk about moduleResolution later, but suffice to say its very important that you get those 3 config options right.

Bye, bye Babel

Babel had been great, but I had a feeling that some of my troubles from before had been due to the fact that some of our app was being loaded/transpiled by Babel and other bits of it by Typescript, so I abandoned the Babel ship:

yarn remove babel-core babel-loader babel-preset-react babel-preset-es2015 babel-eslint ...

There were a bunch of babel plugins I removed too. Suffice to say we now had a lot less dependencies and our Webpack config for regular JS/JSX files went from:

loader: 'babel-loader',
options: {
    plugins: [
        'transform-class-properties',
        'transform-promise-to-bluebird',
        'transform-object-rest-spread',
        'syntax-dynamic-import',
        'transform-async-to-generator',
        'transform-regenerator',
        'transform-runtime'
    ].concat(production ? [] : 'react-hot-loader/babel'),
    presets: [
        'react',
        ['es2015', {modules: false}]
    ]
}

to:

loader: 'ts-loader'

which was nice :)

Need for Speed

I followed the instructions from ts-loader to speed up the Typescript processing which worked a treat.

I mentioned moduleResolution earlier and this is the point when I had to reconfigure that tsconfig option.

The way in which the Typescript compilation is sped up is by handing the type-checking process off into its own thread that runs in parallel to the Webpack bundling. As a consequence, Typescript now needs to resolve modules, as well as Webpack.

The resolution approach we wanted Typescript to take (the Node approach) was not the default value that Typescript selected based on our other configuration options, so we had to explicitly specify the module resolution approach.

Karma...

It was at this point, if I recall correctly, that I updated essentially all of our test related dependencies and also switched our karma webpack config from babel-loader to ts-loader. The reason for updating all our test dependencies (chai, karma, enzyme, etc) was to try and avoid problems due to incompatible or out-of-date libraries.

It look a little time but wasn't hellish enough to ruin my day.

Once complete, thankfully I had a fully Typescript-built application and a set of Karma tests that actually ran successfully! There was no code coverage, I was willing to sacrifice that for the sake of type safety in our codebase, albeit in just one or two files.

Code coverage

This actually took the longest to resolve - primarily because I didn't quite understand how the code coverage process was actually working and there are several different packages on NPM that claim to solve the code coverage problem for Typescript. None of them really solved my problem because we aren't actually running Typescript tests.

We use Karma for testing because a not insignificant amount of our codebase relies indirectly on the DOM existing in order to execute, and mocking out the relevant parts of the DOM and other browser-provided JS was determined not to be worth the effort.

Subsequently our Karma build used Webpack and Babel to generate 'chunks' for each test. The Babel configuration included a plugin called babel-plugin-istanbul that added the relevant code coverage information and the standard coverage reporter in Karma would generate the LCOV file and HTML report that I wanted.

It was actually this article on LinkedIn that helped fill in the gaps in my understanding, or at least point me in the right direction.

Given that we are transpiling our code as part of the test run we need to instrument the source at that point in the build pipeline, so adding the istanbul-instrumenter-loader to our Webpack config alongside the ts-loader achieves that goal.

/* webpack.config.js */
module: {
    rules: [
        {
            test: /\.tsx?$/,
            exclude: /node_modules/,
            use: [{
                loader: 'cache-loader'
            },{
                loader: 'thread-loader',
                options: {
                    workers: Math.max(os.cpus().length - 1, 1);
                }
            },{
                loader: 'ts-loader',
                options: {
                    transpileOnly: true,
                    happyPackMode: true
                }
            },{
                loader: 'istanbul-instrumenter-loader',
                options: { esModules: true }
            }]
        },
...

Additionally, we then need Karma to pick up on the fact that our sources are now annotated, so we add the 'coverage' preprocessor as well as the webpack preprocessor, and then, magically, everything works OK! (Except the HTML reporter...)

/* karma.conf.js */
reporters: ['junit', 'mocha', 'coverage'],
browsers: [
    'PhantomJS',
    'FirefoxHeadless'
],
failOnEmptyTestSuite: true,
singleRun: true,

coverageReporter: {
    dir: '../build/coverage',
    reporters: [
        // For browsing - currently only partially works
        //{type: 'html', subdir: 'html'},
        // For Sonar
        {type: 'lcovonly', subdir: '.', file: 'lcov.info'}
    ]
},

preprocessors: {
    'karma/**/*.js': ['webpack', 'coverage'],
    'karma/**/*.jsx': ['webpack', 'coverage']
},

Summary

  • Switch wholesale from using a Babel loader in Webpack to using the Typescript loader.
    This was surprisingly straight-forward.
  • Fix up the tsconfig.json properties module, target, lib and moduleResolution.
    This was the most important bit in the whole process I think.
  • Understand how our tests are being run and how coverage fits into that build pipeline.
    Always good to understand the technology you're using.
  • Write more TS :)


blog comments powered by Disqus