@ttsc/paths โ Module Specifier Rewriting
@ttsc/paths rewrites import "@lib/foo" into something like import "./modules/foo.js" by consulting compilerOptions.paths, the Programโs actual source files, and rootDir / outDir. It is the most ambitious of the three transform plugins because the rewrite must agree with the output file layout, not just the source โ TypeScript paths get resolved relative to the source tree, but the emitted JavaScript has to use a path that the runtime (Node, the bundler) can actually load.
This page is also the canonical reference for leaf-text AST mutation โ changing a string literalโs Text field correctly. The invariant lives in three lines of code at the heart of pathsRewriter.apply.
If @ttsc/strip felt manageable, this should too. The new ideas are: read tsconfig fields through the driver, walk every syntactic shape that holds a module specifier, and the synthesize-flag rule.
What it does for the consumer
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"paths": {
"@lib/*": ["./src/modules/*"]
},
"plugins": [{ "transform": "@ttsc/paths" }]
},
"include": ["src"]
}// src/main.ts (before)
import { hello } from "@lib/greet";
import type { User } from "@lib/types/user";
export const value = hello();
export type Owner = User;// dist/main.js (after)
import { hello } from "./modules/greet.js";
export const value = hello();// dist/main.d.ts (after)
import type { User } from "./modules/types/user";
export declare const value: ReturnType<typeof import("./modules/greet").hello>;
export type Owner = User;The same source AST is shared between JavaScript and declaration emit, so the rewrite lands in both outputs in a single pass.
Directory layout
packages/paths/
โโโ package.json
โโโ src/
โ โโโ index.cjs โ JS descriptor (CommonJS)
โโโ go.mod
โโโ driver/
โ โโโ paths.go โ Linked transform logic
โโโ plugin/
โโโ main.go โ 36-line dispatcher
โโโ paths.go โ Blank-imports the linked driver packageIdentical shape to strip. The descriptor at src/index.cjs and the entrypoint at plugin/main.go are the same dispatcher you read in the banner walkthrough; the only difference is the plugin name string. Skip ahead to the real logic.
The real logic โ pathsRewriter in driver/paths.go
Everything below is in packages/paths/driver/paths.go. Six steps:
- Build the rewriter from the loaded Program. This is where tsconfig paths,
rootDir,outDir, and the source-file index get cached. - Sort patterns by specificity so
@lib/foo/*wins before@lib/*for the same specifier. - For each file, walk every node and find string literals that are module specifiers.
- Resolve the specifier to a source file inside the Program.
- Compute the relative output path from the rewriterโs source file to the targetโs emitted JS.
- Mutate the string literalโs
Textโ with the synthesize-flag invariant.
1. Building the rewriter
type pathsRewriter struct {
basePath string
outDir string
patterns []pathsPattern
rootDir string
sourceFiles map[string]string
}
type pathsPattern struct {
pattern string
targets []string
}
func newPathsRewriter(prog *driver.Program) *pathsRewriter {
out := &pathsRewriter{sourceFiles: map[string]string{}}
if prog == nil || prog.ParsedConfig == nil ||
prog.ParsedConfig.ParsedConfig == nil ||
prog.ParsedConfig.ParsedConfig.CompilerOptions == nil {
return out
}
options := prog.ParsedConfig.ParsedConfig.CompilerOptions
out.basePath = filepath.Clean(options.GetPathsBasePath(prog.Host.GetCurrentDirectory()))
out.outDir = optionalPath(options.OutDir, prog.Host.GetCurrentDirectory())
out.rootDir = optionalPath(options.RootDir, prog.Host.GetCurrentDirectory())
files := prog.SourceFiles()
if out.rootDir == "" {
out.rootDir = commonSourceDir(files)
}
for _, file := range files {
name := normalizePath(file.FileName())
out.sourceFiles[name] = name
out.sourceFiles[stripKnownSourceExtension(name)] = name
}
if options.Paths != nil {
for pattern, targets := range options.Paths.Entries() {
out.patterns = append(out.patterns, pathsPattern{
pattern: pattern,
targets: append([]string(nil), targets...),
})
}
}
sort.SliceStable(out.patterns, func(i, j int) bool {
return patternRank(out.patterns[i].pattern) > patternRank(out.patterns[j].pattern)
})
return out
}The constructor pulls four facts out of the Program and caches them as an in-memory index. Worth walking each:
basePath โ the directory that paths in compilerOptions.paths resolve relative to. tsgoโs GetPathsBasePath handles the baseUrl vs no-baseUrl logic.
outDir and rootDir โ where emitted JavaScript lands and which subtree owns the source. If rootDir is empty, paths falls back to โlongest common ancestor of every source fileโ via commonSourceDir. This matches tscโs behavior when rootDir is unspecified.
sourceFiles โ a string โ string map of every user source file, keyed two ways:
for _, file := range files {
name := normalizePath(file.FileName())
out.sourceFiles[name] = name
out.sourceFiles[stripKnownSourceExtension(name)] = name
}The same file is registered both with its extension (src/modules/greet.ts) and without (src/modules/greet). This lets resolveSource look up a target like src/modules/greet and find the actual file in one map hit โ without iterating over every source file.
patterns โ copied from options.Paths.Entries(). Each entryโs targets slice is deep-copied with append([]string(nil), targets...) so a later mutation in tsgoโs options does not leak back into the rewriter.
Sidebar: Go map iteration order is randomised
Notice the explicit sort on out.patterns at the end:
sort.SliceStable(out.patterns, func(i, j int) bool {
return patternRank(out.patterns[i].pattern) > patternRank(out.patterns[j].pattern)
})Without sorting, the order of patterns would depend on Goโs map iteration, which is intentionally randomised โ different runs of the same plugin would resolve @lib/foo/bar against either @lib/* or @lib/foo/* non-deterministically. The sort makes the resolution stable and biases toward specificity (more literal characters in the pattern wins). SliceStable preserves the relative order of equal-rank patterns so the final tie-break is โwhichever entry tsgo gave us first.โ
Sidebar: filepath.ToSlash and the Windows path discipline
Notice the helper normalizePath (filepath.ToSlash(filepath.Clean(value))) that wraps every path before it lands in sourceFiles. tsgo normalises filenames to forward slashes internally on every platform; filepath.Join and filepath.Rel use the OS separator (\ on Windows). A Windows plugin that compares prog.SourceFiles()[i].FileName() against filepath.Join(cwd, "src/main.ts") without normalising hits no match and silently no-ops the rewrite. Run every external path through filepath.ToSlash before keying any map or comparing strings; see Pitfalls โ Windows Path Failures for the catalogue entry.
2. Sort patterns by specificity
func patternRank(pattern string) int {
return len(strings.ReplaceAll(pattern, "*", ""))
}A patternโs rank is the count of non-wildcard characters: @lib/foo/* (rank 9) beats @lib/* (rank 5). Same idea as tscโs own internal pattern matching.
3. Walk every node that could hold a module specifier
func (r *pathsRewriter) apply(file *shimast.SourceFile) {
if r == nil || file == nil || len(r.patterns) == 0 {
return
}
visitModuleSpecifiers(file.AsNode(), func(lit *shimast.Node) {
if lit == nil || lit.Kind != shimast.KindStringLiteral {
return
}
spec := lit.Text()
rewritten, ok := r.rewrite(file.FileName(), spec)
if ok && rewritten != spec {
lit.AsStringLiteral().Text = rewritten
lit.Flags |= shimast.NodeFlagsSynthesized
lit.Loc = shimcore.UndefinedTextRange()
}
})
}This three-line tail is the synthesize-flag rule for leaf-text mutations โ the most consequential lines on the page. Discussion in ยง6 below.
The visitor:
func visitModuleSpecifiers(node *shimast.Node, visit func(*shimast.Node)) {
if node == nil {
return
}
switch node.Kind {
case shimast.KindImportDeclaration:
visit(node.AsImportDeclaration().ModuleSpecifier)
case shimast.KindExportDeclaration:
visit(node.AsExportDeclaration().ModuleSpecifier)
case shimast.KindImportEqualsDeclaration:
ref := node.AsImportEqualsDeclaration().ModuleReference
if ref != nil && ref.Kind == shimast.KindExternalModuleReference {
visit(ref.AsExternalModuleReference().Expression)
}
case shimast.KindImportType:
arg := node.AsImportTypeNode().Argument
if arg != nil && arg.Kind == shimast.KindLiteralType {
visit(arg.AsLiteralTypeNode().Literal)
}
case shimast.KindModuleDeclaration:
decl := node.AsModuleDeclaration()
if decl != nil {
visit(decl.Name())
}
case shimast.KindCallExpression:
call := node.AsCallExpression()
if isModuleSpecifierCall(call) && call.Arguments != nil && len(call.Arguments.Nodes) > 0 {
visit(call.Arguments.Nodes[0])
}
}
node.ForEachChild(func(child *shimast.Node) bool {
visitModuleSpecifiers(child, visit)
return false
})
}
func isModuleSpecifierCall(call *shimast.CallExpression) bool {
if call == nil || call.Expression == nil {
return false
}
switch call.Expression.Kind {
case shimast.KindImportKeyword:
return true
case shimast.KindIdentifier:
return call.Expression.Text() == "require"
default:
return false
}
}Six syntactic positions hold a module specifier in TypeScript. The visitor enumerates each one explicitly because the field name and depth differ per kind:
| Source | Kind | Where the literal hides |
|---|---|---|
import x from "pkg" | ImportDeclaration | .ModuleSpecifier |
export { x } from "pkg" | ExportDeclaration | .ModuleSpecifier |
import x = require("pkg") | ImportEqualsDeclaration | .ModuleReference.AsExternalModuleReference().Expression |
type T = import("pkg").T | ImportType | .Argument.AsLiteralTypeNode().Literal |
declare module "pkg" {} | ModuleDeclaration | .Name() (a StringLiteral only for the ambient declare module "pkg" form; namespace foo {} / module foo {} resolve to an Identifier and are filtered out by the KindStringLiteral check inside apply) |
require("pkg") / import("pkg") | CallExpression | .Arguments.Nodes[0] |
After the switch, node.ForEachChild recurses into every child. This is what makes the visitor catch deeply-nested cases like function f(x = await import("pkg")) {} โ the CallExpression is buried inside parameter default initializers, but every node still gets visited because we always recurse.
Sidebar: visitor pattern in Go
The closure-based visitor is the simplest way to walk an AST in Go without an explicit interface. The shape:
func walk(node *X, visit func(*X)) {
if node == nil { return }
visit(node)
node.ForEachChild(func(child *X) bool {
walk(child, visit)
return false
})
}returning false keeps the iteration alive. Returning true would short-circuit โ useful for โfind the first node matching predicate,โ not useful when you want to visit every node.
4. Resolving a specifier
func (r *pathsRewriter) rewrite(fromSource string, specifier string) (string, bool) {
if specifier == "" || strings.HasPrefix(specifier, ".") || strings.HasPrefix(specifier, "/") {
return specifier, false
}
targetSource, ok := r.resolveSource(specifier)
if !ok {
return specifier, false
}
fromOut := r.outputPathForSource(fromSource)
targetOut := r.outputPathForSource(targetSource)
if fromOut == "" || targetOut == "" {
return specifier, false
}
rel, err := filepath.Rel(filepath.Dir(fromOut), targetOut)
if err != nil {
return specifier, false
}
rel = filepath.ToSlash(rel)
if !strings.HasPrefix(rel, ".") {
rel = "./" + rel
}
return rel, true
}The contract: rewrite("/proj/src/main.ts", "@lib/greet") returns ("./modules/greet.js", true).
The fast-path bail-outs at the top are important โ . and / prefixes are already relative or absolute paths and never match a path alias. An empty specifier is a parse error in real TypeScript but the visitor still walks it.
The resolution chain:
resolveSource(specifier)โ find the source file that matches the alias.outputPathForSource(fromSource)andoutputPathForSource(targetSource)โ map both to emitted-JS paths.filepath.Rel(dir(fromOut), targetOut)โ compute the relative path from one to the other.- Force forward slashes (
filepath.ToSlash) โ JavaScript modules use POSIX-style paths on every platform. - Add the leading
./if missing โ Node.js requires it for relative imports.
resolveSource:
func (r *pathsRewriter) resolveSource(specifier string) (string, bool) {
for _, pattern := range r.patterns {
star, ok := matchPattern(pattern.pattern, specifier)
if !ok {
continue
}
for _, target := range pattern.targets {
candidate := strings.Replace(target, "*", star, 1)
resolved := normalizePath(filepath.Join(r.basePath, candidate))
if source, ok := r.lookupSource(resolved); ok {
return source, true
}
}
}
return "", false
}
func matchPattern(pattern string, specifier string) (string, bool) {
if !strings.Contains(pattern, "*") {
return "", pattern == specifier
}
parts := strings.SplitN(pattern, "*", 2)
if !strings.HasPrefix(specifier, parts[0]) || !strings.HasSuffix(specifier, parts[1]) {
return "", false
}
return specifier[len(parts[0]) : len(specifier)-len(parts[1])], true
}For pattern @lib/* and specifier @lib/greet:
parts = ["@lib/", ""]specifierhas the prefix@lib/and the suffix""โ match.- Return
specifier[5 : 10-0] = "greet"(length 10 minus suffix length 0).
The captured * substitutes into each target: ./src/modules/* โ ./src/modules/greet. The candidate is then joined against basePath (the tsconfig directory) and looked up in r.sourceFiles.
lookupSource walks four stages, falling through on each miss: literal match in the sourceFiles map โ stem with any known source extension stripped, re-looked-up โ stem with each of .ts / .tsx / .mts / .cts appended โ stem + /index.<ext> for directory-style imports. The exact source is in driver/paths.go::lookupSource.
5. Mapping source to emitted-JS path
func (r *pathsRewriter) outputPathForSource(source string) string {
if r.outDir == "" || r.rootDir == "" {
return ""
}
rel, err := filepath.Rel(r.rootDir, source)
if err != nil || isOutsideRelativePath(rel) {
return ""
}
return normalizePath(filepath.Join(r.outDir, replaceSourceExtension(rel, emittedJavaScriptExtension(rel))))
}
func emittedJavaScriptExtension(source string) string {
switch strings.ToLower(filepath.Ext(source)) {
case ".mts":
return ".mjs"
case ".cts":
return ".cjs"
default:
return ".js"
}
}The mapping is tsgoโs emit rule, replicated here because the plugin needs to predict the output path the compiler will write. replaceSourceExtension(rel, ".js") strips the source extension and appends .js (or .mjs / .cjs).
isOutsideRelativePath checks rel == ".." || strings.HasPrefix(rel, "../") โ sources outside the rootDir do not have a well-defined output and the rewriter bails out.
6. Mutating a string literal โ the synthesize-flag invariant
This is the three-line pattern at the end of pathsRewriter.apply:
lit.AsStringLiteral().Text = rewritten
lit.Flags |= shimast.NodeFlagsSynthesized
lit.Loc = shimcore.UndefinedTextRange()The trap: if you set lit.Text = rewritten and stop there, the printer silently emits the old specifier. The reason is buried inside tsgoโs printer, but the upshot is:
tsgoโs printer reads leaf-token text through
getTextOfNode, which checks whether the node is โrealโ โ has a parent and is not flagged as synthesized โ and, if so, slices the original source betweennode.Pos()andnode.End(). That slice is the original"@lib/greet". The printer never looks at yourTextfield.
The two extra lines detach the node from its parse-tree source span:
lit.Flags |= shimast.NodeFlagsSynthesizedโ tell the printer this node has no original source position to read from. It now falls back tolit.Text.lit.Loc = shimcore.UndefinedTextRange()โ clear the position range so sourcemap generation does not point at the original literal location.
Set both. Forgetting either is a silent-emit regression โ the transformโs unit tests pass, but the emitted output is unchanged from the input.
The same invariant applies to:
*StringLiteral.Text*Identifier.Text*NumericLiteral.Text- any other โleaf token whose text is the entire payload.โ
It does not apply to structural mutations โ filtering Statements.Nodes, swapping child pointers, replacing one statement with another. Those round-trip cleanly through the printer because the printer iterates statement lists directly without reading source spans. This is why @ttsc/stripโs statement filter does not need the synthesize stamp.
Sidebar: how to know which mutations need the flag
Three checks:
- Are you assigning to a nodeโs
.Textfield? If yes, you almost certainly need the flag. - Are you replacing one slice element with another via
list.Nodes[i] = newNode? If yes andnewNodecame fromNodeFactory.NewX(), set the flag onnewNode(the factory does not set it for you). IfnewNodewas extracted from a different parse, you usually want to clone it first. - Are you
append-ing or filtering aNodeList? No flag needed.
When in doubt, set both โ the cost is zero and the failure mode is silent.
Pulling it together
pathsRewriter is the only shipped transform in this tour that needs project-wide Program facts beyond the source file currently being visited. Its constructor caches the Programโs ParsedConfig.CompilerOptions, SourceFiles, and the Paths map; everything else is a pure function of those four facts and the input specifier string.
The pattern generalizes to any plugin that needs to query project-level information:
- Need
compilerOptions.target/module/jsx? Read them fromprog.ParsedConfig.ParsedConfig.CompilerOptions. - Need the list of user source files?
prog.SourceFiles(). - Need types?
prog.Checker.GetTypeAtLocation(node). See AST & Checker โ Checker Basics. - Need
tsconfig.jsonextends-chain or include paths?prog.ParsedConfig.ParsedConfigcarries the resolved configuration.
What to copy, what to ignore
Copy:
- The pattern of caching tsconfig facts on a constructor-built struct (
pathsRewriter). Avoids re-querying for every node. - The pattern-rank sort. Whenever you read a user-supplied map keyed on globs or patterns, copy this idiom: pull entries into a slice, then
sort.SliceStableby specificity rank. This makes resolution deterministic across runs. - The visitor that enumerates every module-specifier kind. If your plugin cares about module specifiers, this is the exhaustive list as of TypeScript 5.x.
- The synthesize-flag invariant. Read it once; tattoo it.
Do not copy:
- The
optionalPath/normalizePath/stripKnownSourceExtensionhelpers wholesale โ they were tuned to match tsgoโs exact behavior and contain edge cases (Windows drive prefixes,.d.tsvs.ts) that are mostly noise to a plugin that does not rewrite module specifiers. Re-derive them as you need them. - The standalone
plugin/paths.gowrapper when your descriptor points directly at an executable command package. - Reading by package name. Linked plugins receive the project plugin entry paired with their registration order through
driver.PluginContext; executable plugins should parse--plugins-jsonaccording to their own protocol.
Test coverage
The path resolver is exercised by tests/test-paths/src/features/ โ every feature test materializes a real project layout under tests/projects/path-*, runs the real ttsc launcher, and asserts on both the emitted JS and .d.ts.
The synthesize-flag invariant is locked by tests that assert on the printer output text after mutation โ search for lit.Flags and NodeFlagsSynthesized in packages/paths/driver/ and the end-to-end fixtures above.
Where to read next
@ttsc/lintโ the deep-dive on diagnostics, the rule engine, and contributor packages.- AST & Checker โ Recognizing Imports and Module Specifiers โ the same visitor, in the protocol docs.
- AST & Checker โ Mutating the AST โ the synthesize-flag invariant, in the concepts docs.
- Recipes โ Mutating leaf-text nodes โ copy-paste pattern.