npm packages and Node builtins — tcm-ts

8. npm packages and Node builtins

Import a package; that's all. There's no npm install and no package.json to keep up to date: tcm provides the package, with its types, from its own cache.

// tcm: namespace time
import ms from "ms";

/** A duration as words: 90000 → "2m". */
export const short = (millis: number): string => ms(millis);

short(90_000);
$ tcm check
scratch.ts → time
  + new       time.short : (millis: number) => string
installing ms@2.1.3
installing @types/ms@2.1.0

    7 │ short(90_000)
      ⧩ '2m'

Your editor sees the same package: tcm's VS Code extension resolves a scratch file's imports where tcm does.

8.1 What a package's code does

tcm reads a package's code as it reads the codebase's own, so the same rules decide where code using it runs and whether it's pure. It's worked out for each export a definition uses (not the whole package), from the files the package has installed:

  • Through everything the export uses: the top-level declarations it refers to in its module, the package's other modules, export * from chains, and the packages those import, each in turn. A module's top-level code counts too: importing it runs it. CommonJS is read whole, its top-level require()s as imports.
  • On each side, as that side resolves it: the browser's build (browser, import, default in its export map, or its browser field) and the server's (node, import, default).
  • What it finds: what it reaches outside (the clock, randomness, the network, files, the environment, other processes, output, by the same tables as the codebase's code), Node's modules and globals, the browser's globals, and state: a top-level binding something assigns again, or changes in place (push, set, a property). A typeof window check isn't a use.
  • What it can't follow: a computed import() or require(), eval, a module it can't find. Then tcm doesn't trust the rest: that export is treated as before, unseen.

What it's used for:

  • Where code runs: code using an export whose browser build imports Node's modules runs on the server, and tcm view says which export, and which of Node's modules it reaches. An export that can't be followed falls back to the package as a whole: server-side if it has no browser build and its entry imports Node's modules.
  • What's pure: an export that reaches nothing, holds no state and uses no browser globals is as good as the codebase's own code: a constant computed with it is computed once (ms("1d") is kept as 86400000; startOfToday() from date-fns reads the clock, so it isn't), and a server component using it can be kept.

What's worked out is kept in .tcm/cache/npm-facts, by the package's installed version (which never changes), so each export is read once.

8.2 Which version a scratch file gets

Each package a scratch file imports gets a compatible range from the code around it:

  1. the range used by the codebase definitions the file imports, so a new component beside React 18 components gets React 18;
  2. else the range used in the file's namespace;
  3. else the project's own copy, if node_modules has it installed (it's used as is, as before);
  4. else the newest range the codebase already runs;
  5. else the newest release.

What you save records that range. Other code keeps whatever range it was written against, so two apps in one codebase can be on different majors of the same package.

8.3 Versions

When you add a definition that imports a package, tcm records the package's compatible range, as semver defines it: the major version (lodash 4.17.21 → npm:lodash@4), the minor below 1.0 (0.3.7 → 0.3), or the exact version for a prerelease. The range is part of the definition's hash:

$ tcm deps time.short
npm:ms@2#default

So a definition says what API it was written against, not which patch release happened to be installed. Code written against ms 2 is shareable with any other code on ms 2, in this codebase or after a pull into another.

The codebase runs one version per range. Its lock (in codebase.sqlite) records the exact version for each package and range, so everything on React 19 runs on the same React 19.x:

  • Adding code checked against a newer version in the range moves the lock up to it (newer only adds, by semver).
  • A range nothing has locked yet gets the newest version in it whose peer dependencies accept what's already locked: React DOM at a version that matches the React it's used with.
  • A new major is a different range: code on lodash 3 and code on lodash 4 run side by side, each on its own version.

A locked version is one tarball. The first install records its integrity (the registry's sha512 of the tarball) in the lock, and an install that gets different bytes for that version is refused. Pushing and pulling carry the lock entries for the code they send: received code runs on the versions, and the exact tarballs, it was written against. A range the receiving codebase doesn't lock yet takes the sender's version; within a range both lock, the newer one wins.

Each locked version is installed into .tcm/cache under an alias for its range (npm__ms__2), separately from your node_modules. tcm uses pnpm when it's installed, which keeps one copy of each exact version, peers included, and npm otherwise. A package without types of its own gets its DefinitelyTyped types (@types/react) installed beside it, at the newest version for the same range.

Only the package you import is pinned, not the packages it depends on.

8.4 Node builtins

node:fs, node:path and the rest import as usual and aren't versioned. Using one marks code as server-only in a web app.

8.5 Seeing and upgrading packages

tcm packages lists what the codebase runs (in VS Code, the Packages view, with a page per package showing its README and API; see editors): each package and range, its locked version, and how many definitions use it.

$ tcm packages
ms         2   2.1.3    1 definition
react      18  18.3.1   2 definitions
react      19  19.3.1   5 definitions
react-dom  19  19.3.1   1 definition

Within a range, tcm upgrade <package> moves the lock to the newest version in each range the package is used at. Packages built against it (those listing it as a peer dependency: React DOM for React) move along to versions that accept it, and so do their types. No hash changes, since definitions pin the range; everything that uses them is typechecked again, and if anything breaks, the lock goes back where it was (--force keeps it).

$ tcm upgrade react
  react 19  19.3.1 → 19.4.0
  react-dom 19  19.3.1 → 19.4.0
  6 definition(s) re-checked; no hashes changed

To another range, tcm upgrade <package>@<range> [namespace] (or @latest) re-pins the definitions that use it, in that namespace or everywhere, as an update would: they're re-hashed, dependents follow, and whatever doesn't typecheck on the new version goes to tcm todo and keeps its old one. Packages built against it are re-pinned to a range that accepts it (React 18 brings React DOM 18). Code you don't upgrade stays where it was, so one namespace can move to a new major while another stays.

8.6 Types

A package without types of its own gets its DefinitelyTyped package (@types/react for react) installed beside it, at the newest version for the same range, so scratch files, generated modules and re-checks see its types. @types/node comes with tcm, so Node's builtins are typed out of the box. A package tcm provides that has no types anywhere (no @types package either; most ESLint plugins, say) is typed as any, with a warning: there's nothing you could install to type it. A package installed in the project itself is checked as usual.

8.7 Packages in web apps

Browser code can use packages too; Vite bundles them. A package that only works on Node (no browser build, and it imports Node builtins) makes the code using it run on the server.

An app can't mix ranges of a package others are built against. One page runs one copy of each package range, and two copies of React (say) break it: packages built against React expect the one they were given. So when an app's browser code would use two ranges of a package some other package lists as a peer, the app's plan is an error naming the definitions on each range, and tcm upgrade <package>@<range> <namespace> moves one side over. Two ranges of any other package are just both shipped, with a warning.

8.8 Running things

tcm run <name> [args...] runs a definition: functions are called with the arguments (as strings, as on any command line), the result is awaited and printed.

built from c482a14e