18. Importing existing projects
tcm import <dir> --into ns [--exclude a,b] [--update] [--tests] [--mirror] [--verbose]
Each file becomes a namespace: core/src/analyze.ts → ns.core.analyze (src path segments and index file
names are dropped). Imports between files, barrels and workspace packages resolve to hashes.
- Test files are skipped:
*.test.ts,*.spec.ts,*.test-d.ts, andtest,tests,__tests__,dist,buildandnode_modulesdirectories. --testsimportstestandtestsdirectories too, for tests written as test definitions (exports namedtest…, plus whatever helpers they use) rather than as calls to a framework'stest().*.test.tsfiles in them are still skipped, so a thin runner there can register the same definitions with another framework.tcm testthen runs them, cached by hash.--excludetakes paths relative to<dir>, comma-separated, e.g. test helpers that need a dev dependency.- All problems at once. Import reports every problem in the project in one pass and imports nothing until there are none.
--updatere-imports a project whose files changed, astcm updatewould.
Compiler options for imports are described in compiler options.
18.1 Mirrors
--mirror keeps the imported directory in step with the codebase: the codebase is the truth, and the files are
how it reads in git and in editors that don't know tcm. Changes made in tcm are written back to them as edits,
and the rest of each file stays as written (comments, import style, barrels):
- An edited definition (
tcm edit, thentcm update; a merge; a pull) is replaced where it is in its file. What it refers to is spelled the way the file already spells it (ts.Nodethrough a namespace import,parsethrough a named one); imports it needs are added, and ones it stopped using are removed. - A new definition goes at the end of its namespace's file (a
.tsfile becomes.tsxwhen JSX comes to it, and imports of it follow); a removed one is deleted. What a file comes to import from another (anything in the codebase can be imported) is exported there. A namespace no file is for yet gets one, where the mirror reads it back as that namespace: next to a sibling namespace's file, in its parent's directory, or by the namespace's path. - A rename (
tcm mv) is made at every reference, as an editor's rename would: named imports, namespace imports (math.twice), shorthand properties ({ double: twice }), re-exports. It reaches the files near the mirror that use the name too (the rest of its git repository: scripts, tests, an editor extension), though only the mirror's files are checked. A function's or class's name is part of its hash (code sees it as.name), so the codebase then takes the renamed code. - A move to another namespace (
tcm mv app.math.half app.extra.half) moves the declaration to that namespace's file. Imports of it follow (one that brought others too is split), there and in the files near the mirror; the file it left imports it if its code still uses it, and the file it went to stops. Moved and renamed at once, it's moved, then renamed like any rename. A namespace import (math.half) can't follow: tcm says so, and the files stay as they were. - Everything else (Markdown, images, configuration, scripts, hidden files) is files of the
codebase, named by their directory's namespace and their own name, and written back as it changes. In
a git repository, that's what git keeps: what it ignores (
node_modules/, builds) isn't the codebase's, nor is the mirror's own record of releases (tcm-releases.json). So a whole repository can be mirrored, and its codebase is all of it. A directory with a.tcm-filesfile in it is files of the codebase, all of it,.tsfiles too (examples, fixtures): kept as written, not read as code; the mark's lines are patterns of what to leave out (*.vsix). A file whose name can't be a name (a dot before its extension) is reported, and nothing is imported until it's renamed or left out. - Code that imports a file (a page, an image) is written importing it as tcm does, from
#codebase/…(files are values). Plain TypeScript can't import a page, so such files work through tcm: the project'spackage.jsonmaps#codebaseto the codebase's shim ("#codebase/*": "./codebase/.tcm/codebase/*.ts"), with the codebase in the project, as tcm's own repository has it. Without that, the files don't check, and the change isn't written to them. - Dependents re-hashed by an update, and packages upgraded, don't change any text: their files stay as they are.
- The files are one branch's: the branch the mirror was linked on (
main, usually). A session on another branch neither writes to them nor takes their changes; its work reaches them when it's merged there. - The files' style is kept: a mirrored codebase stores code as written (not reformatted), and
tcm editgives it back that way. Code edited in a scratch file is stored as its file spells it once it's written back, so a scratch file's spellings (import nodePath from "node:path") don't reach the files later.
After writing, the files are analyzed again and must hash to what the codebase holds; if they don't, they're put back as they were and tcm says why. When nothing's text changed (an update re-hashed dependents only), there's nothing to check.
The files can change outside tcm too: edited by hand, or by git (a pull, a checkout, a stash). Each command imports what changed before it runs, so it sees the files as they are:
- A definition changed in the files is updated in the codebase, as
tcm import --updatewould. - One gone from the files goes from the codebase.
- One the files haven't changed stays as the codebase has it, even if the codebase changed it meanwhile; that change is then written to the files. A definition changed in both keeps the files' change, and tcm says so.
- Files that don't import yet (an edit half made) wait: the command runs without them, and nothing's written to them until they import.
What importing them says goes to stderr, so a command's output (tcm run) is its own. tcm import <dir> --into ns --update does the same by hand. tcm mirror --check compares the files and the codebase; tcm mirror --unlink stops writing back.
18.2 Verified projects
These have been imported, verified (tcm verify: every definition renders and re-hashes to itself) and run from
a scratch file:
| project | definitions | notes |
|---|---|---|
| neverthrow | 51 | strict: false, kept as its check profile |
| ts-pattern | 247 | namespace imports (P.infer<…>), declare const |
valibot (library/src, --exclude vitest) | 941 | 573 files in 4s; module state, @ts-expect-error above overloads |
Effect 4.1.2 (src of its npm package; the example) | 15,671 | 580 files in 6–9 minutes; interfaces with namespaces of types, statements setting up constants, augmentations, cycles of up to 62 definitions; tcm verify in about 2.5 minutes. Seven small changes (changes.patch): four for TypeScript 7.1, which reports errors 7.0 doesn't, a module object used as a value, and two imports of the package by its own name |
| tcm-ts itself | the repository | see verifying a change |
18.3 What import mode accepts beyond scratch files
Enums, namespaces and parameter properties. Node can't strip these, so their cache modules start with
// tcm: transformand are compiled by a module hook when loaded. They hash like any other syntax.Namespace imports of project modules.
import * as ns from "./mod"withns.memberis a reference tomember, hashed exactly as if it had been imported by name. Usingnsitself as a value is still refused.Modules that re-export a package. When
./modhasexport * from "pkg"orexport { x } from "pkg",ns.x(orimport { x } from "./mod") is a reference topkg'sx, hashed as an import of it. When it hasexport * as p from "pkg",p.x(withimport { p } from "./mod", orns.p.x) is too, in types as well, andpon its own ispkg's namespace, as withimport * as p from "pkg".declare constof anything but aunique symbol(declare const process: { cwd(): string }, code that runs on several hosts naming a global it doesn't import) is a value the host provides: its generated module reads it fromglobalThis, typed as declared (missing, it'sundefined, where reading the name would have thrown).declare const x: unique symbol, the usual brand pattern, is a type-only definition. It's salted like a term, so two identical declarations are still two distinct symbols. So is adeclare class(a class to cast a mixin to), and a namespace holding only types (declare namespace Effect { … }, erasable, so a scratch file takes one too): each merges with a type of its name into one definition (hashing).Top-level
letis module state (a lazy cache, a config cell). It's salted, so each declaration is its own cell. Every definition that assigns to it shares its component (one module), because an imported binding is read-only. Readers import it and see live updates.// @ts-expect-errorand// @ts-ignoredirectly above a declaration (including above an overload) stay with it. Like other comments they aren't hashed, but they decide whether it typechecks.Statements setting up a constant (a table a loop fills,
Impl.prototype = Proto,Object.freeze(types)) go with it (hashing).export default nameof a definition the module declares: a default import of the module is a use of it.declare global { … }anddeclare module "./x" { … }(augmenting another module's definition) are definitions,<file>.globaland<file>.augments, that every check in the codebase includes (hashing).A namespace import beside a type of the same name: in a type,
Iterable<T>besideimport * as Iterableis the global type, andScopebesideimport type * as Scopeis the file's owninterface Scope, as TypeScript reads them;Scope.Scopeis the module's.
Still refused: a declare module adding to several of a module's definitions at once (write one for each), a
declare module of a package, other ambient declarations (a declare namespace with values,
declare function), import x = require(), decorators, export default of an expression, and other top-level
statements (a call, a statement writing into several constants or into something not the module's).
A top-level statement can't be skipped quietly, because that could leave a definition half-initialized; wrap
it in an immediately-invoked function. A module that's there only for its side effect (an enable.ts calling
install()) is left out with --exclude.