Finding dead code (and dead types) in TypeScript

Software engineering is a battle against complexity. Without any planning or care, it's easy to build programs where everything interacts with everything else (the "big ball of yarn" model). With a ball of yarn, if you double the number of components, you quadruple the number of interactions:

Complexity increases with the number of interactions, i.e. quadratically

One of the best ways to fight against this ramp-up of complexity is to simply reduce N, i.e. to write fewer lines of code. Using a higher level programming language or depending on well-tested third-party libraries are common ways to do this. But one of the easiest ways is to find code you don't need any more and delete it.

Or, to quote Ken Thompson:

One of my most productive days was throwing away 1000 lines of code.

So how do you find dead code (and dead types) in a TypeScript project? There are a few ways to do it, but my current solution involves the --noUnusedLocals compiler option and the ts-prune tool from Nadeesha Cabral.

noUnusedLocals

First, --noUnusedLocals. This is typically set this in your tsconfig.json file. It's on by default so its behavior should be familiar. In addition to detecting unused local variables in function bodies, it also detects unused symbols at the module level:

function foo() {}
// ~~~ 'foo' is declared but its value is never read.

export function bar() {}

(In VS Code, unused symbols typically appear in a lighter color instead of getting the red squiggly underline error treatment. This is because it's often only a temporary state until you write the code that uses the symbol.)

In this example nothing calls foo or otherwise references it, so it's dead code. And while bar is unused in this module, it's exported, so there's at least the possibility that another module could import it and call it.

Since imported symbols are local variables inside their module, noUnusedLocals will also catch unused imports for you.

ts-prune

But what about exported symbols? They're still dead code if they're unused, but determining whether that's the case is harder. It requires analysis of your whole program and it's a little trickier than you might expect at first blush. To see why, let's first look at an incorrect way to do it.

What if we just look at all the imports in a program and pair them up with the exports? With this approach, an exported symbol is unused if it's never imported.

There are several problems with this approach. Here are a few:

  1. Code can be imported but still dead. Here are three ways this can happen:
    1. A function is only imported by its test.
    2. Two functions call each other (mutual recursion), but are otherwise unused.
    3. A function is only used by other dead code.
  2. An exported symbol can be alive even if it's never imported. This happens if it's used in its own module.

This is all a little easier to understand in picture form:

Dependency graph showing dead code

Dead code is in gray, live code is bold. Code can be dead despite being referenced. Examples are if it's referenced from other dead code (recA/recB), or code that is not relevant for "liveness" like a test (deadA).

I'm saying "functions" here, but the same considerations apply to types and interfaces, too.

The first category of problems (imported but still dead) is reminiscent of a problem in garbage collection: if you do pure reference counting, then you have a problem with cycles, where two objects reference each other but are otherwise dead. The solution there is to start with some known "live" objects and follow all the references from them. This is known as mark and sweep.

We can do something similar with ts-prune. To get the most value out of it, you create a special tsconfig.json file with a list of entrypoints to your program:

{
"files": [
"src/entry1.ts",
"src/entry2.ts"
]
}

I usually call this tsconfig.ts-prune.json. What constitutes an entrypoint depends on your program. For a Node program like a server, it's the file you run (perhaps server.ts or main.ts or app.ts). For a web application, it's the entrypoint you list in your webpack or equivalent config. For a library, it's any file you want your users to be able to import from the resulting distribution (typically index.ts / index.js).

Once you've created that file, you run something like this:

$ ts-prune -p tsconfig.ts-prune.json | grep -v 'used in module'
src/example.ts:4 - bar

The -p points ts-prune at your special tsconfig.json. Since you never import tests, they wind up being ignored for purposes of detecting alive vs. dead code. The same goes for un-imported modules.

The grep filters out the second category of problems from the above list. I have no problem exporting symbols that are never imported (in fact, I recommend it in Item 47 Effective TypeScript: Export All Types That Appear in Public APIs).

(Sidebar: why? Two main reasons. First, you often wind up needing to import them later, and exporting them makes it possible for tsserver to offer auto-import. Second, you're effectively exporting them already if they're part of a public API. You may as well make it easy.)

The output of ts-prune tells us that the bar symbol is unused despite being exported. This is exactly what we wanted to know!

I highly recommend setting up ts-prune for your project. You might find some dead code that you'd forgotten about! It also works great with more exotic setups like media imports and generated code. Two examples of this from my own projects:

  1. My team has a file, icons.ts, that imports all the images in my web app, either from PNGs in my project or from Material-UI, and re-exports them. In this case running ts-prune finds the unused images, which shrinks your bundle! (Tree shaking would help here, too.)
  2. We use codegen to produce TypeScript types from our Postgres schema. In this case an unused export may correspond to a dead database table, which should definitely be dropped!

As with most dead code elimination tool, when you delete some dead code that it's surfaced, you should immediately run tsc and ts-prune again. Running tsc will surface dead references to the dead code (e.g. its tests), which you should delete. In the process, you might have deleted the last reference to something, which will surface even more dead code. Repeat until convergence (or until you have no code left!).

References:

  • ts-prune is a zero config CLI tool by Nadeehsa Cabral. I added the "used in module" output to make it more useful for finding dead code in addition to unused imports.
  • ts-unused-exports is a related tool by Patricio Zavolinsky. I ran into issues with this tool that eventually led me to ts-prune, but you may prefer it if you don't like ts-prune. It certainly has many options!
  • I'm not aware of any tools for finding dead code in JavaScript. But since TypeScript is a superset of JavaScript, ts-prune should, in theory, be able to work on JS projects. I've never tried this, but, if you get it to work, I'd love to hear about it.
Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »