Typescript - First impressions

/ Lars

In recent years, type systems in JavaScript have also reached the react community. So, with an exciting client on a new codebase and a limited scope, we took the plunge.

TypeScript is not a recent entry into the frontend ecosystem. It appeared on my radar with the announcement that Angular 2 would be built with – and by default be set up to use – TypeScript rather than JavaScript. This is not an article explaining what Typescript is, or is attempting to achieve though. There’s plenty of those already 🙂.

Here I collect some first impression from somebody who watched the developments from the sidelines, and now used TypeScript  in a month-long real-world single-page-application project. A big caveat is that we had no colleague who could help us with their experience and pointed nudges. We relied on the documentation as we could find it.

Most of my hopes for this technology were not very concrete: easier onboarding for future collaborators, more confidence when doing bigger changes to the codebase, maybe less null-related defects. There were two rather concrete hopes though: In the past, I’ve been rather disappointed with prop-types. They tended to go out of sync easily, not be a good match for partial domain objects (e.g. in list views), and after some time we tended to collect console warnings that nobody saw the value in resolving. Second, I hoped that we could track the API return types and notice the places where we had to adjust whenever the API changed.

Spoiler: The project was successful, and we ended with a codebase we can iterate on with confidence.

TL;DR

  • Use an editor that can work with a (ts-)language-server
  • Typed components are the way to the future (with regards to prop-types)
  • Spend time on typing redux-actions
  • API type integration is still on the todo list
  • We will use TypeScript in more projects

For the TypeScript setup, we could fall back on an evergreen: Create-React-App supports TypeScript. That made the time to first render very short. Standing on the shoulders of that giant turned out to come with some caveats in the mid-term though.

Type-checking our codebase was a side-effect of building a release. We could not do one without the other on CI.

More serious though, we did not manage to type-check our tests. They were guaranteed to be valid JavaScript by virtue of running in the test runner. For actual type errors, we ended up crossing our fingers and hoping that our code editors would tell us. That is not a viable long-term solution though, because that also means that changes to the types would only affect test-adjustments the next time somebody touched the tests. Next time we have some budget, we’ll have to investigate some more.

Last, in this setup, we got errors when re-exporting types. We could work around it, but it would have been nice.

// src/api/index.ts
export { default, Api } from ‘./api’;

This was not possible

Documentation

Initially, I worked in my usual code editor. Unfortunately, it did not have language server support at the time. The TypeScript ecosystem seemed not to be setup for that. At all. I could not find a way to look at the inferred type of any variables, unless they ended up causing an error. I had hoped for some form of log statement that would print out the type of the provided variable. But no luck there.

After some stumbling in the dark, we relied on the official repository for types for libraries that don’t maintain any themselves for both mental inference but also typical patterns and common practices. That gave us a strong leg up.

Another invaluable resource for TypeScript+react turned out to be a cheatsheet.

The official documentation was not as helpful. Although that is not that surprising. Switching technologies usually comes with a longer period of getting used to the documentation as much as getting used to the language. What I did not expect though is that some language constructs are only documented in release notes (as far as I could find). In particular, that happened with the let-me-tell-you-this-will-not-be-null postscript exclamation mark. Admittedly a hard syntax feature to search for.

There was a relatively recent change in the handling of TypeScript enums and at the time of writing internet search still prioritized a lot of articles with the former scheme. Also, the compiler does not accept string constants in place of explicit enum members. It is not yet clear to me why. Needing to import the constants was especially odd in the test fixtures.

Typed components instead of prop-types

When switching to TypeScript, properly typed props were one of my concrete hopes, and in that regard the technology delivered. Whenever we improved naming in one component, all the places it was used were pointed out. We could see in the tests when a mandatory prop was not supplied, or the signatures changes in some other way.

We had some problems with basic UI components and co-dependent prop sets and their callback-event-types, but I’d chalk that up to inexperience. Next time, I’d start out with a Button that will always have a click handler. No href, maybe disabled in week three 😅.

Test types

The type-problems in tests were not on my radar as a potential challenge. Besides setup problems with automatically type checking test files, we also had to experiment a lot until we knew how to handle jest mocks in all the different places. In the end, we settled on inline-typing ((fetch as jest.Mock).mockClear()) instead of multiple variables with the same value but different types (const mockedFetch = (fetch as any) as jest.Mock). Also, we might mock less in the future. Let’s see.

Especially in the tests, the additional type-requirements felt like a burden. Admittedly, those requirements originated more in linter than the compiler. Nevertheless, every helper and mock needed something additional.

