To type check, or not to type check, that is the question
- Catches most runtime errors before you ship them to production.
- Gives you more context around runtime errors with types.
- Frees up your mental energy. So you don’t have to keep as much code in your head around what the data structure of each variable is.
- Improves code editor tooling. You can see errors immediately inline when you use a variable or method incorrectly. You also get autocomplete for object fields, methods etc. (including for libraries you import from npm if they have types available).
But it’s not all sunshine and rainbows. Type checking has some cons:
- Slows you down at first. If you aren’t trying to account for every situation, compiler errors can get in the way.
- Can lead to a false sense of security. It only catches most runtime errors, not all – and it won’t catch logic errors where your code is wrong, just like with writing tests.
- Tooling is messy, especially with Flow (Flow libdefs are a pain).
Testing vs linting vs type checking
There are loads of tools available to improve your code quality.
A linter can be very helpful for catching a good chunk of common errors in your code. So can writing tests. So can type checking. But they all have a different purpose in my eyes:
- Linting catches simple syntax errors. It can also enforce code formatting but I’d recommend an automatic formatter like Prettier instead of putting it in your linter.
- Unit tests check input => output of chunks of code.
- end-to-end tests check functionality works from the user’s perspective (here’s a post on how we end-to-end test the Ropig app).
- Type checking catches more complex syntax errors and data structures used throughout your app.
I think all of these pieces can work to help improve the quality of your code. As your app grows and increases in complexity you can add on more tools to help. I’d recommend starting with a simple linter. Then add unit tests for pieces of stand-alone logic. Then add end-to-end tests as features come together. Finally, add a type checker as your data structures solidify.
Whether you use TypeScript or Flow, here are a few tips I’d recommend after I’ve built a few apps with each:
- Generate client types from back-end types where possible so you don’t have to maintain them separately. For example, on Ropig, we have a GraphQL API so we generate Flow types automatically from our API data structures (here’s a post about that).
- Run your type checker on pre-commit and/or in CI like you do your tests/linter. This ensures your code is always clean and errors don’t build up. You don’t want to let errors build up or the type checker loses its value (just like with tests and linting).
- Use codemods for updating your code between versions; this makes updates a lot less painful. For example, flow-upgrade if you are using Flow.
- Use inference where it makes sense so you don’t have to maintain extra type-specific code. In my experience, this is an area that Flow shines in – you don’t need much type code for it to infer most things (but if any inference is costing you type safety, Flow won’t type check until you add explicit types).
- Don’t spend too much time on type checking. If you find yourself wasting a lot of time with fighting the compiler, you either have bad code that you need to fix, or too complex of type code set up. You don’t want to get to the point where you are spending most of your time on tooling instead of fixing bugs and building cool features for your users.
TypeScript or Flow?
As with all tools, TypeScript and Flow each come with tradeoffs.
But they are both good tools that can help you write better code, so it doesn’t matter too much which one you pick. Every tool has pros and cons, there is no silver bullet. Don’t stress and fight about it too much 😉