Every developer I know has a folder somewhere — utils/, lib/, helpers/ — full of code they've written more than once. For me it was radar and satellite tracking maths: TLE parsers, ADS-B decoders, haversine functions I'd copied between three different projects. This week I finally published it as [radar-utils](https://www.npmjs.com/package/radar-utils) — my first public npm package. Here's what I built, why, and everything I had to learn to actually ship it.
The Trigger: Copying Code for the Third Time
I was starting a rewrite of my Overhead app and reached for the ADS-B decoder I'd written six months earlier. It lived in a lib/ folder in another project's repo. I copy-pasted it, forgot about the bug I'd fixed since, and re-introduced it. That was the trigger. A private folder isn't a library. A library is a versioned, tested, documented artifact that survives the death of the repo it was born in.
Scope: What Goes In, What Stays Out
The temptation with a first package is to dump everything. I resisted. Three concerns made the cut:
Everything else — HTTP clients for adsb.lol, satellite propagation via Skyfield bindings, map rendering — stayed in the app. Libraries should do one thing well. Domain-specific plumbing belongs in the app, not the shared code.
Zero Dependencies as a Design Constraint
The moment you add a dependency you own its bugs, its security advisories, its major-version upgrades, and its bundle size. For a utility library, this compounds — anyone who installs your package inherits the whole tree.
I made zero-deps a hard constraint. It forced better design. Instead of pulling in decimal.js for floating-point safety, I documented the precision guarantees. Instead of buffer polyfills, I used Uint8Array and got browser compatibility for free.
// no deps — pure Uint8Array parsing
export function decodeMessage(hex: string): AdsbMessage {
const bytes = hexToBytes(hex);
const df = bytes[0] >> 3;
if (df !== 17) throw new Error(`Expected DF17, got DF${df}`);
// ...
}The tree that installs when someone runs npm i radar-utils? Just my package. That's the win.
Dual ESM + CJS With tsup
Modern Node is ESM-first, but plenty of tooling and older codebases still need CommonJS. Shipping both is table stakes for a well-behaved library in 2026, and tsup makes it a one-line config:
// tsup.config.ts
export default {
entry: [
"src/index.ts",
"src/tle/index.ts",
"src/adsb/index.ts",
"src/geodesy/index.ts",
],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,
treeshake: true,
};That produces .js (ESM), .cjs (CommonJS), and .d.ts (types) for each entry. dts: true means TypeScript definitions are bundled by tsup itself — no separate tsc pass. treeshake lets bundlers eliminate unused exports.
The exports Map: Subpath Imports That Actually Work
The exports field in package.json is the most under-taught feature of modern npm. It replaces the ancient main / module / types fields with a structured, per-entry map:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./tle": {
"types": "./dist/tle/index.d.ts",
"import": "./dist/tle/index.js",
"require": "./dist/tle/index.cjs"
},
"./adsb": { ... },
"./geodesy": { ... }
}
}Two things this buys you. First, users can do import { parseTLE } from "radar-utils/tle" and only pay for the TLE bundle. Second, unlisted paths (like radar-utils/dist/internal/private-helper) are hard-locked — nobody can reach into your internals. That's an API contract enforced by Node itself.
Testing With Vitest Before Publishing
I refuse to publish untested code. vitest is the modern default — Jest-compatible API, faster startup, first-class TypeScript, and it works with ESM without ceremony.
// tle/parse.test.ts
import { parseTLE } from "./parse";
test("parses ISS TLE with correct epoch", () => {
const iss = parseTLE(sampleTLE);
expect(iss.satelliteNumber).toBe(25544);
expect(iss.inclination).toBeCloseTo(51.6416, 4);
});
test("rejects invalid checksum", () => {
expect(() => parseTLE(badChecksumTLE)).toThrow(/checksum/i);
});The prepublish hook wires it all together so I can't accidentally ship broken code:
"scripts": {
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
}If typecheck fails, tests fail, or the build fails, npm publish never runs.
sideEffects: false — Free Tree-Shaking for Consumers
One line in package.json — "sideEffects": false — tells bundlers your package has no top-level side effects, so any unused export can be stripped from the final bundle. For utility libraries this is huge. Someone importing just haversine from radar-utils/geodesy doesn't ship your TLE or ADS-B code.
The Publish Ceremony
Actually publishing was anticlimactic:
npm login
npm publish --access publicBut two things bit me first. Scoped-vs-unscoped: I originally wanted @sachin/radar-utils but scoped packages default to private, requiring the paid tier. --access public fixes it if you really want a scope; I went unscoped instead. And 2FA — npm requires TOTP by default for publish, which is good, and npm publish prompts for the code inline.
The First-Version Trap
I published 0.1.0, not 1.0.0. This matters more than it sounds. 1.0.0 signals "stable public API." Every future breaking change costs a major version bump and downstream migration work. 0.x says "the API may still shift" — every 0.x.y → 0.(x+1).0 is allowed to break things. I want six months of real usage before I commit to 1.0.0.
Semantic versioning is a promise, not a decoration. Break it once and users don't trust you.
What I'd Do Differently
Set up CI on day one. I built and tested locally, then published. A GitHub Action running the test suite on every PR — before there's a PR — sets the standard for later contributors.
Write the README before writing the code. I did this partially. When I did, the API came out cleaner because I was designing for the consumer, not the internals. When I didn't, I ended up refactoring the public surface post-hoc.
Add JSR alongside npm. [JSR](https://jsr.io) is the newer TypeScript-first registry, and dual-publishing is trivial. Future me will thank present me.
The Meta Lesson
Publishing a package is a forcing function for quality. Nothing exposes lazy code faster than knowing a stranger will read it, import it, and file an issue when it breaks. The specific mechanics — tsup, exports maps, semver — are learnable in an afternoon. The mindset shift — from "code I wrote" to "artifact I own" — is the real upgrade.
radar-utils is 186KB unpacked, zero dependencies, and does exactly what it says on the tin. If you're tracking aircraft or satellites in TypeScript, [give it a spin](https://www.npmjs.com/package/radar-utils). And if you've been sitting on your own utils/ folder — this is your sign.