Updated January 6, 2021
How to Add TypeScript to an Existing React Native Application
Do you want to add TypeScript to your React Native app (if not we've covered why you should use TypeScript in a 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
npx react-native init MyApp --template react-native-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 code from our class showing you how to test React Native apps. If you'd like to follow along this code exists on Github.
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/jest @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-native",
"lib": ["es2017"],
"moduleResolution": "node",
"noEmit": true,
"strict": true,
"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 in React Native
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.
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 already knows 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 in React Native
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.
Pro tip: There is no harm in naming a non-JSX file .tsx. I often use .tsx for every file I write in TypeScript so that if I later use JSX in that file I don't have to change the file type.
When we do that we see quite a few errors pop up.
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.