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:
function UserList({ users }) { return ( <div> {users.map(user => ( <UserCard user={user} onUpdate={handleUpdate} /> ))} </div> );}I called it with the wrong data shape:
<UserList users={[{ name: "Alice" }]} /> // Missing id and emailThe 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:
npm install --save-dev @types/react @types/react-domBoth 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:
{ "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:
-
libmust includedom— Without this, TypeScript doesn’t recognize browser APIs likedocumentorwindow. If you omit theliboption entirely, TypeScript defaults to includingdom, so you can skip this if you don’t specifylibat all. -
jsxmust be set — Thepreserveoption works for most applications. TypeScript keeps the JSX as-is, letting your bundler (Webpack, Vite, etc.) handle the transformation. For libraries, usereactorreact-jsxinstead.
Step 3: Rename Files to .tsx
Every file containing JSX must use the .tsx extension. This is TypeScript-specific—.ts files cannot contain JSX.
MyComponent.jsx → MyComponent.tsxApp.jsx → App.tsxutils.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:
interface User { id: string; name: string; email: string; role: 'admin' | 'user';}
interface UserCardProps { user: User; onUpdate: (id: string, updates: Partial<User>) => void;}
export function UserCard({ user, onUpdate }: UserCardProps) { const handleRoleChange = (newRole: 'admin' | 'user') => { onUpdate(user.id, { role: newRole }); };
return ( <div> <h3>{user.name}</h3> <span>{user.email}</span> <select value={user.role} onChange={(e) => handleRoleChange(e.target.value as 'admin' | 'user')}> <option value="user">User</option> <option value="admin">Admin</option> </select> </div> );}Now if I pass an incomplete user object, TypeScript fails immediately:
// This fails compilation<UserCard user={{ name: "Alice" }} onUpdate={handleUpdate} />// 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:
function handleSubmit(e) { e.preventDefault(); const formData = new FormData(e.target); // What is e.target?}With TypeScript, I specify the exact type:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { 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:
const [user, setUser] = useState<User | null>(null);const [isLoading, setIsLoading] = useState<boolean>(false);For custom hooks, I added return type annotations:
interface UseUserReturn { user: User | null; isLoading: boolean; error: Error | null; refetch: () => Promise<void>;}
export function useUser(userId: string): UseUserReturn { const [user, setUser] = useState<User | null>(null); const [isLoading, setIsLoading] = useState<boolean>(true); const [error, setError] = useState<Error | null>(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 --typescriptfor new projects, or follow the migration guide atnextjs.org/docs/app/building-your-application/configuring/typescript - Remix: The framework generates TypeScript config automatically—check
remix.run/docsfor 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:
-
Only installed
@types/react— DOM events likeonClickproduced type errors until I added@types/react-dom -
Used
.tsfor components — TypeScript refused to parse JSX in.tsfiles. I had to rename every component to.tsx -
Skipped
jsxin tsconfig.json — The default settings fromtsc --initdon’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:
- Install
@types/reactand@types/react-domas dev dependencies - Configure
tsconfig.jsonwithdominlibandjsxset topreserve - Rename every JSX-containing file from
.jsxto.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