DEV Community

Cover image for Getting rid of your dead code in ReScript
Gabriel Nordeborn
Gabriel Nordeborn

Posted on

Getting rid of your dead code in ReScript

Exploring ReScript's tools for eliminating dead code, keeping your repository clean and without unnecessary distractions.

Dead code is code that's left in your repository but never actually used in your application.

Other than occupying unnecessary space and adding to compilation times, dead code can also be distracting and confusing when you look at it thinking it's used in the application. Like when bending the new thing you're building to work with an existing abstraction that's not in use, and should be deleted instead. Or refactoring a component to work with something new you're working on, but the component itself is unused in the application and should be removed.

ReScript has some quite advanced and convenient tools for dealing with dead code. In this article we're going to explore how you can get started using them to keep your repository clean and free of the potential distractions of left behind dead code.

We'll explore dead code analysis in ReScript by running the analysis on a real, fairly large production code base that's in active development. This will uncover concrete and interesting real life examples of how the analysis can help remove dead code and reduce the complexity of the code base.

Let's get started!

Dead Code Analysis

Dead code analysis is about tracking types and values throughout your program at compile time, figuring out how (and if) they're actually used. Depending on your language and type system, this can be quite tricky, and in many cases impossible to get 100% right.

Whether some piece of code is dead or not can be subtle. Simple cases are easy - a function that is never called, or a value that is never used. The more complicated cases however are much harder. What about record fields that are never read? Or variant cases that are handled in your code, but never actually constructed at runtime?

In ReScript, we're lucky. A bunch of language design decisions and language features come together in a way that makes dead code analysis both effective and accurate.

Baked into the ReScript VSCode extension is the ReScript Code Analyzer. This code analyzer (also called reanalyze) is an advanced statical analysis tool for ReScript built by core team member Cristiano Calcagno. Cristiano has an extensive background in statical analysis, both in academia and in practice.

The code analyzer works by continuously analyzing your entire code base. Any issue found is reported right in the editor's "Problems" pane. It's easy to start, easy to stop, and is really fast given the amount of work it's doing.

Getting started

Enough background, let's get started! You'll need version >=1.8.2 of the VSCode extension.

You activate the analysis in the ReScript VSCode extension by running the command > ReScript: Start Code Analyzer (Cmd/Ctrl + P brings up the command palette).

Stopping the analysis is a matter of either running the command > ReScript: Stop Code Analyzer, or clicking the "Stop Code Analyzer" button in the status bar that's visible when the code analyzer is active.

Once you've started the code analysis, you should see the "Problems" pane being populated with various warnings and errors related to dead code.

Example of the VSCode extension "Problems" pane being populated with issues related to dead code

It's possible to do fine grained configuration of the analysis if you'd like. You can configure things like suppressing reporting for entire sub folders, and so on. Quite useful, and we'll cover configuration of the analysis at the end of this article.

Examples from real life

Let's look at some concrete cases of dead code that the analysis is finding in the real production code base we're looking in, and how fixing them helps us clean up the code.

Record fields that are never used

Since records are nominal, each defined field will always be available. But what about fields that are never actually read? Let's look at an example.

There's a CheckBoxList.res file. This file has a React hook that returns a record with a bunch of helpers for dealing with the state for a list of check boxes. The return type looks like this:

type useReturn = {
  items: array<item>,
  toggleItemChecked: string => unit,
  setCheckedOnItem: (string, bool) => unit,
  checkAll: unit => unit,
  uncheckAll: unit => unit,
}
Enter fullscreen mode Exit fullscreen mode

Running the analysis tells us: useReturn.toggleItemChecked is a record label never used to read a value

So, toggleItemChecked is never actually used by any of the code using the hook. We can safely remove that. And removing that allows us to remove the underlying code for the function, getting rid of a little bit of complexity in that hook. Nice.

Variant cases that are never used

What about variant cases that are defined, accounted for the in the code, but never actually used in the application? Let's look at a concrete example.

There's a React component called <Menu />. It renders a pop-over menu. The menu takes an array of item, and the type definition of these items look like this:

type textType =
  Text(string) | TextWithIcon({icon: React.element, text: string}) | Render(React.element)

type href =
  | Href(string)
  | Render(HeadlessUi.Menu.itemChildrenRenderProps => React.element)
  | Callback(unit => unit)

