Porting a React Frontend to TypeScript
Posted on
The beta version of Execute Program was written in Ruby and JavaScript. Then we ported all of it to TypeScript in multiple steps. This is the story of the frontend port, which was the first step.
In Execute Program's original JavaScript frontend, I often made small mistakes. For example, I'd pass the wrong prop names to a React component, or leave a prop out, or pass the wrong kind of data. (Props are pieces of data sent as arguments to a React component. It's common for a component to pass some props to one of its sub-components, which pass them on again, etc.)
This is a big problem with dynamic languages like JavaScript and Ruby. I've been learning to mitigate it for 15 years. Here I am talking about it way back in 2011. Mitigations like the ones discussed there do help, but they don't scale well as a system grows, and there's no safety net for when we forget them.
I thought that 15 years was enough; I wanted to return to static type systems, where these mistakes are impossible. There were a few options: Elm, Reason, Flow, TypeScript, PureScript. (That's not an exhaustive list.) I decided on TypeScript because:
- TypeScript is a superset of JavaScript, so porting to it is easy. Reversing a port is even easier: delete the type definitions; now we have JavaScript again.
- The TypeScript compiler is written in TypeScript and distributed as compiled JavaScript, so we can run it in our web app. Our TypeScript course does exactly that: we evaluate users' TypeScript code in the browser to avoid network latency.
- This one is particular to our business, but: TypeScript is more popular than the other options. That means more people want to learn TypeScript from courses like ours. Writing Execute Program itself in TypeScript was a good way to set us up for writing a TypeScript course.
Porting the frontend JavaScript code to TypeScript took about 2 days in October of 2018. Here's a plot showing how much code we had in each language leading up to, and immediately after, that port.

That was pre-beta, so the system was still small: about 6,000 lines. The period after the port is missing here; we'll expand on that in future posts.
After that port, React prop problems disappeared. We'll look at a couple examples, starting with an easy one. Here's the code that renders the "Continue" button that appears after every paragraph of text in our courses:
<Buttonautofocus={true}icon="arrowRight"onClick={continue}primary>Continue</Button>
The type of that Button component's props is shown below.
When reading a property type like autofocus?: boolean, "autofocus" is the name of the property; "?" means that it's optional; ":" separates the property name from its type; and "boolean" is the type.
The last property type, onClick, means "a function that takes no arguments and returns nothing".
(If TypeScript's function type syntax is unfamiliar, you can get a comprehensive overview in our lesson on TypeScript's function types.)
type ButtonProps = {autofocus?: booleanicon?: IconNameprimary?: booleanonClick: () => void}
What happens if we change the "autofocus" prop from true to 1? We're now passing a number value where the type system expects a boolean. Less than a second later, the compiler prints the error below. (Some irrelevant details have been removed here; we'll do that for all of the errors in this series of articles.)
src/client/components/explanation.tsx(13,27):
error: Type 'number' is not assignable to type 'boolean | undefined'.
The offending code also turns red in vim. I fix it and the red goes away. Fixing the mistake takes seconds. In Ruby or JavaScript, I might spend minutes manually testing the app and rummaging around in its state to find out what happened. (I could also rely on automated tests, but we cover the issue of tests vs. types in another post.)
That integer-to-boolean change was a simple and low-stakes use of the type system.
Button's icon property shows more advanced use.
Here's the Button invocation again:
<Buttonautofocus={true}icon="arrowRight"onClick={continue}primary>Continue</Button>
It looks like the icon prop is just a string: "arrowRight".
At runtime, in the compiled JavaScript code, it will be a string.
But in the ButtonProps type shown above, we defined it as an IconName, which is defined elsewhere.
Let's see what the type does before we look at its definition. Suppose that we change the "icon" prop to "banana". We don't actually have an icon named "banana".
<Buttonautofocus={true}icon="banana"onClick={continue}primary>Continue</Button>
Less than a second later, the TypeScript compiler rejects that change:
src/client/components/explanation.tsx(13,44):
error: Type '"banana"' is not assignable to type
'"menu" | "arrowDown" | "arrowLeft" | ... 21 more ... | undefined'.
The compiler is saying that "icon" can't be any arbitrary string; it has to be one of the 24 strings that we've defined as icon names. The compiler will reject any change that leaves us referencing a non-existent icon; it's not a valid program and can't even begin executing.
There are multiple ways to implement the IconName type.
One is to write a type that explicitly lists all of the possible icon names.
Then we'll have to keep the icon names in sync with their image files on disk.
That type might look like:
type IconName ="menu" |"arrowDown" |"arrowLeft" |"arrowRight" |...
In English: "a value of type IconName is statically guaranteed to be one of the strings specified here, but not any other string."
(This type is a combination of two topics covered by Execute Program lessons: literal types and type unions.)
Our IconName isn't defined as a simple union of literal string types.
Keeping a list of icon names in sync with a list of files is boring work that we can make the computer do!
Instead, our icon.tsx file looks like this:
export const icons = {arrowDown: {label: "Down Arrow",data() {return <path ... />}},arrowLeft: {label: "Left Arrow",data() {return <path ... />}},...}
The actual SVG <path /> tags are right inside the source code, in an object keyed by the icon's name.
(It's also possible to do this without inlining the SVG into a source file.
For example, we could use some Webpack tricks to keep the images in their own files, but still have a guarantee that every icon in the list also exists on disk.
So far, this simpler solution has worked well for us.)
By defining the icons in this way, we can extract a union type of their names automatically with one line of code:
export type IconName = keyof typeof icons
(In English, you can think of that type as saying "whenever something has the type "IconName", it must be a string that matches one of the keys of the icons object.)
That's it; there's no other type-level work required.
The rest of the code is just a straightforward Icon React component that looks up an icon in a list and returns its SVG path.
There are no explicit TypeScript types in that function; it looks like pure JavaScript, though it's still type-checked.
Here's a minimal version with all unrelated details stripped away:
export function Icon(props: {name: IconName}) {return <svg>{icons[props.name].data()}</svg>}
Now we can drop new icons into the "icons" list by putting the SVG tags in that source file. When we do that, the icon becomes available for use in the Button component, as well as any other part of the system that accepts an icon name. If we delete an icon from the list, every part of the system that references it instantly fails to compile, ensuring that we have no stale icon references that can cause errors at runtime.
These examples are simple by static type standards, but I think that they illustrate how much low-hanging fruit there is in a web application. Most of an application's code doesn't involve advanced type system features; it's simple stuff like "make sure that we're passing the right props" and "make sure that our icons actually exist."
We do this kind of thing all over the system. Some more examples:
- We have a
Notecomponent used throughout the system. It has atoneprop to determine the style of the note: "info", "warning", "error", etc. If we retire one of those tone options, we'll remove it from the union type, and allNoteuses that referenced that tone will error until we update them. - Every URL that we link to is statically guaranteed to exist. When we rename or delete a URL, every component linking to it fails to compile until we update them to match.
- When we link to those URLs, the type system ensures that we fill in any holes in the URL. For example, the path "/courses/:courseId/lessons/:lessonId" has two holes, "courseId" and "lessonId". If we try to link to that path but forget to supply a "courseId", then the code won't compile.
- Every API request that we make on the client is statically guaranteed to match the payload structure of the corresponding server-side API endpoint. If we rename a property in an endpoint, even deep inside a nested API object, then any code referencing that endpoint property will fail to compile until we update it to match. (We cover this in another post.)
Problems like these come up often in programming, especially in dynamic languages, but can be statically prevented without writing any automated tests and without doing any manual testing. Some of them take work; our API router verification was tricky to write. But a lot of them are easy. The one-line "IconName" type above really is the entire solution to the problem; it will work if you copy it into a TypeScript file.
Porting our frontend code to TypeScript was just the beginning. We've since ported the backend from Ruby to TypeScript, then grown and maintained it for nine months after the port.
Gary Bernhardt