Swatinem Blog Resume

DX on Small Projects

My Opinion and how I manage my own projects

— 7 min

Continuing my series, I will take a look at what tools and workflows I use to manage my small projects. I will also explain some of the very opinionated guidelines that I follow.

We will specifically talk about code that will be published, and can be consumed publicly by anyone. This has some implications on the structure of the code.

# Maintaining a public API

I do have quite a strong opinion on bundling and how to best publish / expose code that you write, which has implications on how you consume that code.

Writing a small and focused library means that you should ideally have only one, or very limited and explicit set of entry points.

The problem is that in theory, people can just import any file that is included in an npm package. And others will start to begin relying on internal implementation details they really shouldn’t. And they will complain if you break things by re-organizing your internal code.

People will just happily import { SomeInternalClass } from "your-library/some/internal/file".

# Bundling Code

One way to avoid this is to bundle your code, which I highly recommend everyone should do. I am a big fan of, and an early adopter and contributor to rollup, and one project I would like to show off to highlight some of my recommendations is rollup-plugin-dts, which you can use also bundle up TS type definitions alongside your code.

The README of rollup-plugin-dts shows a clear example of how to best use it.

# Managing Dependencies

I am still surprised how often people get this wrong. Or how little thought they put into it.

For example, there are multiple sources out there that explain why libraries should not pin their dependencies, but rather delegate that choice to the users of that library.

Another important thing to understand is the difference between direct dependencies and peerDependencies. The yarn blog has a good article about that. TLDR: When your libraries users should not care or even know, put it into dependencies. If your library is used alongside or together with some other dependency, put it into peerDependencies. For example, rollup-plugin-dts put both rollup and typescript into peerDependencies, because it can’t work independently of those two, and someone using rollup-plugin-dts will have to use the other two as well.

Something else I see quite often, which I think is just wrong is that some libraries are putting @types into their dependencies. This is only valid for other @types packages!

Why? Because @types should never ever be used in production. They are by definition devDependencies. Just because users of your-library happen to also use typescript and are getting typechecking errors because they are missing @types/node does not mean that @types/node belongs into dependencies. The code will run in production without that!

# Support Targets

A little bit related to dependencies. I would recommend to people to publish code in the most recent JS dialect possible, that you can run natively in the most up-to-date runtime. Make your users pick a support target. Don’t force transpiled code or polyfills on your users. A short test showed that not transpiling async/await but rather using it natively cut down the bundle size by ~10%, but more importantly, it cut the startup time of the code by ~25%.

This sadly is one of the disadvantages of JS being a language that relies on a runtime. :-(

When talking about a target, I would also encourage people to publish code that targets a standard module system, by which I mean native import/export syntax. That way the user of your library has the choice how to best consume it, such as by bundling it with the rest of their code.

Sadly though, this goal is at odds with being able to run that code in node natively sadface. One solution is to publish the code both as commonjs, and as native modules, which however is also at odds with using deep import paths, which I previously argued you should avoid anyway :-).

But the takeaway is to publish code in a way that is friendly to bundlers. Which also has implications on the dependencies, which also need to be friendly to bundlers, which sadly most code still is not.

# Testing and Linting

Todays post is about small libraries, such as rollup-plugin-dts, of which I wrote, say ~98% myself. This means I don’t really need a complex linting setup, apart from format on save that the IDE provides. I will focus more on linting in a future post.

Being a small and focused library also means its easy to test, which I usually put quite some effort into doing, as close to 100% coverage as possible.

For this I use jest in combination with ts-jest. Being small also means that the convenience of having a single command to both typecheck and test my code including code coverage outweighs the disadvantage of that workflow being slow. Running the testsuite takes around ~6 seconds for rollup-plugin-dts and maybe ~10 seconds for intl-codegen. The slowness probably comes from the fact that both tools do typechecking using TS as part of the tests themselves, rather than the tooling itself.

One nice thing about software engineering itself is that you are constantly challenged, and you need to re-evaluate all your decisions and opinions all the time, which makes you grow. There is a saying that if you are not somewhat ashamed of your own code you wrote a year ago, you didn’t really grow as a developer. But I digress.

So, while yes, I do like jest for its convenience and especially for its expect matchers and snapshot testing, I also dislike it at the same time. It is an overly large behemoth that tries to do too much, and creates a lot of problems doing so.

One example is buggy handling of TS code, because well jest aims to support TS out of the box, but fails to do so ever so subtly. And while it supports file-level mocking, the way it does that is not always obvious and can lead to quite some surprising problems. You can mock the local ./send.ts file by creating a ./__mocks__/send.ts, but it will then also use that mock for import X from "send" in a completely different part of your codebase. This was surprising. But once I figured it out, it also kind of explains why jest spews tons of warnings when you have two mocks named ./__mocks__/index.ts in different parts of your codebase.

Learning from this, I would recommend to just avoid file based mocking. I will also re-evaluate my opinion about having a test runner running your tests. Maybe it would be a better idea to build a testsuite as a dedicated executable that you can run, which explicitly uses a testing library internally for organizational purpuses, a concept that for example zora advocates. I grew quite wary of tools that force you to organize your code in a certain way.

I think I will experiment with this concept in rollup-plugin-dts and intl-codegen in the future.