DEV Community

Cover image for Incrementally adopting TypeScript in a create-react-app project
Manny Colon
Manny Colon

Posted on

Incrementally adopting TypeScript in a create-react-app project

You can gradually adopt TypeScript in your create-react-app project. You can continue using your existing Javascript files and add as many new TypeScript files as you need. By starting small and incrementally converting JS files to TypeScript files, you can prevent derailing feature work by avoiding a complete rewrite.

Incrementally adopting TypeScript in a create-react-app project can be valuable, especially if you don't want to do a full-fledged migration before you fully learn TypeScript or become more proficient with it.

For this tutorial, the app we'll be converting to TypeScript is a counter app built with redux-toolkit, if you're not familiar with redux, redux-toolkit or TypeScript, I highly suggest you take a look at their docs before doing this tutorial as I assume you have some basic understanding of all of them.

Before you start please make sure you don't have create-react-app globally installed since they no longer support the global installation of Create React App.

Please remove any global installs with one of the following commands:
- npm uninstall -g create-react-app
- yarn global remove create-react-app
Enter fullscreen mode Exit fullscreen mode

First, let's bootstrap a React app with Create React App, using the Redux and Redux Toolkit template.

npx create-react-app refactoring-create-react-app-to-typescript --template redux 
Enter fullscreen mode Exit fullscreen mode

Here is a visual representation of the project's directory and file structure.

πŸ“¦ refactoring-create-react-app-to-typescript
 ┣ πŸ“‚ node_modules
 ┣ πŸ“‚ public
 ┣ πŸ“‚ src
 ┃ ┣ πŸ“‚ app
 ┃ ┃ β”— πŸ“œ store.js
 ┃ ┣ πŸ“‚ features
 ┃ ┃ β”— πŸ“‚ counter
 ┃ ┃ ┃ ┣ πŸ“œ Counter.module.css
 ┃ ┃ ┃ ┣ πŸ“œ Counter.js
 ┃ ┃ ┃ ┣ πŸ“œ counterAPI.js
 ┃ ┃ ┃ ┣ πŸ“œ counterSlice.spec.js
 ┃ ┃ ┃ β”— πŸ“œ counterSlice.js
 ┃ ┣ πŸ“œ App.css
 ┃ ┣ πŸ“œ App.test.js
 ┃ ┣ πŸ“œ App.js
 ┃ ┣ πŸ“œ index.css
 ┃ ┣ πŸ“œ index.js
 ┃ ┣ πŸ“œ logo.svg
 ┃ ┣ πŸ“œ serviceWorker.js
 ┃ β”— πŸ“œ setupTests.js
 ┣ πŸ“œ .gitignore
 ┣ πŸ“œ package-lock.json
 ┣ πŸ“œ package.json
 β”— πŸ“œ README.md
Enter fullscreen mode Exit fullscreen mode

Also, feel free to take a look at the final version of the project here, if you want to see the original Javascript version go here.

Adding TypeScript to create-react-app project

Note: this feature is available with react-scripts@2.1.0 and higher.

TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.

Installation

To add TypeScript to an existing Create React App project, first install it:

npm install --save typescript @types/node @types/react @types/react-dom @types/jest

# or

yarn add typescript @types/node @types/react @types/react-dom @types/jest
Enter fullscreen mode Exit fullscreen mode

Now, let's start by renaming the index and App files to be a TypeScript file (e.g. src/index.js to src/index.tsx and App.js to App.tsx) and create a tsconfig.json file in the root folder.

Note: For React component files (JSX) we'll use .tsx to maintain JSX support and for non React files we'll use the .ts file extension. However, if you want you could still use .ts file extension for React components without any problem.

Create tsconfig.json with the following content:

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": [
    "src"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Next, restart your development server!

npm start

# or

yarn start
Enter fullscreen mode Exit fullscreen mode

When you compile src/App.tsx, you will see the following error:

SVG type error

Solution with custom.d.ts

At the root of your project create custom.d.ts with the following content:

declare module '*.svg' {
  const content: string;
  export default content;
}
Enter fullscreen mode Exit fullscreen mode

Here we declare a new module for SVGs by specifying any import that ends in .svg and defining the module's content as string. By defining the type as string we are more explicit about it being a URL. The same concept applies to other assets including CSS, SCSS, JSON, and more.

See more in Webpack's documentation on Importing Other Assets.

Then, add custom.d.ts to tsconfig.json.

{
  ...,
  "include": ["src", "custom.d.ts"]
}
Enter fullscreen mode Exit fullscreen mode

Restart your development server.

npm start

# or

yarn start
Enter fullscreen mode Exit fullscreen mode

You should have no errors and the app should work as expected. We have converted two files (Index.js -> index.tsx and App.js -> App.tsx) to TypeScript without losing any app functionality. Thus, we've gained type checking in our two converted files.

