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
andmoduleResolution
.
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 :)
- 09 Oct 2018 » A strange bug on AWS Lambda
- 17 Jan 2018 » How to run Karma tests in browsers in Docker
- 07 Dec 2017 » Switching from Javascript to Typescript
- 30 Oct 2017 » Fun with React event handlers
- 17 Jul 2017 » Switching from Groovy to Java
- 24 May 2017 » Useful Git Aliases
- 27 Mar 2017 » Practical Ratpack Promises
- 03 Nov 2016 » Custom Content in Forms for Confluence Connect
- 04 Oct 2016 » Checking user permissions from REST calls
- 30 Sep 2016 » Using the reflection API in Confluence
- 28 Sep 2016 » Creating a custom Confluence Blueprint
- 06 Sep 2016 » ReactJS in Forms for Confluence Connect
- 25 Apr 2016 » Migrating to ES6 in Atlassian Add-ons
- 17 Mar 2016 » All kinds of things I learnt trying to performance test against Fisheye/Crucible
- 24 Dec 2015 » Adaptavist’s Holiday Gift of Atlassian Deployment Automation
- 17 Dec 2015 » Getting a Custom Field value safely
- 07 Dec 2015 » Putting Google Analytics to work with plugins for Confluence
- 02 Dec 2015 » Devoxx Voting, A retrospective
- 25 Nov 2015 » Some things I've learnt about SingleSelect
- 15 Oct 2015 » Using SOY for JIRA actions
- 26 Sep 2015 » Object Reflection in Groovy
- 22 Sep 2015 » Introducing Adaptavist Labs