Migrating Our Frontend to TypeScript

Psst: we are hiring remote frontend developers and backend developers.

Illustration of a person holding a laptop and a person holding a picture of cat

Today I’d like to talk about our experience migrating our frontend from React JavaScript to React TypeScript. The things we have learned along the way and how using TypeScript makes our lives easier and the code more robust.

I joined Crystallize in May 2019. The team was small, and the product didn’t have many features. Like many other projects, the repository was in JavaScript, although TypeScript has already been a thing. Still, we didn’t feel the need to use it, so the decision was made, and we stood with JavaScript for a couple of years.

The team has grown fast forward to 2021, and one of the major needs was to know how to use that component written months ago, without the need to see other implementations and visit the component file. Although we had some prop types, which was ok, it didn’t give us the API for that component, what props we needed to pass, and whatnot.

By this time, we were already using Snowpack as our build tool. It is faster than our previous choice - parcel 1 - and provides a good developer experience, and it was easy to add the support for TypeScript. A couple of lines on the config file, and BOOM, we manage to introduce TypeScript in the project and the already existing JavaScript files.

Our approach was simple:

  • Not changing everything at once;
  • Transform js files to ts or tsx whenever we touch it;
  • New files should be written in TypeScript;

So this was our first approach to TypeScript, no rigid rules, no enforcing anything. It will be a marathon, not a sprint.

The team didn’t have much experience with TypeScript, so at first, one of the mistakes we made, and we found out later, was the @ts-ignore directive.

[newsletter]Join Crystallize newsletter!

Learn, build and grow your headless commerce with tips and tricks delivered to your inbox!

Lesson #1: Expect Error Instead of Ignoring

Use the directive @ts-expect-error instead of the @ts-ignore.

This was not of our knowledge, like I said before, we didn’t have much experience with TypeScript, and after only a few months into using TypeScript, using @ts-ignore was a bad choice, since it will be ignored even if we update the other file/component to .ts.

On the contrary, when using the direct @ts-expect-error, whenever we update the file/component to TypeScript, the editor automatically throws us an error, saying the directive was no longer necessary because there was no error on the file/component.

// Bad practice
// @ts-ignore
import MyComponent from "my-component";

// Good practice
// @ts-expect error
import MyComponent from "my-component";

We didn’t find and replace every place that’s using the @ts-ignore directive, the strategy here is to replace them with the @ts-expect-error directive upon touching the file or simply remove the directive if the file is already written in TypeScript.

Lesson #2: Rely On Generated Types

One thing we also introduced with TypeScript was the generated types from the API. This was accomplished with the use of codegen. My colleague Matěj has a good blog post about this in How to use GraphQL API with generated TypeScript Types?.

Whenever there were changes on the API, or we added new queries or mutations (since we are using GraphQL), we could simply run a Node script that updates the types, and then use them. Super duper nice since we didn’t need to come up with new types, well, kind of.

In the beginning, we did come up with some custom types based on the ones already created by codegen. The lack of experience with TypeScript made us make those mistakes, either because we extended the object data with a new property or because we wanted to use some of the properties on the type. But there is an easier way. Use the native method provided, e.g., Pick, Omit, Partial, etc. More on utility types here.

// Generated type
type User = {
firstName: string;
lastName: string;
age: number;
};

// Bad practice
type PartialUser = {
firstName?: string;
lastName?: string;
age?: number;
};

// Good practice
type PartialUser = Partial<User>;
// Generated type
type User = {
firstName: string;
lastName: string;
age: number;
};

// Bad practice
type PartialUser = {
firstName: string;
age: number;
};

// Good practice
type PartialUser = Pick<User, "firstName" | "age">;
// Generated type
type User = {
firstName: string;
lastName: string;
age: number;
};

// Bad practice
type PartialUser = {
firstName: string;
age: number;
};

// Good practice
type PartialUser = Omit<User, "lastName">;

This way we don’t need to maintain custom types ourselves, which means if, for some reason, the base type changes, we get the partial type updated. Keep it simple and build more complex types incrementally.

Lesson #3: Transform Files to the Correct Extension a.s.a.p.

This one is not strictly TypeScript related but was still a good lesson. At a certain point, Snowpack was not working for us anymore, it didn’t seem to be maintained in a while, some conflicts with external dependencies when building the app and overall, too much time trying to debug external dependencies. So we needed to look for an alternative. Among all the good options out there, we landed on Vite, but it came with a caveat, since some of the files in the codebase were still in .js, and Vite doesn’t like that much to have React in .js files. With that being the case, we need to update our remaining .js files with React to .jsx.

Sidenote. All the new files were being created either in .ts for files that don’t contain any JSX (e.g., a custom hook) or .tsx for those containing JSX.

We used a codemod to transform the .js files containing JSX to .jsx files. Happily, all went smoothly, and we managed to do that change effortlessly and quickly.

Lesson #4: Lay Down Some Convention Rules

Since the team didn’t have much experience with TypeScript, we quickly started having interchangeable interfaces and types and different naming conventions. We still have that in the codebase, not that it is wrong per se, but it doesn’t look so good and a more important point here is that it makes it easier to understand others' code if everyone follows the same convention. So our advice here is to agree upon some convention early on, so everybody will follow that convention and things will look much better.

// Interface starting with an I, that was our first approach
interface IMyComponent {
isExpanded: boolean;
}

// Interface ending with Props, this was our second approach, using interfaces, drop the I and use props instead
interface MyComponentProps {
isExpanded: boolean;
}

// Type ending with Props, this is our current approach, we opt to use types instead of interfaces
type MyComponentProps = {
isExpanded: boolean;
};

The latter example ended up being the one adopted using types over interfaces and ending the type with “Props”, so that way, everybody knows how we should type components, functions, and hooks going forward. 


Sidenote: this was just a matter of preference, notice that there is nothing wrong with interfaces or naming your types/interfaces starting with an “I”. The important thing here is that the team agrees on something and follows that convention.

Conclusion

Overall, it has been a good experience and decision to migrate our codebase to TypeScript. Most of the common mistakes are being addressed by TypeScript, and the autocomplete is really good, especially handy for those components that you wrote some months ago and later forgot the props about.

For us, this is a long process as we didn’t do everything at once but decided to do it incrementally. It has been almost two years since we started this migration, and we are proudly near 70% of the codebase is in TypeScript. A good achievement if you ask me, plus it’s only going to get better from here.

Next stop, we’ll use Turborepo to migrate to a monorepo.