I've been slowly falling in love with TypeScript. I have a thousand little JS projects. Small prototypes with minimal tests and documentation. Often just to help me get a thing done. Typically when I revisited those after some months, I would be a complete stranger with no mental map of all the components or constraints or decisions that led to things being as they are.

When I wrote it it all made sense, looking at it later, I would be disappointed in my brain's ability to retain the ins & outs of this project and it would feel like a minefield. Any step taken to change things could mean an explosion and the loss of something dear.

But I've been playing around with TypeScript since a year or so, and when I revisit those projects, even though they were created in similar haste and vain, the code is more self-explanatory and it is actually hard to make missteps.

Even though I'm a TypeScript amateur, I did want more of this. But, as indicated I have many small projects laying around, so I need to simplify migration to TypeScript.

Folks on the internet say: "Well TypeScript is just a superset of JavaScript so you can simply rename .js to .ts and gradually fix warnings, and then turn them into errors."

That's kinda true but the reality is more involved as the documenting of my steps in this blog will demonstrate.

So I've tried to leverage computers - being okay with manual work - but adding machines insofar they could reasonably quickly be set up to automate things.

For instance, the automation used here will not figure out types by itself. It just aims to get you past the first bumps in the road, after which you will have a TypeScript project, after which you can add fixes in smaller increments, basically removing occurences of any and // @ts-expect-error as you go, getting you increasingly more benefits from TypeScript, while also making the first migration step not take more than an hour or so.

As a dad and co-founder, an hour here and there is what I can spare, so this splits the work in bite-sized chunks.

I assume (do make changes to accommodate your own setup):

  • Node 14+
  • Jest as your test runner
  • Eslint as your linter and formatter
  • Yarn as your package manager
  • Your files are safe under Git, and you created a fresh branch ts in sync with latest HEAD
  • You understand this is a very risky process and you are ready to revert and/or put in manual work as well
  • Your sources files are under src/ and test/
  • You have a back-end program. This does not accommodate for Webpack, React, etc.

Install dependencies

First I installed a number of dependencies:

yarn --dev add \
  @types/jest \
  @typescript-eslint/eslint-plugin \
  @typescript-eslint/parser \
  eslint-import-resolver-typescript \
  eslint-plugin-prefer-import \
  replace-require-with-import \
  ts-jest \
  ts-migrate \
  ts-node \
  typescript \
;

Initialize

Then I initialized a tsconfig.json via:

npx tsc --init

Migrate

Now let's use AirBnB's ts-migrate tool which takes care of renaming .js -> .ts, adding properties on ES6 classes, a number of other things, and commiting to Git in each step.

npx -p ts-migrate -c "ts-migrate-full ."

CommonJS -> ESM

Now let's switch CommonJS require to ESM/TypeScript import:

require2import ./src/**/*.ts
npx require2import ./{src,test}/**/*.ts

this only took us so far, we'll need to make ESM compatible as well. By replacing:

  • module.exports = -> export default (this will require manual checking and fixing, and don't do this on the .eslintrc.js file)

the require2import script made some mistakes too so I had to replace (just some examples to give you an idea):

  • import debug from 'depurar'('botty') -> import depurar from 'depurar'\nconst debug = depurar('botty')
  • const abbr\s+=\s+require\('@kvz/abbr'\) -> import abbr from '@kvz/abbr'
  • hunt for any other require( occurence and replace with import manually

Install missing types

Now I added missing types as reported by VSCode

yarn --dev add @types/{lodash,mkdirp,intercom-client,common-tags,humanize-duration,js-yaml,node-schedule,yaml-front-matter,errorhandler,uuid}

For those modules without DefinitelyTyped submissions, I added // @ts-expect-error above their imports.

Dev Ergonomics

For dev ergonomics, in package.json I replaced:

  • node ./src/**/*.js -> ts-node ./src/**/*.ts
  • I hunted for any other .js occurences and decided if I wanted to auto-transpile .ts with ts-node or no. If not I opted for prefixing with /dist/ which will contain the transpiled JavaScript.
  • I also changed my Nodemon scripts to transpile in near real time: nodemon --watch "src/**" --ext "ts,json" --ignore "src/**/*.spec.ts" --exec "ts-node src/index.ts"

CI

For CI, in package.json I added:

"build": "tsc --sourceMap --strict --outDir dist/",

and I added dist/ to .gitignore.

I added a yarn build step to my CI, and let production know it should now start the program in ./dist/. For example, in production uses yarn start, my package.json would now read:

"start": "node ./dist/src/index.js",

This way, all the TypeScript build tooling can stay in dev/ci, and production benefits from being lightweight JavaScript (faster startup time without ts-node's transpilation and less modules to distribute if you shipped with yarn --production).

Jest

Now let's make Jest TypeScript aware:

yarn ts-jest config:init

This creates a jest.config.js with the appropriate preset and environment to use.

ESLint

I fixed ESLint by adding these to my .eslintrc.js:

parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
settings: {
  'import/resolver': {
    typescript: {},
    node      : {
      extensions: ['.js', '.jsx', '.ts', '.tsx'],
    },
  },
}
rules: {
  // Missing file extension "ts" for "../../src/services/Intercom"
  'import/extensions': [
    'error',
    'ignorePackages',
    {
      js : 'never',
      jsx: 'never',
      ts : 'never',
      tsx: 'never',
    },
  ],
}

The required modules where already part of my first yarn command.

I changed the eslint --fix command to read this in my package.json:

"fix": "env DEBUG=eslint:cli-engine eslint --fix . --ext .js,.jsx,.ts,.tsx",

Manual fixes

I opened all files in VSCode and silenced TypeScript errors with // @ts-expect-error or other simple means. Elaborate fixes will have to wait for separate smaller focused pushes. Our aim right now is to have a TypeScript project in under an hour.

Finally I ran my lint fixer via yarn fix, as well as Jest unit tests, and fixed all they complained about. I made sure to commit often so that it was easy to roll back individual steps.

Success?

Success. The whole process took less than an hour and CI is passing. While this is just the start and there is limited benefit due to TypeScript not fully understanding the code yet (by suppressing errors via @ts-expect-error and giving it types like any), these can gradually be resolved as I work on the codebase.

If this post feels a bit rough around the edges, that is correct, I just took down notes from converting one project and will make changes here after I do the next.

Having the process documnted in this place allows for a bit of a repeatable process that will hopefully make it less error prone and faster each next time I refer to - and improve it.

Concluding

So TypeScript all the things? No, assuming you do like TypeScript, there are still cases I would probably refrain from adopting, for example if you have:

  • many files going through a slow filesystem like vboxfs, a non-SSD, networked file system, or Docker on MacOS (through its secret VM). The added transpilation time may be unbearable. Slow iteration times can really break your motivation to work on a thing
  • a large community-oriented project you may not want to raise the barrier of entry of. While TypeScript may be a superset of JavaScript, people writing it are a subset. You may just get less contributions if you limit yourself to it. This holds true mostly/especially for browser-based projects, where interested parties may have backgrounds in Ruby or PHP, and know just enough JS to propose a fix, but are prohibitively discouraged when they encounter TypeScript. JavaScript is ubiquitous and this may, in cases, be an all-deciding factor to your project's success.

In these cases you could consider sprinkling JSDoc type comments on top of your JS codebase to get some of the benefits without Node.js or the browser no longer understanding your code as-is.

Please let me know if you spot mistakes, have additions, or would like to share your own experiences with TypeScript. As indicated I've barely scratched the surface so I'm sure there's room for improvement and would like to learn from you.