Skip to content

How to Add TypeScript to an Existing React Project

I had a React project that started as a quick prototype. Six months later, it became a production application with dozens of components. Every time I passed wrong props to a component or mistyped a hook dependency, I discovered the bug at runtime—usually after users reported it.

I decided to add TypeScript. The process seemed straightforward until I hit confusing errors about missing type definitions, JSX parsing failures, and framework-specific quirks. Here’s what actually worked.

The Problem: Runtime Bugs That Should Be Compile-Time Bugs

Before TypeScript, I had code like this:

UserList.jsx
function UserList({ users }) {
return (
<div>
{users.map(user => (
<UserCard user={user} onUpdate={handleUpdate} />
))}
</div>
);
}

I called it with the wrong data shape:

App.jsx
<UserList users={[{ name: "Alice" }]} /> // Missing id and email

The component rendered, but when handleUpdate tried to access user.id, everything broke. TypeScript catches these mismatches before you run the code.

Step 1: Install Type Definitions

The first step is installing the React type packages. I ran:

Install React type definitions
npm install --save-dev @types/react @types/react-dom

Both packages are necessary. @types/react provides types for React itself, while @types/react-dom covers DOM-related types like event handlers.

Step 2: Configure tsconfig.json

TypeScript needs specific settings to work with React. I created a tsconfig.json with two critical options:

tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["src"]
}

The two settings that matter most:

  • lib must include dom — Without this, TypeScript doesn’t recognize browser APIs like document or window. If you omit the lib option entirely, TypeScript defaults to including dom, so you can skip this if you don’t specify lib at all.

  • jsx must be set — The preserve option works for most applications. TypeScript keeps the JSX as-is, letting your bundler (Webpack, Vite, etc.) handle the transformation. For libraries, use react or react-jsx instead.

Step 3: Rename Files to .tsx

Every file containing JSX must use the .tsx extension. This is TypeScript-specific—.ts files cannot contain JSX.

File renaming pattern
MyComponent.jsx → MyComponent.tsx
App.jsx → App.tsx
utils.js → utils.ts (no JSX, so .ts is fine)

I renamed all my components and immediately saw type errors appear in my editor. This is exactly what I wanted—the compiler was now catching bugs I previously found at runtime.

Step 4: Add Types to Your Components

After renaming files, I started adding type annotations. The simplest pattern uses an interface for props:

UserCard.tsx
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
interface UserCardProps {
user: User;
onUpdate: (id: string, updates: Partial&lt;User&gt;) => void;
}
export function UserCard({ user, onUpdate }: UserCardProps) {
const handleRoleChange = (newRole: 'admin' | 'user') => {
onUpdate(user.id, { role: newRole });
};
return (
&lt;div&gt;
&lt;h3&gt;{user.name}&lt;/h3&gt;
&lt;span&gt;{user.email}&lt;/span&gt;
&lt;select value={user.role} onChange={(e) => handleRoleChange(e.target.value as 'admin' | 'user')}&gt;
&lt;option value="user"&gt;User&lt;/option&gt;
&lt;option value="admin"&gt;Admin&lt;/option&gt;
&lt;/select&gt;
&lt;/div&gt;
);
}

Now if I pass an incomplete user object, TypeScript fails immediately:

App.tsx
// This fails compilation
&lt;UserCard user={{ name: "Alice" }} onUpdate={handleUpdate} /&gt;
// Error: Property 'id' is missing in type '{ name: string }'
// but required in type 'User'

Handling Event Types

React events have specific types. I used to guess the event object shape:

Before TypeScript
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target); // What is e.target?
}

With TypeScript, I specify the exact type:

After TypeScript
function handleSubmit(e: React.FormEvent&lt;HTMLFormElement&gt;) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
}

The e.currentTarget is correctly typed as HTMLFormElement, so TypeScript knows new FormData() will work.

Typing Hooks

Hooks have built-in types, but I learned to be explicit for better inference:

useState with explicit types
const [user, setUser] = useState&lt;User | null&gt;(null);
const [isLoading, setIsLoading] = useState&lt;boolean&gt;(false);

For custom hooks, I added return type annotations:

useUser.ts
interface UseUserReturn {
user: User | null;
isLoading: boolean;
error: Error | null;
refetch: () => Promise&lt;void&gt;;
}
export function useUser(userId: string): UseUserReturn {
const [user, setUser] = useState&lt;User | null&gt;(null);
const [isLoading, setIsLoading] = useState&lt;boolean&gt;(true);
const [error, setError] = useState&lt;Error | null&gt;(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setIsLoading(false));
}, [userId]);
const refetch = async () => {
setIsLoading(true);
setError(null);
await fetchUser(userId).then(setUser).catch(setError);
setIsLoading(false);
};
return { user, isLoading, error, refetch };
}

Framework-Specific Considerations

If you’re using a framework, follow its TypeScript guide instead of generic setups:

  • Next.js: Use create-next-app --typescript for new projects, or follow the migration guide at nextjs.org/docs/app/building-your-application/configuring/typescript
  • Remix: The framework generates TypeScript config automatically—check remix.run/docs for details
  • Gatsby: Has its own TypeScript plugin—see gatsbyjs.com/docs/how-to/custom-configuration/typescript/
  • Expo: React Native requires different type packages—check docs.expo.dev/guides/typescript/

These frameworks override or extend the base TypeScript settings with framework-specific optimizations.

What I Got Wrong Initially

My first attempt failed because I made three mistakes:

  1. Only installed @types/react — DOM events like onClick produced type errors until I added @types/react-dom

  2. Used .ts for components — TypeScript refused to parse JSX in .ts files. I had to rename every component to .tsx

  3. Skipped jsx in tsconfig.json — The default settings from tsc --init don’t include JSX support. I had to add "jsx": "preserve" explicitly

After fixing these, the migration worked.

Summary

Adding TypeScript to an existing React project takes three steps:

  1. Install @types/react and @types/react-dom as dev dependencies
  2. Configure tsconfig.json with dom in lib and jsx set to preserve
  3. Rename every JSX-containing file from .jsx to .tsx

The type errors that appear after migration are exactly what you want—they’re bugs TypeScript now catches at compile time instead of runtime.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments