Adding TypeScript to an Existing React Native Application

Author

Spencer Carli

Developer, cat dad, and devout pizza lover. Teaching at React Native School and building apps with Handlebar Labs.

Last Updated: August 28, 2019

Do you want to add TypeScript to your React Native app but don't know how you would possibly migrate the entire app over at once? I've felt the same!

Fortunately, we can do so incrementally.

Note: If you're simply starting a new React Native project and are ready to use TypeScript you can do so by running react-native init MyApp --template typescript. This template is what we'll be following along with in this tutorial.

Another quick note, I use Visual Studio Code as my editor. The TypeScript integration is automatic and great. You'll want to investigate how to set up TypeScript in your editor to get the full value.

Installation

We'll be migrating the example from testing month here at React Native School.

First, we need to install the various packages.

Terminal

yarn add --dev typescript

Then we need to add types for all the packages we use.

Terminal

yarn add --dev @types/jest @types/node @types/react @types/react-native @types/react-test-renderer

Next, at the root of your project, create a tsconfig.json file and paste the following. This is pulled from the typescript template I mentioned above.

tsconfig.json

{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "jsx": "react",
    "lib": ["es6"],
    "moduleResolution": "node",
    "noEmit": true,
    "strict": false,
    "target": "esnext"
  },
  "exclude": [
    "node_modules",
    "babel.config.js",
    "metro.config.js",
    "jest.config.js"
  ]
}

At this point you shouldn't notice anything different in your project.

Migrating JS to TypeScript

Let's start the migration! We'll start at App/util/api.js, which looks like this:

App/util/api.js

export const api = (path, options = {}) => {
  return fetch(`https://jsonplaceholder.typicode.com${path}`, options)
    .then((res) => res.json())
    .then((response) => {
      if (!Array.isArray(response) && Object.keys(response).length === 0) {
        throw new Error('Empty Response');
      }

      return response;
    });
};

To migrate to TypeScript change the .js to .ts. You'll notice that everything still works because valid JS is valid TS.

TypeScript can implicitly set types for you, as you can see it's doing for me.

Implicit Types

You can also see that I've got a warning showing up. It doesn't like that I don't have a type specified for the path. We know that it's a string so let's say that.

App/util/api.ts

export const api = (path: string, options = {}) => {
  return fetch(`https://jsonplaceholder.typicode.com${path}`, options)
    .then((res) => res.json())
    .then((response) => {
      if (!Array.isArray(response) && Object.keys(response).length === 0) {
        throw new Error('Empty Response');
      }

      return response;
    });
};

We can even take it a step further and specify the return type. TypeScript knows already we'll return a Promise but the actual content of the promise response is specified as any. We know it will either be an object, array, or error, so we can specify that.

App/util/api.ts

export const api = (
  path: string,
  options = {}
): Promise<object | Array<object> | Error> => {
  return fetch(`https://jsonplaceholder.typicode.com${path}`, options)
    .then((res) => res.json())
    .then((response) => {
      if (!Array.isArray(response) && Object.keys(response).length === 0) {
        throw new Error('Empty Response');
      }

      return response;
    });
};

Migrating JSX to TypeScript

Next let's look at App/screens/Post.js.

App/screens/Post.js

import React from 'react';
import {
  SafeAreaView,
  Text,
  TouchableOpacity,
  StyleSheet,
  FlatList,
  View,
  ScrollView,
} from 'react-native';
import { api } from '../util/api';

const styles = StyleSheet.create({
  content: {
    paddingHorizontal: 10,
  },
  title: {
    fontWeight: 'bold',
    marginTop: 20,
  },
});

class PostList extends React.Component {
  state = {
    post: {},
    comments: [],
  };

  componentDidMount() {
    const postId = this.props.navigation.getParam('postId');
    this.getPost(postId);
    this.getComments(postId);
  }

  getPost = (postId) => {
    api(`/posts/${postId}`).then((post) => {
      this.setState({ post });
    });
  };

  getComments = (postId) => {
    api(`/posts/${postId}/comments`).then((comments) => {
      this.setState({ comments });
    });
  };

  render() {
    return (
      <SafeAreaView>
        <ScrollView contentContainerStyle={styles.content}>
          <Text style={styles.title} testID="post-title">
            {this.state.post.title}
          </Text>
          <Text>{this.state.post.body}</Text>
          <Text style={styles.title}>Comments</Text>
          <FlatList
            data={this.state.comments}
            renderItem={({ item }) => (
              <View>
                <Text>{item.name}</Text>
              </View>
            )}
            keyExtractor={(item) => item.id.toString()}
          />
        </ScrollView>
      </SafeAreaView>
    );
  }
}

export default PostList;

First step is to convert it to Post.tsx. In TypeScript it is important to add the x to the end of the file specify that it is JSX based.

When we do that we see quite a few errors pop up.

Post.tsx TypeScript Errors

Let's do the easy ones first - postId.

App/screens/Post.tsx

// ...

