Skip to Content

@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 package

Identical 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:

  1. Build the rewriter from the loaded Program. This is where tsconfig paths, rootDir, outDir, and the source-file index get cached.
  2. Sort patterns by specificity so @lib/foo/* wins before @lib/* for the same specifier.
  3. For each file, walk every node and find string literals that are module specifiers.
  4. Resolve the specifier to a source file inside the Program.
  5. Compute the relative output path from the rewriterโ€™s source file to the targetโ€™s emitted JS.
  6. 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.

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.โ€

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:

SourceKindWhere 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").TImportType.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.

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:

  1. resolveSource(specifier) โ€” find the source file that matches the alias.
  2. outputPathForSource(fromSource) and outputPathForSource(targetSource) โ€” map both to emitted-JS paths.
  3. filepath.Rel(dir(fromOut), targetOut) โ€” compute the relative path from one to the other.
  4. Force forward slashes (filepath.ToSlash) โ€” JavaScript modules use POSIX-style paths on every platform.
  5. 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/", ""]
  • specifier has 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 between node.Pos() and node.End(). That slice is the original "@lib/greet". The printer never looks at your Text field.

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 to lit.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.

Three checks:

  1. Are you assigning to a nodeโ€™s .Text field? If yes, you almost certainly need the flag.
  2. Are you replacing one slice element with another via list.Nodes[i] = newNode? If yes and newNode came from NodeFactory.NewX(), set the flag on newNode (the factory does not set it for you). If newNode was extracted from a different parse, you usually want to clone it first.
  3. Are you append-ing or filtering a NodeList? 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 from prog.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.json extends-chain or include paths? prog.ParsedConfig.ParsedConfig carries 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.SliceStable by 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 / stripKnownSourceExtension helpers wholesale โ€” they were tuned to match tsgoโ€™s exact behavior and contain edge cases (Windows drive prefixes, .d.ts vs .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.go wrapper 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-json according 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.

Last updated on