type item = {
  disabled: bool,
  text: textType,
  href: href,
}
Enter fullscreen mode Exit fullscreen mode

Essentially, we have an item that can have text to render the text for the menu item, and a href to deal with rendering the link for the menu item.

As we run the analysis, two things are reported:

  • textType.Render is a variant case which is never constructed
  • href.Render is a variant case which is never constructed

Interesting! It appears that Render, which is an escape hatch in this component for essentially "rendering what you want" for text and linking, is never actually used in the code base. That means there's no place in the entire app that's configuring a menu item to use Render for text, or for href.

So, I can remove those two variant cases. However, removing them per se isn't the nice thing - it's that I can now remove the code in the component that handles Render. And in this specific case, removing the code handling Render meant being able to reduce the complexity of the component quite a lot.

Notice the level of granularity in the analysis that the code analyzer is capable of doing. It's finding a variant case, and making a distinction between that variant case being consumed (which it is inside of the component, since we account for it as we're rendering) and it being constructed, meaning someone actually using that specific variant case somewhere in the code when defining menu items. Quite powerful.

Unused parts of React state

This one was quite interesting. The app has a form for changing password for the user, ChangePasswordForm.res. That form has its state defined like this:

type validationState = Idle | Invalid | Valid

type state = {
  oldPassword: string,
  newPassword: string,
  newPasswordRepeated: string,
  validationState: validationState,
}
Enter fullscreen mode Exit fullscreen mode

It then uses a reducer to manipulate that state. Notice validationState here. This was all hooked up nicely in the form - validation states were produced as the form was changed, and so on. However, dead code analysis tells us a different story - validationState is a record label never used to read a value.

Hmmm... It turns out that while the component does produce the right validation states for the various scenarios, it's never using the validation state anywhere. It's just setting it.

Apparently producing the validation state was part of how the form used to work. But, it was refactored at some point to not use that validation state, and rather just look at if things were valid via another mechanism on submit. Someone just forgot to remove it from the state and the surrounding logic for producing it.

This, while rather small, is a prime example of distractions and the potentially dangerous confusion that can come from dead code. You won't catch that the validation state isn't used by just looking at this component quickly. And if you don't catch that, refactoring or building out this component means you're likely to take the validation state and its logic into account.

And that might cause you to build a more complicated solution for the thing you're doing, slow you down, and so on. Dangerous.

Unnecessarily exposed functions in an interface file

DrilldownTarget.resi has two reports:

  • MetricParam.parse is never used
  • MetricParam.serialize is never used

These both indicate that for the interface file, parse and serialize from the MetricParam sub module is exposed to the outside world, but nobody is actually using them. So, it's safe to remove them from the interface.

A small thing, but helpful to avoid unnecessarily exposing them.

But what if you do want to expose them even if nobody is currently using them? We'll cover how to tell the code analyzer about that later on.

Regular dead code

It's also finding quite a few functions that are just never called.

RouterUtils.res has a bunch of helpers around constructing and handling URL:s. The analysis is telling us the following:

  • routerUrlToPath is never used
  • routeUrlStartsWith is never used

Removing them uncovers a number of other helpers in that file that are no longer needed. The end result is a much thinner RouterUtils.res.

Unused React components

It's finding a number of unused React components. Some of them I want to keep because I know they'll be used in the future (more on how to tell the code analyzer below). But, many of them can be removed. They're old and outdated. It's just that nobody had removed them.

Living with the dead

Sometimes removing the dead code isn't what you want. It might be code that happens to be dead at this moment, but that you know will be used later. Or code that while not used right now still serves a purpose in terms of being illustrative of a particular use case. And so on.

The code analyzer understands 2 annotations for suppressing dead code reporting at a more granular level. These are @live and @dead.

We're going to talk a bit about the difference between @live and @dead soon. There's some subtle nuance between them. But, first let's look at a number of examples of how you can add these annotations.

// `Small` is never constructed in the code base as of now, but I know it will be later, so I can annotate just that variant case
type size = XSmall | @dead Small | Medium | Large

// `age` is never read in the code base right now, but I know it will be eventually, so I don't care that it's considered dead as of now
type user = {
  name: string,
  @dead age: int,
}

// This entire type isn't used right now, but I need to keep it around regardless
@live
type pet = {
  isCat: bool,
  owner: user
}

// This function also isn't used right now, but I want to keep it around regardless of that
@live
let getUserName = user => user.name
Enter fullscreen mode Exit fullscreen mode

