Migrating a React app from JavaScript to TypeScript
The task was to rewrite a ~30-file React Single Page App from JavaScript to TypeScript. The goal of the rewrite was to make it easier to later do more serious refactoring and add new features.
The SPA was part of a big full-stack multi-page app whose client-side JavaScript part was ~250 files, including a few more React and Vue SPAs, all bundled together with Webpack, transpiled with Babel. The rest of the JavaScript codebase should remain unchanged. It needs to be possible to import JS files from TS files and TS files from JS files.
The team was 2 experienced JS devs, enthusiastic to try out TypeScript 🙂.
Spoiler: everything went very well except for one bigger hiccup (see the last section of this post). We ended up pretty satisfied with this approach.
Learning
We read the entire TypeScript Handbook first. We figured investing some time in gaining a better understanding of TypeScript will pay off with fewer beginners’ mistakes along the way.
As I was learning, I did a few TypeScript Exercism exercises of course, and created a gist with TS snippets that I found surprising. I was very happy to have found this React TypeScript cheatsheet that turned out to be very helpful.
Approach
To make it possible to work in parallel on separate branches, and to ensure we can merge our work to master often to minimize conflicts, we decided on a file by file approach.
Choose one file, rename it from *.js(x)
to *.ts(x)
, and fix all TS compiler errors in that one file. Repeat for 2-3 files per PR.
We didn’t want to half-ass it either, as the goal was to make this code shiny and ready for whatever the future brings, so we went all in with the strict TypeScript checks.
We:
- Set
"strict": true
- Set
"noImplicitAny": true
- Set
"noUncheckedIndexedAccess": true
- Avoided enums
- Avoided assuming we know better than the compiler
- Didn’t overwrite types with
as
- Didn’t ignore
null
/undefined
warnings, usedif
and optional chaining to be safe - For example:
1 2 3 4 5 6 7 8
// the return type of `document.querySelector` is: Element | null // if that's not specific enough, do: const form: HTMLFormElement | null = document.querySelector(formSelector) if (form) { doSomethingWith(form) } // don't: const form = document.querySelector(formSelector) as HTMLFormElement doSomethingWith(form)
- Didn’t overwrite types with
- And set
"allowJs": true
to be able to still include JS files in TS files
Tools
tsc
and ts-loader
One of the decisions one has to make when setting up TypeScript is: transpile with Babel or with tsc
?
Even though we were already using Babel to transpile our JavaScript code, we went fully with tsc
for TypeScript. We knew we wanted a type-check step as part of our CI setup, so we needed tsc
anyway. The decision to use tsc
both for development-time type-checks as well as production builds was a gut feeling that it’s better to do both with the same tool.
We set up the tsc --noEmit
command to run as part of our testing & linting CI job, and added ts-loader
to our Webpack config.
ESLint
We’ve been using ESLint to lint our JavaScript code, so it seemed obvious to do the same for TypeScript. We took advantage of ESLint’s overrides that allow you to set different linting rules per file extension. We have a lot of different files to cover - .js
, .vue
(JS), .jsx
(React JS), .ts
, and .tsx
(React TS).
However, we hit a problem. We’ve not only been using ESLint for linting, but we’ve also relied on it for all of our JavaScript formatting needs. The TypeScript ESLint project is very clear that they don’t want you to use it for formatting. Thus, we also needed to migrate our formatting needs to a dedicated tool.
Prettier
We set up Prettier as the formatting tool for all our JS and TS files. To make sure there are no conflicts between Prettier and ESLint, we had to turn off all formatting-related ESLint rules.
Types for external dependencies
As we were migrating files with external dependencies from JS to TS, sometimes we would get errors about external dependencies missing type declarations. Those errors come with useful hints about where to find the types:
1
2
3
4
js/components/SomeComponent.tsx:1:31 - error TS7016: Could not find a declaration file for module 'react'. '/Users/angelika/code/my_app/assets/node_modules/react/index.js' implicitly has an 'any' type.
Try `npm i --save-dev @types/react` if it exists or add a new declaration (.d.ts) file containing `declare module 'react';`
1 import { createContext } from 'react'
You can check in the NPM package registry if a specific package has types built in, in a separate package, or doesn’t have them at all.

The unfortunate global type conflicts
One unexpected problem that cost us a full day of work to figure out came about when we tried to migrate the first file that used a React context. We hit this mysterious error:
1
2
'MyContext.Provider' cannot be used as a JSX component.
Its return type 'ReactElement<any, string | JSXElementConstructor<any>> | null' is not a valid JSX element.
What do you mean a context provider is not a valid JSX element? Of course it is. We ended up chasing a false lead that suggested that those kinds of errors are caused by two different and conflicting versions of the @types/react
package being installed, but that was not our problem
Finally, we arrived at the real cause: The @types/react
package and the vue
package both define a global JSX.Element
type, and those two definitions conflict. This means that it’s currently impossible to use both React and Vue in the same TypeScript project.
Thankfully we didn’t need Vue to work with TypeScript, so in theory we could get rid of Vue’s type definitions. In practice, Vue and Vue’s type definitions come in a single package, so you can’t get rid of them.
We ended up using patch-package to monkey-patch the vue
package to remove the conflicting global type.