Joe Attardi Software engineer and author focused on frontend/UI topics.

Understanding TypeScript’s benefits and pitfalls

7 min read 2143

Understanding TypeScript's benefits and pitfalls

JavaScript’s dynamic typing allows for flexibility, but it adds extra complexity and risk. If someone passes a Number to a function that expects a Date, it’s likely that the function will throw an exception unless the function adds some extra code to ensure that the argument is actually a Date.

Type checking is the primary advantage of TypeScript. By adding static typing to the language, we can catch many of these problems at build time. They can be fixed before the code is shipped.

But it’s not a panacea, of course. Like any tool, there are positive and negative aspects. In this post, we’ll talk about:

The good

Let’s start by exploring TypeScript’s benefits. There are many others not listed here, but these are some of the best.

Excellent code completion

I primarily use Visual Studio Code as my IDE. Among its many powerful features, VS Code comes with inbuilt TypeScript intelligence. Pop-up code completion is available for the web platform APIs as well as any third-party package that has type definitions (now, in 2022, most do).

Can’t remember the arguments for splicing an array? VS Code has you covered. Consider this simple array:

const numbers = [1, 2, 3];

When I begin to call a function on the numbers array, VS Code’s IntelliSense kicks in and shows me matching functions:

matching functions from IntelliSense

I see the function signature and a description of each argument. As I continue typing a call to splice, the current parameter is highlighted:

Splice parameter highlighting.

If I call a function incorrectly, I get the red underline immediately:

Incorrect function red highlighting.

Of course, VS Code is only one editor; many other modern editors and IDEs also offer first-class TypeScript support.

Support for incremental adoption

TypeScript has many configuration options, including many that control strict type checking.

When you start out with a simple configuration and turn strict checking off, the barrier to entry lowers and your project can start enjoying the benefits of static typing. As you and your team get more familiar with TypeScript, additional options can be added to start making stricter checks.

TypeScript can be introduced within a project of JavaScript files because it outputs JavaScript. After the build process, everything is just JavaScript. This allows for a more gradual transition to TypeScript; instead of renaming all *.js files to *.ts and likely getting many errors (depending on the configuration setting), you can start with one or two TypeScript files. Eventually, files can be renamed to *.ts and the new type errors that arise can be solved.

Better third-party library integration

When working with a third-party package with TypeScript, you may not find yourself switching between your editor and a browser tab with the documentation as often. Many libraries on npm ship with TypeScript type definition (d.ts) files included.

The npm website also shows a TypeScript badgefor any package that includes type definitions built in:

Typescript badge for typescript definitions

Community-provided types

There are plenty of other packages that do not include type definitions. To support these situations, Microsoft also runs the DefinitelyTyped project, a GitHub repository where members of the community can submit type definitions for libraries and tools that are missing them.

These types are published as separate packages under the @types scope. The types are often not contributed by the package’s author. Generally speaking, unless a package is out of date or abandoned, you’re very likely to find type definitions for whatever packages you are working with today.

If you’re a library author, you don’t even need to write definitions by hand. The TypeScript compiler can be configured to automatically generate these d.ts files based on module(s) in your library.

The not-so-good

TypeScript is not without its detractors. Sometimes, I actually agree with what they say. TypeScript has its share of warts as well that can sometimes be annoying.



Safety not guaranteed (at runtime)

It’s easy to be lulled into a false sense of security with TypeScript. Even if your entire project is written in TypeScript, has rigid type definitions, and has the strictest type checking turned on, you are still not safe.

TypeScript performs all of its type checking at build time. Maybe someday browsers will support running TypeScript natively, but for now, the TypeScript compiler goes through your code, makes sure you don’t have any type errors, and outputs plain JavaScript that can run in a browser (or Node.js environment).

This generated JavaScript contains no type checking. That’s right, at runtime, it’s all gone. You can be confident, at least, that any code that it processed won’t have type errors; your app won’t blow up because someone tried to call splice on a Date object.

Most apps don’t exist in a vacuum, though. What happens when you request data from an API? Suppose you write a function to process data from a well-documented API. You can create an interface to model the expected shape of the data and all functions that work with it use this type information.

Maybe this particular service changed the format of their API data and you missed the update. Suddenly you’re passing data that doesn’t match the type definition of the function. Boom! Exception in the console.

All is not lost. Sneh Pandya discusses some options for checking types at runtime in Methods for TypeScript runtime type checking.

TypeScript adds extra complexity, even for simple tasks

Let’s listen for input events on a text input using JavaScript:

document.querySelector('#username').addEventListener('input', event => {
  console.log(event.target.value);
});

Whenever the user enters some input, it is printed to the console. This works in the browser and all is well.

Let’s drop that exact same code into TypeScript. It will give us a couple of errors about the reference to event.target.value.


More great articles from LogRocket:


The first is:

Object is possibly 'null'.

TypeScript has inferred that the event argument is of type Event, the base interface of all DOM events. According to the specification, it is possible for the Event‘s target property to be null (for example, a directly created Event object with no target given).

The DOM type definitions specify the type of Event.target as EventTarget | null. TypeScript is telling us that event.target could be null — not based on our code, but on the definitions themselves.

In this instance, we know that event.target will be defined because the event is coming from the <input> element, to which we added a listener. We can safely assume that event.target is not null. For this, we can use TypeScript’s non-null assertion operator (the ! operator). This operator should be used with great care, but here it’s safe:

