You Might Not Need That Dependency

In the last year or so, NodeJS has gained a slew of new features that previously were only available if you installed various dependencies. But are those features sufficient, or does it still make sense to keep using third-party dependencies for some of these features? Let’s have a look.

Let’s go through a number of dependencies that I’ve been using up until recently with NodeJS. For each dependency, I’ll look at why we might need it in the first place, and what the NodeJS replacement looks like. Then I’ll describe some of the caveats that might make removing that dependency hard, and finally try and evaluate how much of a benefit it really is to get rid of that dependency.

TS-Node, TSX

The tsx tool might be the worst named tool in history. Why would you name your command to run TypeScript files after the file extension you use for some of those files? But I digress.

Sometimes you have TypeScript source files, and while developing you just want to execute them directly, without worrying about the compile step. Once upon a time we used ts-node, now tsx is more popular, either way they’re doing essentially the same thing: load TypeScript files, compile them to JavaScript on the fly, and transparently pass them to Node to execute.

It turns out NodeJS can do this by itself now. You don’t even need any special flags — node ./index.ts will run and execute index.ts as± if it were plain old JavaScript.

Caveats

  • By default, this just does type stripping, which means you’re limited to a subset of TypeScript syntax. If you really need enums, or parameter properties, you can use --experimental-transform-types.
  • When you’re importing a TypeScript file, you’ll need to use the correct .ts extension, rather than .js. This requires some configuration for tsc and the IDE to not show red squiggles — you’ll need allowImportingTsExtensions enabled, and potentially rewriteRelativeImportExtensions depending on how you transpile your code for production.
  • You’ll probably still need to do the compilation step when you release your code. NodeJS doesn’t do type stripping at all for packages installed in node_modules, and generally recommends still compiling your code before releasing it or running it in production. NodeJS also doesn’t do any type checking, so you’ll still need that step to make sure that your TypeScript types actually make sense. (Note: while NodeJS doesn’t do type stripping for dependencies, if you’ve got a monorepo setup where local packages end up symlinked into node_modules, that does work as expected — NodeJS first resolves the symlink, then checks that the resolved file is in the right place.)

Is It Worth It?

How easy a change this is will depend a lot on how much you use different TypeScript features. Mostly, I stick to the erasableSyntaxOnly subset of TypeScript, but if you use a lot of enums, you might want to try --experimental-transform-types, and if you use a lot of decorators, you might want to skip this optional altogether.

Getting rid of tsx allows you to get rid of up to 25MB of package space, mostly in the form of esbuild and its various binary dependency packages. However, if your bundler is using esbuild anyway (as Vite does, for example), you might not see so much of a win.

I’d say the best reason to switch to Node’s native type stripping, then, is reducing a layer of abstraction. Rather than running tsx which spawns its own processes and does its own CLI parsing, you’re just running your normal node binary, with all the command line options you’d expect.

TSX (Again), Nodemon

Okay, the other thing you might be using tsx for is tsx watch. You might also be using nodemon, or some other similar option. All of them do the same thing: they watch your application’s source files, and if one of them changes, they restart the current process.

This was added into NodeJS around the same time that type stripping was added, and can be accessed via node --watch.

Caveats

  • Nodemon can do a bit more than just watch JS files, so if you need that, you might be better off going for the dedicated tool.
  • How you specify which files to watch is a little bit different. Nodemon watches the current directory (or any directories you specify). Node’s built-in watcher instead watches for changes in imported files, so you shouldn’t need to worry about specifying which files to watch or ignore.

Is It Worth It?

Unless you’re using the expanded features of Nodemon (and you probably aren’t), this is a super easy switch. You might even be able to get rid of some configuration as well.

Nodemon is a bit of a dependency beast, bring 29 transient dependencies with it, going up to five layers deep. Each dependency is fairly small (although the total is still almost 1MB of space), but it’s still nice to reduce supply chain issues if any of those dependencies were compromised by a malicious actor. Plus quicker install times has never hurt anyone.

Dotenv & Friends

Environment variables are convenient for configuration an application, but when developing, it can be convenient to store those variables in a file and have them loaded automatically. Typically, you might use a module like dotenv, or a CLI like dotenv-cli or dotenvx to handle that, but NodeJS can do that for you to.

