Element 84 Logo

The JavaScript Cave: Watch Your Step!

01.13.2023

If I had to describe what it’s like to work in JavaScript, I’d have to say, “it’s like the cave scene in Raiders of the Lost Ark.” The language is one booby trap after another. You can never let your guard down. Developers new to JavaScript don’t realize that the language is out to get them, they write code that seems perfectly reasonable, and … it fails. Developers who have used JavaScript for a long time know to watch their step and be very, very careful.

Say you take a string from the user and parse it into a number. Which function do you use? Number(input)parseInt(input), or Number.parseInt(input)? If parsing fails, what will happen? The behavior is not intuitive, and slightly different depending on which function you use.

Number("0A") // => NaN
parseInt("0A") // => 0


Should that "0A" value have actually returned 0? Was 0 really what the user intended, or was that a typo?

Being the conscientious developer you are, you check to make sure you didn’t get NaN back.

if (value === NaN) throw new Error('Not a number');


Even when you specify an invalid input, though, that exception is never thrown. What’s going on?

Reading the docs for NaN, you’ll find that:

  1. When NaN is one of the operands of any relational comparison, the result is always false.
  2. NaN compares unequal to any other value – including to another NaN value. If you want to safely check for that NaN value, you must use the isNaN() function.

Or say you have a simple “increment” function, but you forget to parse the user’s input into a number first.

const increment = (x) => x + 1;

increment(2) // => 3, makes sense
increment("2") // => "21" ???

Other examples:

  • Is null an object?
  • If list[0] === undefined, does that mean that the list is empty, or that the first item in the list is the value undefined?
  • Say you have these two objects: {} and { name: undefined }. How do you check to see if they have a name property? if (obj.name) or if ('name' in obj)?
  • If you catch an error, can you be sure that it’s actually an instance of Error that will have a message property?
  • If your function takes an optional argument, is if (arg) a safe way to test for its presence? What if the value is 0? Or an empty string?

The list goes on and on. These aren’t hypotheticals. I’ve seen almost all of these issues in recent PRs that I’ve reviewed. It isn’t the fault of the new (or even mid-level) JavaScript developer, they just haven’t discovered how many “gotchas” there are in this language.

So, if you have to write JavaScript (and there are a lot of good reasons to do that), how do you do it safely?

There are two tools that are essential for writing safe JavaScript: TypeScript and ESLint. In combination, they can help you detect many potential bugs during development as opposed to runtime.

TypeScript

Using static types can help prevent a lot of subtle issues in JavaScript code. TypeScript forces you to think about those situations where a value might be undefined, but you forgot to check for it. Or, as in the “increment” example above, accidentally passing a string into a function that’s expecting a number. It’s also valuable “documentation that compiles”, but that’s an article for another day. I won’t spend too much time on “why use TypeScript”, there are a lot of good articles that cover that already.

If you’re going to use TypeScript, the best option is to use it from the beginning of your project and write .ts files. You’ll find that the code you write when you’re being explicit about types is going to be different (and I’d argue better) than the code you’d write without static types.

On the other hand, if you have a large JavaScript codebase, then rewriting it in TypeScript may not be realistic. In that situation, using the TypeScript compiler to check your JavaScript code can still help you uncover a lot of bugs. The TypeScript Handbook has an excellent section on how to use TypeScript and JSDocs to type check your .js code. Be prepared, though. It’s going to find a lot.

ESLint

The other essential tool for writing safe JavaScript is ESLint. ESLint is commonly used for enforcing a uniform code style, but what I’m more interested in is its ability to detect bugs as they’re written.

ESLint has a ton of built-in rules. Reading through them is a good exercise, because they point out many of the traps that developers can fall into. The documentation for each rule also shows examples of “good” and “bad” code. The possible problems rules are probably the most important for code correctness.

In addition to the built-in rules, there are a lot of ESLint plugins that provide additional checks. A few that you should take a look at are:

Every project is going to have a slightly different ruleset, but I highly recommend creating a robust ESLint configuration and using it to detect as much as possible.

VSCode integration

Most editors make it easy to add TypeScript and ESLint seamlessly into your workflow. VSCode has excellent TypeScript support, as do most other editors that support the Language Server Protocol.

For ESLint, there is the ESLint extension. It monitors your code as you type and highlights potential issues detected by ESLint. I’d suggest enabling the editor.formatOnSave option as well.

CI integration

TypeScript and ESLint should be part of your CI workflow. Adding ESLint is easy, just run eslint in one of your test steps. TypeScript checking is simple as well using the --noEmit flag. That will check your code and report on any type errors, but will not write any compiled JS to the filesystem. Both ESLint and TypeScript should be blocking steps in your build pipeline, similar to the automated tests.

Incremental adoption

When you first add TypeScript and ESLint to existing codebases, you are going to get a lot of errors reported. I’ve seen over 10,000 reported errors on some larger projects. It can be unrealistic to fix all of them at once, and doing so would also create a high risk of regressions.

One option to help incrementally make your code better, without fixing everything at once, is to track the number of reported errors and only fail the build if the number of errors goes up. If the number of errors goes down, make that the new limit. To help with that, I’ve created two npm packages: tsc-ratchet and eslint-ratchet. These two utilities will “ratchet down” the number of errors allowed so that, over time, your code will trend toward fewer errors.

Conclusion

I have written a lot of JavaScript code over the years, from very small libraries to extremely large distributed systems handling petabytes of data. Even with all of that experience, I am very uncomfortable when I move onto a project that is using JavaScript but does not have TypeScript and ESLint as backstops. That is the first thing I put in place, and it’s often very revealing. JavaScript can be used for creating very robust and bug-free systems, but not on its own. Tread carefully, and use the tools available to keep yourself out of trouble.

This article was originally published in Marc’s Digital Garden.