class Post extends React.Component {
  state = {
    post: {},
    comments: [],
  };

  componentDidMount() {
    const postId = this.props.navigation.getParam('postId');
    this.getPost(postId);
    this.getComments(postId);
  }

  getPost = (postId: number) => {
    api(`/posts/${postId}`).then((post) => {
      this.setState({ post });
    });
  };

  getComments = (postId: number) => {
    api(`/posts/${postId}/comments`).then((comments) => {
      this.setState({ comments });
    });
  };

  // ...
}

export default Post;

Next we'll do the component props. Since props is an object I'm going to create an interface (that we'll also export in case we need to access it elsewhere).

App/screens/Post.tsx

// ...
export interface PostProps {
  navigation: any;
}

class Post extends React.Component<PostProps> {
  state = {
    post: {},
    comments: [],
  };

  // ...
}

export default Post;

Challenge: Using @types/react-navigation use the proper types for navigation rather than any.

Next we'll do component state. Similar process to props...

App/screens/Post.tsx

// ...
export interface PostState {
  post: PostType;
  comments: Array<CommentType>;
}

class Post extends React.Component<PostProps, PostState> {
  state = {
    post: {},
    comments: [],
  };

  // ...
}

export default Post;

Where are these PostType and CommentType coming from? We need to define them. If they're something used in many places throughout your app then you should define them in some centralized location. We'll just do it in this file for simplicity.

App/screens/Post.tsx

// ...
export interface PostType {
  title?: string;
  body?: string;
  id?: number;
}

export interface CommentType {
  name: string;
  id: number;
}

export interface PostState {
  post: PostType;
  comments: Array<CommentType>;
}

class Post extends React.Component<PostProps, PostState> {
  state: PostState = {
    post: {},
    comments: [],
  };

  // ...
}

export default Post;

It specified what should be there and what type it should be.

The finished file:

App/screens/Post.tsx

import React from 'react';
import {
  SafeAreaView,
  Text,
  TouchableOpacity,
  StyleSheet,
  FlatList,
  View,
  ScrollView,
} from 'react-native';
import { api } from '../util/api';

const styles = StyleSheet.create({
  content: {
    paddingHorizontal: 10,
  },
  title: {
    fontWeight: 'bold',
    marginTop: 20,
  },
});

export interface PostType {
  title?: string;
  body?: string;
  id?: number;
}

export interface CommentType {
  name: string;
  id: number;
}

export interface PostState {
  post: PostType;
  comments: Array<CommentType>;
}

export interface PostProps {
  navigation: any;
}

class PostList extends React.Component<PostProps, PostState> {
  state: PostState = {
    post: {},
    comments: [],
  };

  componentDidMount() {
    const postId = this.props.navigation.getParam('postId');
    this.getPost(postId);
    this.getComments(postId);
  }

  getPost = (postId: number) => {
    api(`/posts/${postId}`).then((post: PostType) => {
      this.setState({ post });
    });
  };

  getComments = (postId: number) => {
    api(`/posts/${postId}/comments`).then((comments: Array<CommentType>) => {
      this.setState({ comments });
    });
  };

  render() {
    return (
      <SafeAreaView>
        <ScrollView contentContainerStyle={styles.content}>
          <Text style={styles.title} testID="post-title">
            {this.state.post.title}
          </Text>
          <Text>{this.state.post.body}</Text>
          <Text style={styles.title}>Comments</Text>
          <FlatList
            data={this.state.comments}
            renderItem={({ item }) => (
              <View>
                <Text>{item.name}</Text>
              </View>
            )}
            keyExtractor={(item) => item.id.toString()}
          />
        </ScrollView>
      </SafeAreaView>
    );
  }
}

export default PostList;

Configuring Tests

To get your tests working with TypeScript first you need to tell Jest to look for TypeScript files by adding moduleFileExtensions to the jest object in your package.json. We'll also change the setup file to .ts.

package.json

{
  // ...
  "jest": {
    "preset": "react-native",
    "automock": false,
    "setupFiles": ["./setupJest.ts"],
    "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
  }
  // ...
}

Next, we'll convert setupJest.js to setupJest.ts and inform our tests of the mocking methods available.

setupJest.ts

import { GlobalWithFetchMock } from 'jest-fetch-mock';

const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock;
customGlobal.fetch = require('jest-fetch-mock');
customGlobal.fetchMock = customGlobal.fetch;

Now, in all of our tests, we need to convert fetch to fetchMock to alleviate any TypeScript errors in a test that exists in a TypeScript file (such as in App/util/__tests__/api.test.ts).

Wrapping Up

Now you can incrementally start adopting TypeScript in your React Native app. It's no small task but adding static type checking can help you identify typos that lead to bugs before your tests ever have to run.

You can see the entire migration process for the example app in this commit.

Additional Resources

React Native School Logo

React Native School

Want to further level up as a React Native developer? Join React Native School! You'll get access to all of our classes and our private Slack community.

Join the email list to be notified of all new lessons and classes!