← Back to blogReact Native

Migrating a React Native codebase to TypeScript mid-project

July 22, 2022·9 min read·2 comments

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:

  1. Enable TypeScript alongside JavaScript
  2. Start converting from leaf files (no imports from other project files)
  3. 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 any temporarily.
  • 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.

RESPONSES
Tom ErikssonAug 4, 2022

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.

Sarah MitchellAug 17, 2022

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.

Leave a response