Compile or not compile?

Marek Hanzal
7 min readNov 23, 2023

--

Prepare your hats, this ride will be crazy. It’s all about decisions related to compiling (bundling) your npm libraries.

Photo by Kira auf der Heide on Unsplash

I like topics, which are a bit controversal. I see it more like “out of the box” thinking. You are probably doing the same, but maybe you’re a little afraid to say that aloud, because there are a lot of people who don’t wan’t to listen those interesting ideas.

So here we go.

Let’s Bundle it!

TL;DR; If you want be shocked right away, scroll to next section, this one you already know.

Usual scheme. Understandable. You pickup your favourite tool, choose the output, generate ESM/CJS stuff, npm publish, profit.

This has main advantage your users can simply consume your library without any hassle, so world is nice and shiny.

The burden is on your side to setup your tooling, test if everything works in all cases, struggle with dev mode, watching changes and all the stuff you already know.

Basically, here is nothing more to say, because you all know this. It’s more like a little recap to show the contrast between these two approaches.

Is there something we can do about that?

Prepare yourself, because…

Photo by Vadim Sherbakov on Unsplash

Let’s Source it!

…there is another apprach you can take. Ask somebody, if he knows TypeScript. You’ll get probably positive answer. And this idea is the ground for this apprach.

TL;DR; Idea is to ship only sources to npm (but keep the same usage).

What if you take your sources, transpile them (you need to know everything lints & builds), but publish sources only? This way user of your package is responsible for building/bundling, but it provides smoother experience if there is any modification needed. You just copy the original sources (from the package) to your monorepo and make changes.

Some bullets for you:

  • the best for monorepo development, because when your “internal” packages are not transpiled, you don’t need to watch the changes, wait for IDE to update indexes (yes, that day IDEA was struggeling a lot) and refreshing your app itself
  • you can run tsx with TypeScript sources, but you have to organize your code in the way tsx is not picking up things it does not need or it may fail to start (yes, I’m looking at you, barrel files)
This is translation cli tool called directly by tsx, but it needs an import from deep of the package or it won’t start (because it goes through barrel and takes a lot of stuff it does not need).
  • You’re without duplicate output — ESM/CJS, also bundlers may ignore “use client” (or other) directives.
  • But your client may need to use module transpilation, like Next.js does.

Main advantage is in a your own monorepo, for external npm packages you should think twice, but as an experimental way of thinking it could be interesting to provide lightweight packages (no sources map, no builds) for users as it’s not hard to consume those packages (with today’s tooling).

Barrels

Photo by Mauro Lima on Unsplash

From my point of view, barrel files are probably the worst thing in JavaScript at all. Now take a deep breath. I’ve an argument.

Now I’ll talk in a simplified example, so for now forget about tree shake and advanced module processing

Let’s say you have a package with quite a lot of exports. When you consume such package, everything is parsed and eventually executed. This also blocks you from having “server-side” and “client-side” code next to each other, because import fs from “node:fs” just breaks on client builds.

I feel you saying “tree-shaaaaake”

Yes, but this solves consequence of the problem and many times it does not even work properly, not talking about computing and complexity needed for this feature to work.

But there is a way, how to solve this problem in an elegant way (thanks, Next.js!)

The solution (somewhere in the middle)

Nothing is pure and shiny, even this solution takes some price. But even it’s quite annoying to work like that, I think it’s worth it.

Next.js has a feature called modularize imports. They already superseded it with another option which — at this time — does not work properly in common userland, so I hope they won’t remove it.

What’s cool about that? When you’re creating your own packages, create all the stuff you’re used to. The only difference is you have just one index.ts and one folder exporting all the stuff a package has.

// index.ts in the root of package
export * from "./$export/$export";

This help with IDE which is not screwed up, because you’re exporting everything as usual, but…

// ./$export/$export.ts
// ...some exports...
export * from "./BuildingCollection";
export * from "./BuildingComponents";
export * from "./BuildingConstructionRequirement";
// ...rest of exports

This is a barrel file for all exports your package has; this file could grow large as more exports are added. Also, I can feel some negative thoughts about the naming, but that has a reason too:

Do you see ordering of files? First is $export folder, first is $export.ts file. That’s beause you may edit them quite often and searching for export.ts or whatever name in the middle of packages is annoying.

Big note here: it does not matter, if you’re using source/not-source apprach, but having clean and normalized exports in your package helps your users use only things they need, so tools like modularized imports or optimized imports may work much better.

Why not just use good old index.ts in exports?

Just because of file ordering. It has a little disadvantage, because your IDE will start to give you new set of intentions from $export folder, but if you overlook it, it doesn’t matter so much (because those exports are clean without further barrels).

Last part of this section is requirement to create file with the name of component (or function or interface or whatever) you’re exporting. This is another annoying part, because you’ll do this a lot.

// ./$export/BuildingCollection
export {BuildingCollection} from "../ui/BuildingComponents";

You have to re-export exactly one component from the file with the same name. This is important, because this narrows your internal package structure to this format: /src/$export/ComponentName.ts. That’s important, because if somebody want’s to use only one piece without taking the whole package, it’s possible without knowing internals of your package.

Also, this is prerequisite for…

Photo by Sergio Gonzalez on Unsplash

The Final: Putting strings together

If you’re still here, you may be curious, how the whole thing fits together and how to use it.

Create transpile.mjs file, where are exported package names you want to transpile and optimize. This requires previously created project structure (/src/$export/[ComponentName].ts).

// transpile.mjs
export const transpile = [
'@derivean/building',
];

Update your next.config.mjs with transpiling and modularized imports.

import withPlugins from 'next-compose-plugins';
import { transpile } from './transpile.mjs';

/**
* @type {import('next').NextConfig}
*/
const config = {
// ... some of your config...
/**
* This one is optional, may be required if Next.js fails to build
*/
transpilePackages: transpile,
/**
* This part is important
*/
modularizeImports: transpile.reduce((acc, item) => {
acc[item] = {
/**
* Do you see? Here we're doing the magic to import
* a file from within your package, so barrels are skipped
* at all
*/
transform: `${item}/src/$export/{{member}}`,
skipDefaultConversion: true,
};
return acc;
}, {}),
// ... rest of your config
};

export default withPlugins([], config);

Now you have everything you need. Example usage:

import {
BuildingPreview,
withBuildingRepository
} from "@derivean/building";

Goes behind the scenes into this:

import {BuildingPreview} from "@derivean/building/src/$export/BuildingPreview";
import {withBuildingRepository} from "@derivean/building/src/$export/withBuildingRepository";

So here we are! Barrels are skipped, thus zillions of unnecessary code is not processed, so there is (almost) no tree shake needed. Feel the difference.

What it is good for?

A lot of stuff for quite “offtopic”, but I saw package organization together with bundling as big problem (around 60k big — module count on relatively simple page with Mantine UI and some lib stuff), with this apprach lowering that number to around 25k (still a lot, but much better). Try that on old MacBook from 2019 with 8GB of RAM.

Also bundle sizes will be much smaller.

Ah, bad luck. I’ve tried to make both builds to show you results, but because I’m skipping barrels, I’m not able to build the app without modularized imports.

Sorry.

Photo by Patrick Perkins on Unsplash

The end

This article was about absolutely different way, how to organize your code. It was hardcore try-fail to find this. And it works wery well (I’m using it in production code for a while), so as an experimental feature (in your eyes) just give it a try, because even it has some drawbacks, it’s worth it.

--

--

Marek Hanzal

I love programming and getting new knowledge. I'm fullstack web-dev, quite a crazy man with a lot of things to say.