This content originally appeared on Level Up Coding - Medium and was authored by Jonatan Kruszewski
And living to tell it
Introduction — Why Typescript in 2024?
It is 2024, and TypeScript finally surpassed JavaScript in popularity, making a migration to TS a good long term bet. Additionally, surveys from Stack Overflow mark the upward trend for TS, which climbed two spots in the ranking gaining 8% —the sharpest climb on the board — and a slight decline in JS—something that can’t be overlooked.
Besides the trend in popularity, I am personally finding that developers are more open to type safe languages in the last couple years. The “fear” of bringing types to JS doesn’t exist anymore, and TS has become stable and popular enough to give it a chance if haven’t done so yet. Believe me, you will enjoy catching bugs in compilation time before it hits a unit test or even worse — a user.
The Situation
The codebase I migrated wasn’t too bad. A Create React App Monorepo, set up with yarn workspaces and configured with CRACO that runs flawlessly with its tests, proper modules configured, and linting. Nothing too shiny, nothing major to complain about. The time will come when we move forward to Vite, but that is for another day.
On the less-than-stellar side, the repo relied heavily on PropTypes for type validation, which is something to take care of if you want to migrate to React 18.3 or later. This is not an actual issue for now, but TS solves that issue, providing an easy replacement. Let’s save that for yet another day.
In the meantime,
Let’s dive in!
The Migration Plan
Managing a codebase that has a whole Design System plus several packages interrelated in the same monorepo can come as a challenge.
Setting TypeScript isn’t much of a hurdle, neither is configuring Babel (or your favorite compiler), updating your linter or updating your Jest configurations; but as the saying goes:
“The flap of a butterfly’s wings in Brazil could set off a tornado in Texas”
Meaning, you fix your Babel config, and suddenly your linter goes red. You fix the linter, suddenly the tests don’t pass. You fix the tests, again the linter goes red. It is a constant pursuit of fixing issues, before they break your code or your soul, whichever comes first.
So, in this guide I will lead you through how to do that (fix the code). I spent enough hours in front of the screen debugging and trying to understand the issues, to come up with a solution so you can do it in a breeze.
My personal recommendation, is every time you make a small step, commit, and continue. Your future self will thank you once you encounter a problem and need to checkout a previous stage of your project.
The Setup
When migrating a Create React App (CRA) monorepo to TypeScript, and especially when using CRACO (to override CRA’s config), it’s important to ensure you’re installing only what you need to avoid unnecessary complexity. Let’s breakdown the necessary dependencies before we explain what they do:
Core TypeScript:
- typescript
TypeScript Type Definitions
- @types/react
- @types/react-dom
- @types/react-router-dom
- @types/node
- @types/jest (for testing)
Babel (Compilation):
- @babel/preset-typescript (for TypeScript syntax stripping)
- ts-loader (to transform .ts and .tsx files)
ESLint (to lint TypeScript properly):
- @typescript-eslint/parser (to parse TS files)
- @typescript-eslint/eslint-plugin (set rules for linting for TS)
Step #1: Installing dependencies
The first thing that you need to know when you migrate to TS besides the long depency list, is that you will need something called “type definitions”. You can recognize them because they have the extension .d.ts, they allow your IDE to help you identify the correct signature:
Needless to say not all the projects have them, and that is where DefinitelyTyped comes into action. Definitely Typed is a project that provides a central repository of TypeScript definitions for those NPM packages that lack them.
So, besides installing TS, you will want to install those types definitions for your other dependencies, as well as all kind of plugins and presets for making Webpack, Babel and Eslint work.
Let’s go over all these dependencies by category:
- Install TS and related types as dependencies. Make sure that your TS version matches the supported version for typescript-eslint, which will be needed afterward.
yarn add -D typescript @craco/types @types/node @types/react @types/react-dom @types/react-router-dom @types/jest
2. Install other desired Typescript dependencies, mostly for your compiler, linter, test runner, etc. In my case, since I am using Babel and EsLint I would need:
yarn add -D @babel/preset-typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser
3. Install a TS loader. You can use ts-loader or babel-loader
yarn add -D es-loader
4. Lastly, install the desired types for any other library that you might be using, like Lodash or UUID:
yarn add -D @types/lodash @types/uuid
Step #2: Add a global.d.ts file
At the root of your project, create a global.d.ts file. This file prevents TS from yelling at you if you try to import something that is not TS code by specifying how to handle those extensions.
To start with, you will want to add declarations for your styles:
declare module '*.scss' {
const scssContent: Record<string, string>;
export default scssContent;
}
declare module '*.css' {
const cssContent: Record<string, string>;
export default cssContent;
}
With time, this file will grow. For example for png images you can add:
declare module "*.png" {
const value: any;
export default value;
}
The point is that every extension that isn’t .ts , you declare it in this file.
Step #3: Update your Compiler and Webpack Settings
In your monorepo, you will want to set up your compiler to handle TS files. If you are using CRACO that would be your craco.js file, but if not, head to your compiler settings —babel.config.js, or whatever.
- Update Babel to have the TS preset:
module.exports = {
babel: {
cache: false,
presets: [
'@babel/preset-react',
'@babel/preset-typescript', // <-- Add this line
],
plugins: [
'@babel/plugin-proposal-export-default-from',
],
},
...
}
Some projects, instead of having a babel key in this file, use a separate babel.config.json. If that is the case, the procedure is the same, you should still add @babel/preset-typescript to the presets.
2. Add the extensions .ts and .tsx to the webpack object, as well as the ts-loader transformation in the module section:
webpack: {
configure: (webpackConfig, {
env,
paths
}) => {
return {
...webpackConfig,
resolve: {
...(webpackConfig.resolve || {}),
extensions: [
...(webpackConfig.resolve.extensions || []),
'.js',
'.jsx',
'.ts', // <-- Add this line
'.tsx', // <-- Add this line
],
},
},
module: {
...(webpackConfig.module || {}),
rules: [
...(webpackConfig.module.rules || []),
// your other rules
{
test: /\.tsx?$/, // <-- Add this line
use: 'ts-loader', // <-- Add this line
exclude: /node_modules/, // <-- Add this line
},
],
},
};
};
Adding these configurations will tell webpack how to resolve handle the ts and tsx files. Since those aren’t regular plain JS files we need to transform them to something that Babel can understand.
Step #4: jsconfig.json becomes tsconfig.json
If you had a jsconfig.json before, be ready to say goodbye to it. In TS, tsconfig.json is the necessary file.
There is even a chance that you didn’t have a jsconfig.json, and that is ok: it is not always necessary. But in TS, this is the main configuration file and we do need it. Everytime you have typescript as a dependency, you will have a tsconfig file.
Usually, you will have a global TSconfig file for your whole project, and then each package might have additional configurations that extend the global one.
So, at the top level, instead of having something like this:
//jsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"moduleResolution": "node",
},
"exclude": [
"node_modules"
]
}
You will have:
//tsconfig.json
{
"compilerOptions": {
"lib": [
"esnext",
"dom",
"dom.iterable"
],
"allowJs": true,
"checkJs": false,
"jsx": "react",
"baseUrl": ".",
"moduleResolution": "node",
"target": "ES6",
"module": "ES6",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./build"
},
"include": [
"./packages/**/*.ts",
"./packages/**/*.tsx",
"./packages/**/*.d.ts",
"./packages/**/*.js",
"./packages/**/*.jsx",
"./global.d.ts",
],
"exclude": [
"node_modules"
],
}
If you pay attention to some of these settings, you will realize that it allows having JavaScript code among your new TS code. Also, you will want to add your “old” js files to the include option too.
Step #5: Update jsconfig.json files in your packages to tsconfig.json
The same as before, for each package, if you have a jsconfig replace it. Make sure that you are extending the root settings:
// tsconfig.json inside some package
{
"extends": "../../tsconfig.json",
// your other settings
}
Step #6: Update ESLint Config
ESLint default parser is @babel/eslint-parser. This parser doesn’t support non standard EcmaScript Syntax A.K.A. doesn’t support TypeScript. So, we will need to switch it up for something that does, @typescript-eslint/parser.
So, update the parser, extensions and plugins in your eslint configuration file:
{
"parser": "@typescript-eslint/parser", // <-- Update this line
"extends": [
"airbnb",
"airbnb/hooks",
// your other extensions
"plugin:@typescript-eslint/eslint-recommended", // <-- Add this line
"plugin:@typescript-eslint/recommended" // <-- Add this line
],
"plugins": [
"@babel",
"react",
"react-hooks",
// your other plugins
"@typescript-eslint" // <-- Add this line
],
... // your other settings
}
After updating your linter you may encounter new issues due to the new rules: fix them and create a new commit if so.
Step #7: Build & Check code
At this point, you should have a working monorepo. Check that the tests run, that your linter works properly with .js | .jsx | .ts | .tsx extensions, and troubleshoot if not.
Another important thing to do is to build each package. You may encounter issues at that stage that you might not have experienced during development. Don’t save that for the last minute.
Step #8: Pat yourself on the back
If you made it to this point and everything is working, you can give yourself a pat on the back. Good job!
Pitfalls I encountered
During this migration I learned a lot. Not only on the TSConfiguration, but deepened my knowledge on how everything plays along in a bigger setting.
One of the things that turned my hair a little bit more grey was the path and baseUrl settings in the tsconfig file. It turns out, that nowadays you don’t need anymore baseUrl, but you can still use it.
So I was playing around with both options without fully understanding them and I was trying to set paths to other packages without succees.
The problem was, that the baseUrl was pointing to a nested src folder, while the way I wrote the path to the package as if it were the root. So obviously, that didn’t work.
So, this wasn’t working:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "src",
"paths": {
"*": ["../packageMain/src/*"], // from src, should had been 2 folders up
"somePackage/*": [
"../somePackage/*" // from src, should had been 2 folders up
]
}
}
}
So, either I should had removed the baseUrl or updated the paths to be one folder up:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "src",
"paths": {
"*": ["../../packageMain/src/*"],
"somePackage/*": [
"../../somePackage/*"
]
}
}
}
That little mistake cost me more time than I would have liked 😅
Do you need Babel at all for TypeScript?
If you are sticking with Babel for other reasons (e.g., using plugins for JSX, polyfills, or custom transformations), then you will need Babel + TypeScript integration. Otherwise, TypeScript alone (via tsc or through Webpack’s ts-loader) can handle the transformation.
However, CRA uses Babel by default, and if you’re migrating while retaining CRA’s Babel setup, you’ll still need Babel in the pipeline.
If you’re only using TypeScript for type-checking and aren’t relying on custom Babel plugins, you could skip Babel entirely and instead use ts-loader with Webpack for a more native TypeScript workflow.
Wrapping Up
Migrating to TypeScript in a Create React App monorepo using CRACO can seem like a challenging task, but with a clear plan, each step becomes manageable.
While the process involves updating configurations, installing dependencies, and adjusting ESLint and Babel settings, the end result is a more robust, maintainable codebase. You’ll appreciate the type safety and reduced runtime errors that TypeScript provides, especially in larger projects.
If you encounter issues along the way — whether it’s linter conflicts, outdated dependencies, or unfamiliar TypeScript errors — remember to tackle them one at a time, commit frequently, and, most importantly, remain patient.
These steps are an investment in your project’s future scalability and stability. And trust me, once you’ve lived to tell the tale, you’ll be glad you made the switch.
Happy coding!
Mastering TypeScript Migration in a Create React App Monorepo: A Practical Guide with CRACO was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Jonatan Kruszewski
Jonatan Kruszewski | Sciencx (2024-10-08T16:38:48+00:00) Mastering TypeScript Migration in a Create React App Monorepo: A Practical Guide with CRACO. Retrieved from https://www.scien.cx/2024/10/08/mastering-typescript-migration-in-a-create-react-app-monorepo-a-practical-guide-with-craco/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.