Now, we can incrementally adopt TypeScript in our project one file at a time. Let's do exactly that, starting with Counter.js. Change Counter.js to Counter.tsx.

Restart the app, npm start or yarn start.

It will complain that it cannot find module ./Counter.module.css or its corresponding type declarations.

Missing type declarations for CSS modules

We can fix it by adding a type declaration for *.module.css to the end of custom.d.ts. So, our custom.d.ts file should look as follow:

custom.d.ts

declare module '*.svg' {
  const content: string;
  export default content;
}

declare module '*.module.css';
Enter fullscreen mode Exit fullscreen mode

Alternatively, you could also use typescript-plugin-css-modules to address the CSS modules error but adding a type declaration is good enough in this case.

The next error/warning is related to incrementAsync.

incrementAsync type error

However, before we fix the second error in counterSlice.tsx, we must change src/app/store.js to src/app/store.ts then define Root State and Dispatch Types by inferring these types from the store itself which means that they correctly update as you add more state slices or modify the middleware setting. Read more about using TypeScript with Redux in their TypeScript docs.

src/app/store.ts should look as follows.

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
Enter fullscreen mode Exit fullscreen mode

Okay, now that we have defined Root State and Dispatch Types let's convert counterSlice to TypeScript.

src/features/counter/counterSlice.js -> src/features/counter/counterSlice.ts

In counterSlice.ts the first error is that the type of the argument for payload creation callback is missing. For basic usage, this is the only type you need to provide for createAsyncThunk. We should also ensure that the return value of the callback is typed correctly.

The incrementAsync function should look like this:

// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
export const incrementAsync = createAsyncThunk(
  'counter/fetchCount',
  // Declare the type your function argument here:
  async (amount: number) => {// HERE
    const response = await fetchCount(amount);
    // The value we return becomes the `fulfilled` action payload
    return response.data;
  }
);
Enter fullscreen mode Exit fullscreen mode

We added a type (number) to the argument called amount in the callback function passed to createAsyncThunk as the second argument.

Before we go on with the other type errors, we must address the error with the response value returned from the fetchCount function inside the function callback passed to createAsyncThunk in incrementAsync. In order to fix it we must first fix it at the root of the problem, inside counterAPI.js.

Thus, first convert counterAPI.js to counterAPI.ts.

type CountType = {
  data: number;
};

// A mock function to mimic making an async request for data
export function fetchCount(amount: number = 1) {
  return new Promise<CountType>((resolve) =>
    setTimeout(() => resolve({ data: amount }), 500)
  );
}
Enter fullscreen mode Exit fullscreen mode

In this Promise, I have used the promise constructor to take in CountType as the generic type for the Promise’s resolve value.

Now, let's go back to counterSlice.ts and the next error is that the selectCount selector is missing a type for its argument. So, let's import the types we just created in store.ts.

Import RootState and AppDispatch types:

import type { RootState, AppDispatch } from '../../app/store'
Enter fullscreen mode Exit fullscreen mode

Use RootState as a type for selectCount's argument (state)

selectCount

// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state: RootState) => state.counter.value;
Enter fullscreen mode Exit fullscreen mode

incrementIfOdd

// We can also write thunks by hand, which may contain both sync and async logic.
// Here's an example of conditionally dispatching actions based on the current state.
export const incrementIfOdd =
  (amount: number) => (dispatch: AppDispatch, getState: () => RootState) => {
    const currentValue = selectCount(getState());
    if (currentValue % 2 === 1) {
      dispatch(incrementByAmount(amount));
    }
  };
Enter fullscreen mode Exit fullscreen mode

Okay, we should have zero type errors or warnings now. We've converted the following files to TypeScript:

 src/app/store.ts
 src/features/counter/Counter.tsx
 src/features/counter/counterSlice.ts
 src/features/counter/counterAPI.ts
Enter fullscreen mode Exit fullscreen mode

Finally, let's convert our test files:

Change App.test.js to App.test.tsx and counterSlice.spec.js to counterSlice.spec.ts

Run your tests:

npm test
Enter fullscreen mode Exit fullscreen mode

or

yarn test
Enter fullscreen mode Exit fullscreen mode

All tests should pass, however, you may encounter the following problem:

"Property 'toBeInTheDocument' does not exist on type 'Matchers<any>'."
Enter fullscreen mode Exit fullscreen mode

To fix it, you can try adding the following to tsconfig.json:

...,
  "exclude": [
    "**/*.test.ts"
  ]
Enter fullscreen mode Exit fullscreen mode

All tests should pass now:

Passing unit tests

Feel free to check out my repo with the final version of this app.

Thanks for following along, happy coding!

Latest comments (0)