Notice how you can add these annotations at a granular level.

So, with that out of the way - what's the difference between @live and @dead?

@dead - when something is dead, but you intend to fix it

Adding @dead to something will suppress dead code reports for that piece of code. If that code comes alive (as in it's used again), the code analyzer will tell you that the piece of code you've marked as dead is actually alive now.

This is useful because it'll allow you to remove dead code suppressions when they're no longer useful.
over.

@live - when something is potentially dead but you want to keep it around regardless

The @live annotation will also suppress dead code reports, but it won't tell you if the thing you're suppressing becomes alive again. Additionally, it'll also keep anything using it alive. It'll essentially force the dead code analysis to behave as if the code is alive for real.

Configuration of the analysis

You can configure the analysis in bsconfig.json. Let's examplify by looking at the configuration for the project we just ran the analysis for. Here are the relevant parts of bsconfig.json:

{
  "reanalyze": {
    "analysis": ["dce"],
    "suppress": ["src/bindings", "src/stories", "src/routes"],
    "unsuppress": [],
    "transitive": false
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's go through what we're configuring.

analysis

This tells the code analyzer what analysis you want to run. dce means Dead Code Elimination, and is what we'll be focusing on in this article. There are more analysis you can activate (most notably exception). We're going to cover them in later articles.

suppress

This lets you suppress dead code reports for entire subfolders and specific files in your project. Note that it's just suppressing the reports, the analysis still takes place. So a suppressed folder might be keeping code alive in a folder where you do want reports.

Useful for ignoring dead code in places where you don't care, or in places where you can't fix the dead code anyway (generated code being a classic example).

We're ignoring our bindings folder with hand rolled bindings. We're also ignoring both stories and routes because those contain things that are either a) mostly generated, or b) irrelevant that they're dead. For example, Storybook stories are considered dead because they're never actually used in the main app. But we don't mind that.

unsuppress

There's nothing configured here right now, but this entry allows you to unsuppress reporting for specific files in the suppressed folders. Handy for certain cases.

transitive

This one is important, because it controls whether code that's transitively dead is reported or not. "Transitively dead" is a mouthful. It essentially means code that's dead because all of the code that's using it is dead.

Setting this to true means you'll immediately see all the dead code in your repository. That's useful for certain scenarios, like overviewing how much dead code you have in total. However, it's not very user friendly, because you won't know the full story of why the code is dead. So, it's not technically dead by itself because other code is still referencing it, but it's considered dead because the code referencing it is dead itself.

The default is false. That's most useful for when you're going to be working with removing your dead code. Doing that means that you'll only get reports of dead code for things that is dead by itself, and not because everything using it is dead.

That is useful because you'll be able to remove the dead code that's reported, and your program will still compile after. You'll be able to remove "layers" of dead code at a time.

This is important for a number of practical reasons:

  • It makes it easy to trust the tool - your program will still compile after removing the code the tool is saying is dead
  • It makes it easy to remove dead code incrementally. This is very important, because this means you can clean up your repository little by little, over time. Much easier than being faced with all the dead code at once, with no clear indication of what to remove first
  • It helps you not get overwhelmed. Even if you have thousands of cases of dead code, a large majority of them are probably going to be dead only because the code using them is also dead, and you couldn't remove them in isolation anyway. This approach makes sure you don't even need to think about those cases until they're relevant.

Closing thoughts

This type of functionality is in my opinion one of the unique selling points of a language like ReScript. Fine grained, accurate and efficient dead code analysis. It's possible because of the design and simplicity of the language. And because of the type system.

A great example of using the language's strengths to build something pragmatic and useful in the real world. You need help from the computer to do these types of things. Over time it's impossible to not accidentally miss removing code that's no longer in use. Especially in the more complicated cases, like we saw in the example of the form with with the unused validation state.

Tools for dealing with these things need to be pragmatic. What are we trying to get done? Dead code can exist for various reasons, and like we've seen it's not always the right decision to remove it. That's why our focus is on building tools that help you improve your code base and situation one small step at a time.

There, you know all about dead code analysis in ReScript now! Go try it in your own code bases and see what comes out.

Thank you for reading!

A special shout out to Elm review which has inspired how reanalyze reports code that's transitively dead.

Top comments (1)

Collapse
 
vasco3 profile image
JC

does It work well with monorepo?