Testing was not all bad though. Since the domain objects are type checked, their factories and  especially fixture data was always up to date with domain type changes. I’m not sure I’ve worked in a code base where that was the case before.

Domain and store types

In this regard, I can report unqualified success. We spent time to model and import these core types. Any changes due to changed requirements and domain discovery were traced through the codebase by the compiler. In the life of this project, we significantly restructured the store shape of one of the reducers, and once the compiler was quieted, and the tests were green, we could proceed with confidence.

Redux, actions, and types

While we spent time early on to flesh out the domain object types, the store shape type, and the API call types, we only left a generic type for the actions. We struggled for some time with a dispatch type that would work in the thunk action creators. Worse, not collecting all the action types led to some ugly casting in the reducers, where we’re pretty much bypassing the type system and just asserting that in a particular actions case, the payload is of that type. 🤞

case `${submitBlock}-error`: {
    const error = action.payload as Error;
    const { id } = action.meta as { id: uuid };
    return update(
      ['byId', id],
      entry => ({ ...entry, error, dirty: true }),
      state,
    );
  }
…

For now, I accept that tradeoff, since in my experience action payloads are rather stable, and the guarantees for the store shape are much more important. Still I wonder what a good solution would look like. A short internet search surfaced patterns that seem verbose. Definitely a topic we will iterate on.

In this project, we relied on an API documented in an OpenAPI specification. I vaguely remembered descriptions of testing the (domain) types against an API description from other experience reports, and this was a hope for us as well. Unfortunately, we could not find any packages when searching. In the aftermath, it turned out we used the wrong search terms. Found via swaxios – which seems to be still a bit experimental – the project to use might be swagger-codegen. And GraphQL code generator for GraphQL-APIs. Something to look forward to 🙂.

Adoption within the team

TypeScript promises improved collaboration on software at scale. Our teams usually consist of engineers with various specialties. Our technical designers were game for the experiment, but when it came to type errors with generics, or component props union types (👉Component types # Button), we ended up learning together a lot. With the new technology, the established patterns are broken, although not beyond repair. And the benefits were not as noticeable.

All in all, we estimate we spent about 10% additional project time on fighting with the compiler, browsing the documentation, and typing our domain. For a first project that feels not too bad.

Open issues

Before closing, there were a few smaller issues that don’t fit the other categories.

During development, I switched editors to vscode in order to get the benefits of the language server. I’d assumed that would be the best of breed implementation. Unfortunately, that worked rather unreliably with global variables, which were injected by some libraries. In particular, jest-environment-puppeteer adds the global page to test files. page was regularly marked as untyped. As a work-around, open the packages type file from the node_modules folder. As long as it stays open, the variable and type is not forgotten again.

The function type syntax seriously broke my mind. I could not find a way to express that a function is of a known function type.

interface StringTransformer {
  (input: string): string;
}

function lowercase(str: string): string { // ⚡️
  return str.toLowerCase();
}

const lowercase: StringTransformer = (str) => { // 🤮
  return str.toLowerCase();
}

Either I’d assign the function to a variable with that function type, or I’d have to reiterate all the argument- and return-value-types, ignoring the generic type and severing that connection. This was painful with thunks and some extracted event handlers. For the thunks, we found a workaround by typing the return value of the thunk-creator with the generic. That still feels like a hack though.

export const create = (): ThunkAction<Block, Promise<Block>> => ( // <- type return value as the generic
  dispatch, getState, api,
) =>
  dispatch({
    type: create.toString(),
    payload: api.createBlock(),
  });
setActionNames(create, 'block/create');

Conclusion

This is just a first impression report. While I’m listing many a problem we encountered, we ended up with a successful project, and a good basis to iterate on.

In future greenfield projects I would definitely consider TypeScript again. In particular in projects where collaborators might change a lot, stability is important, or where the mid-term vision is of a large product. The typing benefits were already evident in this small project.

In prototyping setups, where the speed of work is more important than future maintainability, or with an important deadline that is looming, I’d be more reluctant, due to the aforementioned problems, the perception that the careful typing will not pay off, and the fact that we’re still familiarizing ourselves.

Whether or not to migrate an existing codebase, I’ve not collected enough experience to confidently assert what should happen. I can say that in established JavaScript codebases I now enter, I do miss the explicit types. And while I would not want to decide to migrate, I’d definitely like to learn from such a project.

Besides the capability of thinking in types, there is an additional short-term benefit: Every now and then, I browse dependency code. In recent months, the amount of packages that use TypeScript seemed higher than before. Browsing and reading TypeScript with knowing just JavaScript is definitely possible, but quickly scanning those is easier now.

© 2020 bitcrowd GmbH.