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 * fromchains, 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-levelrequire()s as imports. - On each side, as that side resolves it: the browser's build (
browser,import,defaultin its export map, or itsbrowserfield) 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). Atypeof windowcheck isn't a use. - What it can't follow: a computed
import()orrequire(),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 viewsays 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 as86400000;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:
- the range used by the codebase definitions the file imports, so a new component beside React 18 components gets React 18;
- else the range used in the file's namespace;
- else the project's own copy, if
node_moduleshas it installed (it's used as is, as before); - else the newest range the codebase already runs;
- 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
lodash3 and code onlodash4 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.