Converting a React App to TypeScript
📖 Click here for a fully-worked example of the code in this blog post
I've been looking at TypeScript recently (yes, I'm a bit late to this party) as a means to improve the readability and maintainability of an existing large web application. I thought I'd initially start small with something that I own, so in this post I'll describe the steps that I took to convert a suite of small React apps to using TypeScript.
Setting Things Up
I have two small web apps - they're small utilities that I host on my RPG website: here and here - and as such they use bare-bones React and nothing else. In the future I envisage these becoming either part of a larger web app, or for there to be a suite of similar-sized web apps. I also wanted to re-use some shared components between them. For that reason I imported the two existing git repos into a Lerna monorepo. This isn't a necessity however and all of the steps below can be run on each project individually.
To use TypeScript, I need some new dependencies. I require the typing definitions for React and ReactDOM, which are shipped separately from the main packages. I also require a Webpack loader that can trigger TypeScript compilation. Using yarn/npm/lerna, install: @types/react @types/react-dom @typescript awesome-typescript-loader --save-dev
TypeScript looks for a tsconfig.json
file, which instructs the TypeScript compiler where to look for code, where to produce code, and any specific compilation rules that should be followed during compilation. My configuration is explicitly disabling the noImplicitAny
rule as I'm looking for strict typing. It's also instructing TypeScript to produce ES2015-compilant JavaScript and use the commonjs module format.
{
"compilerOptions": {
"outDir": "./dist/",
"sourceMap": true,
"noImplicitAny": true,
"module": "commonjs",
"target": "es2015",
"jsx": "react"
},
"include": [
"./src/**/*"
]
}
TypeScript's compilation step replaces Babel's transpilation step, meaning that I could delete Babel, it's dependencies, and the .babelrc
files.
I then renamed all my .jsx
files to .tsx
. In Visual Studio Code this started to produce quite a lot of errors almost immediately!
I was already using Webpack to build my distributables and would like to continue to do so. After upgrading from Webpack 2 to 4, I then had to make some small changes to the config file - to tell it to look for TypeScript files, and run them through the TypeScript loader.
var webpack = require('webpack');
module.exports = {
entry: "./src/index.tsx",
output: {
filename: "barbarian-combat-tracker.js",
path: __dirname + "/dist"
},
devtool: "source-map",
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"]
},
module: {
rules: [
{ test: /\.tsx?$/, loader: "awesome-typescript-loader" },
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" },
{ test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] },
{ test: /\.png$/, loader: 'url-loader?limit=25000' }
]
},
externals: {
"react": "React",
"react-dom": "ReactDOM"
}
};
The way that imports works in TypeScript is slightly different to ES imports. Therefore I had to change all occurrences of: import React from 'react'
to: import * as React from 'react'
Converting Functions
Most functions are quite simple to convert to TypeScript - specify the types of any parameters and the type of the returned object:
const proficiencyBonus = (level: number) : number => {
if (level >= 17) { return 6 }
else if (level >= 13) { return 5 }
else if (level >= 9) { return 4 }
else if (level >= 5) { return 3 }
else return 2
};
Converting the React Components
The vast majority of the components that I use are stateless components. These are simple to convert manually and act as a good starting point. If you envisage your React components as a tree, the simplest ones will most likely live at the leaf nodes and will most likely be stateless, receiving properties from their parent components and producing output wholly dependent upon those inputted properties. Take the following React component for example:
const Effect = ({ effect, invocation }) =>
<li>
<span>
<span className="description">{effect}</span>
<span className="invocation">{invocation}</span>
</span>
</li>
The first step was to take that list of properties from the component and define an interface:
interface EffectProps {
effect: string
invocation: string
}
Then, I declared the type of your functional component to be one of React.SFC
. This is a generic type, with a type parameter corresponding to the interface declared for the component's properties:
const Effect: React.SFC<EffectProps> = ({ effect, invocation }) =>
<li>
<span>
<span className="description">{effect}</span>
<span className="invocation">{invocation}</span>
</span>
</li>
From there I progress upwards in the component, doing the same thing. tree. Every time I saw a property that was complex object, I gave it the type any
. These will be revisited later on, but help in the immediate sense that you can progressively work through your files and see them go from red to green.
Functions that you pass as properties into React components, for example for use as a callback function, must also have typing information supplied.
interface CheckboxProps {
value: boolean
onChange: (checked: boolean) => void
disabled?: boolean
children: React.ReactNode
}
My top-level root React component is a stateful component. It's using its internal state to track anything that can change within the application - I thought the state was small enough not to warrant using a state management library such as Redux. These too must be typed! Rather than using React.SFC
, I use React.Component
. It's also a generic type with two type params - the first represents the type of the props, the second the type of the state. As I don't care about the props I'm comfortable using any
. For my state I use a custom type called Model
, which I need to define myself.
class Container extends React.Component<any, Model> {
constructor(props: any) {
super(props);
this.state = {
// Define the state inital values
}
}
render() {
// Render the component tree
}
}
Defining Domain Object Models
Let's talk a bit about how to define those custom domain-based types. I need a custom type to define the shape of the top-level state, and also replace the instances of any
that I've sprinkled temporarily throughout the code into something more meaningful. This is as straightforward as defining some more interfaces:
export interface Pact {
name: string
value: string
}
For some of my domain types, I had some properties which were optional. TypeScript doesn't expect optionals by default - you need to instruct the compiler to allow for them by denoting the property with a ?
:
export interface Invocation {
id: string
name: string
eldritchBlast?: boolean
effect?: string
minLevel?: number
pact?: string
patron?: string
source?: string
tome?: string
}
One of the more esoteric types I had to define was for an object which was a map of string -> boolean
. Of course all objects in JavaScript are keyed by string, which effectively makes them maps by default. But in order to represent this as a type, specifically a type that makes sense in the domain, I firstly had to define a helper Map
type, which I can then extend:
interface Map<T> {
[key: string]: T
}
export interface ActiveInvocations extends Map<boolean> {}
As this is such as small project I decided to extract the domain-based types into a separate model.ts
file. However as an application grows I could quite conceivably see that you would end up defining these in separate files. Once again by working upwards from the leaf React components up to the top-level root React component, I was able to replace all instances of any
which represented a domain object with the newly-defined types.
From here all I need to do is run webpack, load the index.html
file and everything is working!
Conclusion
This was a relatively straightforward exercise. I didn't have any extra dependencies such as state management libraries to handle. Overall it took me a few hours to grasp (enough of) the TypeScript syntax and perform the conversion. Visual Studio Code is my primary IDE of choice and it's great to see its TypeScript integration works so well that you can use it as a guide while converting. It also called out a few errors in my existing code (oops!) which I was able to correct as part of the conversion. I can totally see the benefits of introducing TypeScript, although hopefully
Next steps for me are to investigate how well TypeScript plays with a React-Redux project, and then something such as the Apollo GraphQL client - the prospects of using a GraphQL schema to strongly-type the frontend code is very exciting to me!