NodeJS exposes this in a couple of ways. Firstly, you can use --env-file or --env-file-if-exists to load a file from the CLI. Alternatively, you can use process.loadEnvPath(...) to programmatically load a file at runtime.

Caveats

  • Sometimes it’s hard to pass a specific flag to NodeJS (for example because it’s being run implicitly by another command). In that case, a command like dotenv-cli might still be slightly easier to use, because it’ll ensure that every child process created will automatically get the right environment variables.
  • Some env parsers include fancy logic like variable expansion. If you’re using this, you’re probably better off with a proper configuration file anyway, but if you insist on the .env format, then you’ll need to keep to the fancy parser. Similarly, if you’re handling encrypted secrets, it might be worth using a dedicated tool for that.

Is It Worth It?

dotenv is honestly a pretty simple module — no transient dependencies, 20kB install size, it’s hard to complain too much. That said, 20kB is still larger than 0kB, and there’s not much you’re losing out on here.

If you’ve got more heavy-duty dependency needs (like managing secrets for a team of developers working on different projects), you’ll need something more than this, but then you’ll have needed something more than dotenv as well.

Jest, Vitest, Node-TAP, etc

There are so many test runners out there, and now there’s one more. NodeJS comes with its own built-in test-runner, and it’s a lot more powerful than I initially gave it credit for. The basic functionality is all there, and if you’re familiar with Jest’s describe/it style, there are even aliases for that.

In terms of features, you’ve got various tools for mocking, including mocking timers and modules; you’ve got coverage reporting; you’ve got various tools to filter tests; and even some support for snapshot testing.

I will confess that the documentation is lacking right now. For example, a lot of the documentation uses nested tests — i.e. putting a test('...', () => { ... }) call inside another test block, to simulate suites. But the test runner also supports real suites using the suite or describe functions, and playing around, these produce much better results than nested tests. There’s also some rough edges, like snapshot testing being supported, but inline snapshots not being supported.

Caveats

  • The aforementioned rough edges and missing documentation is the biggest kicker here. Also things like polish and appearance — the failure message in Vitest, say, includes a diff of the expected and actual values and often shows exactly what went wrong, while the failure message in the built-in runner is a JSON object with a bunch of attributes.
  • More than some of the other options in this list, a testing framework comes with a whole ecosystem. If you’re using Jest, you might well be using a bunch of Jest plugins, or have tools that come with explicit support for Jest. Using the built-in runner might therefore require either forgoing some of those tools and plugins, or doing the same thing the plugin would be hand. For example, if you’ve got a bunch of fancy custom assertions, you’ll need to either switch to Node’s standard assertions, or write your own functions that do the custom assertion.
  • The NodeJS test runner runs in Node, unsurprisingly. If you’re running tests in the browser (e.g. with Karma, or Vitest’s new browser mode), you’re out of luck here.

Is It Worth It?

Testing frameworks tend to be bulky, complicated tools because they’re doing a lot of stuff. Vitest, for example, clocks in at 37 packages and over 35MB (again in large part due to ESBuild). But at the same time, testing frameworks do a lot, and there’s a lot of value in a testing framework that makes everything as frictionless as possible.

The built-in test runner can do more than you’d expect, but it’s not quite frictionless yet. I recommend trying it out a bit, but in my own projects I’m generally sticking with Vitest. That said, for small projects that don’t need many dependencies in the first place, it might be exactly what you need. If you’ve been content so far with node-tap, then the built-in test runner will probably cover everything you’ve been doing so far.

Conclusion

In recent months there have been a handful of high-profile attacks via NPM dependencies. That doesn’t mean we should avoid NPM altogether, but it does mean thinking hard about each dependency we use, and deciding whether it really is worth the natural risk that comes with depending on third-party tools.

In this article, I’ve looked at a few examples of places where you might be able to get rid of dependencies altogether by just using NodeJS features directly. I’ve only touched on some of the possibilities — I’ve completely ignored the wealth of new JavaScript language features and libraries that can replace existing libraries, like various new array methods, or the Temporal namespace for handling dates and times.

I hope some of these ideas are useful to you, and more than that, I hope that your supply chain is a little bit safer because of some of these changes.