We migrated a React Native app with about 120 files from JavaScript to TypeScript while four developers were actively shipping features. The app was six months old, had no tests for most screens, and was deployed to production with real users.
The decision to migrate came after the third bug in two weeks that TypeScript would have caught at compile time: a navigation param that was renamed in one screen but not the screen that passed it, a Redux action payload that changed shape without anyone updating all the consumers, and a prop that was optional in the component but required by the parent.
The strategy
Converting everything at once wasn't an option. We couldn't stop feature work for a week, and a big-bang migration would have created hundreds of type errors that needed fixing before anyone could build the app.
Instead, we used an incremental approach:
- Enable TypeScript alongside JavaScript
- Start converting from leaf files (no imports from other project files)
- Work inward toward the core
Step 1: enable TypeScript
npm install --save-dev typescript @types/react @types/react-native
Create a tsconfig.json:
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"lib": ["es2019"],
"jsx": "react-native",
"strict": true,
"allowJs": true,
"checkJs": false,
"moduleResolution": "node",
"resolveJsonModule": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
The key settings: allowJs: true lets TypeScript files coexist with JavaScript files. strict: true enables all strict type checking for .ts/.tsx files. JavaScript files aren't checked (checkJs: false).
With this config, you can rename any .js file to .ts (or .jsx to .tsx) and it becomes a TypeScript file. Everything else stays JavaScript. The app still builds.
Step 2: start from the leaves
We mapped out the dependency graph mentally (a proper tool would have been better, but the codebase was small enough). Files that didn't import from other project files were the leaves: utility functions, constants, configuration, standalone hooks.
These files are the easiest to convert because you don't need to know the types of imported project modules. You only need to type the function signatures and any external library types.
// utils/formatDate.ts (was .js)
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
We converted about 30 utility and config files in the first day. Zero type errors because these files were simple. The app still built. No one's workflow was affected.
Step 3: work inward
After the leaves, we converted shared components, then screen-specific components, then screens, then navigation. Each layer depended on the layers already converted, so we had proper types flowing through.
Typing React Navigation
This was the hardest part. React Navigation's type system is powerful but requires setting up a root param list type that describes every screen and its params.
// navigation/types.ts
export type RootStackParamList = {
Home: undefined;
Profile: { userId: string };
Settings: undefined;
PostDetail: { postId: string; title: string };
EditPost: { postId: string };
};
export type TabParamList = {
Feed: undefined;
Search: { query?: string };
Notifications: undefined;
Account: undefined;
};
Then in each screen:
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'PostDetail'>;
export default function PostDetailScreen({ route, navigation }: Props) {
const { postId, title } = route.params; // Fully typed
// navigation.navigate('Profile', { userId: '123' }); // Type-checked
}
This catches the exact class of bugs that motivated the migration. If you rename a param or change its type, TypeScript shows errors in every screen that navigates to that route. Before this, we were catching these at runtime.
Typing Redux
Our Redux store used Redux Toolkit, which has good TypeScript support. The main work was typing the root state and dispatch:
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
ui: uiReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Then replacing useSelector with useAppSelector throughout the codebase. This gives you autocomplete for state shape and catches typos in selector paths.
React Native specific typing issues
Some things that tripped us up:
Style props. React Native's StyleSheet.create infers types well, but passing style props between components needs explicit typing:
import { StyleProp, ViewStyle, TextStyle } from 'react-native';
interface CardProps {
style?: StyleProp<ViewStyle>;
titleStyle?: StyleProp<TextStyle>;
children: React.ReactNode;
}
Animated values. Animated.Value is typed, but the interpolation output is Animated.AnimatedInterpolation<string | number>, which doesn't directly satisfy style types. You need to cast:
const opacity = useRef(new Animated.Value(0)).current;
// Style needs explicit typing
const animatedStyle = { opacity: opacity as unknown as number };
Gesture handler event types. If using react-native-gesture-handler, the event types are verbose:
import { GestureEvent, PanGestureHandlerEventPayload } from 'react-native-gesture-handler';
function onGesture(event: GestureEvent<PanGestureHandlerEventPayload>) {
const { translationX, translationY } = event.nativeEvent;
}
What not to type strictly early on
We made a mistake typing everything as strictly as possible from day one. Some things are better left loosely typed during migration and tightened later:
- Third-party library return values that lack proper types. Use the library's types or
anytemporarily. - Complex Redux thunk chains. Get the basic state typed first, then tighten the async action types.
- Test files. Converting tests to TypeScript during the migration adds friction without immediate benefit.
The rough week
Days three through seven were hard. We hit a critical mass of partially-typed files where imports between typed and untyped modules created confusing errors. The TypeScript compiler would infer any from JavaScript imports, which defeated the purpose.
The fix was to add minimal type declaration files (.d.ts) for the JavaScript modules that were most heavily imported but not yet converted. A one-line declaration file was enough to unblock:
// api/client.d.ts (temporary, until client.js is converted)
declare module '../api/client' {
export function get(url: string): Promise<any>;
export function post(url: string, data: any): Promise<any>;
}
After the first week, about 80% of files were TypeScript. The remaining 20% were the most complex screens with deep component trees. Those were converted over the following two weeks without urgency.
The result
Within a month, the codebase was fully TypeScript. The immediate benefit was that the category of bug that motivated the migration, mismatched navigation params, renamed props, changed payload shapes, stopped appearing entirely. The longer-term benefit was faster feature development because autocomplete and inline documentation made it easier to work with unfamiliar parts of the codebase.
The cost was one rough week and about 60 hours of total engineering time across the team. Given the bugs it prevented, that cost was recovered within the first month.
The approach of starting from leaf files is smart and I haven't seen it suggested anywhere else. We've been trying to migrate a large codebase and keep getting stuck because we start from the entry points and the cascade of errors is overwhelming.
The React Navigation typing section alone was worth reading. That param list pattern is something I put off for months because I assumed it would require more restructuring than it actually does.