input.addEventListener('input', event => {
  console.log('Got value:', event.target!.value);
});

This takes care of the “possibly null” error, but there’s still another:

Property 'value' does not exist on type 'EventTarget'.

We know that event.target here refers to the <input> element (an HTMLInputElement), but the type definitions say event.target is of type EventTarget, which does not have a value property.

To safely access the value property, we need to cast event.target as an HTMLInputElement:

input.addEventListener('input', event => {
  const target = event.target as HTMLInputElement;
  console.log('Got value:', target.value);
});

Notice that we also no longer need the ! operator. When we cast event.target as HTMLInputElement, we do not consider the possibility of it being null (if this was a possibility, we’d instead cast it as HTMLInputElement | null).

Error messages can be difficult to decipher

Simple type errors are usually easy to understand and fix, but sometimes TypeScript produces error messages that are unhelpful at best, and undecipherable at worst.

Here’s some code for a simple user database:

interface User {
  username: string;
  roles: string[];
}

const users: User[] = [
  { username: 'bob', roles: ['admin', 'user']},
  { username: 'joe', roles: ['user']}
];

We have a list of users and a type definition that describes a user. A user has a username and one or more roles. Let’s write a function that produces an array of all unique roles across these users:

const roles = users.reduce((result, user) => { // Huge error here!
  return [
    ...result,
    ...user.roles.filter(role => !result.includes(role)) // Minor error here
  ];
}, []);

We start with an empty array, and for each user, we add each role that we haven’t seen yet.

The error message that we get may well scare a beginner away from TypeScript forever:

No overload matches this call.
  Overload 1 of 3, '(callbackfn: (previousValue: User, currentValue: User, currentIndex: number, array: User[]) => User, initialValue: User): User', gave the following error.
    Argument of type '(result: never[], user: User) => Role[]' is not assignable to parameter of type '(previousValue: User, currentValue: User, currentIndex: number, array: User[]) => User'.
      Types of parameters 'result' and 'previousValue' are incompatible.
        Type 'User' is missing the following properties from type 'never[]': length, pop, push, concat, and 26 more.
  Overload 2 of 3, '(callbackfn: (previousValue: never[], currentValue: User, currentIndex: number, array: User[]) => never[], initialValue: never[]): never[]', gave the following error.
    Argument of type '(result: never[], user: User) => Role[]' is not assignable to parameter of type '(previousValue: never[], currentValue: User, currentIndex: number, array: User[]) => never[]'.
      Type 'Role[]' is not assignable to type 'never[]'.
        Type 'string' is not assignable to type 'never'.
          Type 'string' is not assignable to type 'never'.

How can we solve this massive error message? We need to make a slight change to the callback passed to reduce. TypeScript has correctly inferred that the user argument is of type User (since we are calling reduce on an array of Users), but it can’t find a type for the result array. In such a scenario, TypeScript gives the empty array a type of never[].

To fix this, we can just add a type to the result array and tell TypeScript it is an array of Roles. The error goes away and the build process succeeds.

const roles = users.reduce((result: Role[], user) => {
  return [
    ...result,
    ...user.roles.filter(role => !result.includes(role))
  ];
}, []);

The error message that we received was, while technically correct, difficult to understand (especially for a beginner).

Build performance can suffer

The type checking benefits of TypeScript don’t come without a cost. Type checking can slow down the build process, especially in a large project. If you are running a development server that reloads on code changes, the slow TypeScript build step can slow down your development as you wait for the code to build.

There are some ways around this. For example, instead of the TypeScript compiler, Babel can be used to transpile TypeScript into JavaScript with the @babel/plugin-transform-typescript plugin. This plugin transpiles only; it does not perform type checking.

Type checking can be done separately by running the TypeScript compiler with the noEmit option. This will check types, but will not output any JavaScript. By using a two-step process, type checking can be skipped when you need a fast development server, and can be done as an extra test step or as part of a production build.

It’s not foolproof

When we cast something to another type or use escape hatches, such as the non-null assertion operator, TypeScript trusts us at our word. If I add the ! operator after an expression, TypeScript won’t warn me about cases where it could be null. I might have missed a possible scenario where the value could actually be null and introduce a bug. The earlier warning about a false sense of security applies here as well.

Since this effectively disables type checking, many projects use an ESLint rule that forbids the use of the ! operator.

Take the good with the bad

These benefits and pain points will each resonate differently with different teams. For some, the benefits may outweigh the performance, strictness, and unhelpful errors. Yet, others may not think it’s worth the trade-off.

When encountering a confusing error, some developers may just cast the value as any, which will usually satisfy TypeScript (but at the cost of possible bugs that would otherwise have been prevented). There’s an ESLint rule for that, too. Of course, there are also ways around ESLint.

Using TypeScript effectively requires patience and the discipline to listen to what the compiler is telling you. Taking the time to properly solve a tricky error can be painful, but usually pays off if you stay with it.

On the other hand, if you anticipate using the ! operator, casting as any, and using other tricks to make errors go away, you will not be getting the full benefits of TypeScript. In such cases, you may want to consider if it’s worth making the switch.

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Joe Attardi Software engineer and author focused on frontend/UI topics.

Leave a Reply