Command Palette

Search for a command to run...

All postsEngineering

Shipping My First npm Package: radar-utils

SB
Sachin Babu
Jul 17, 20269 min read

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:

  • `radar-utils/tle` — NORAD Two-Line Element parser with checksum validation
  • `radar-utils/adsb` — ADS-B 1090ES (DF17) message decoder with CPR position decoding
  • `radar-utils/geodesy` — Haversine distance, bearings, destination point, cross-track distance
  • 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 public

    But 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.y0.(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.

    All postsEnd of article

    Keep Reading

    All Posts →