Pitfalls
Common failures and direct fixes. Each entry quotes the actual host error string when there is one, so you can grep the symptom and land on the fix.
First-build failing? Skim this page top to bottom before debugging. Three pitfalls catch the majority of first-hour problems: pnpm linker, missing platform package, plugin
name-doesn’t-match-dependency.
source does not exist
Your tarball missed the Go source or the manifest used a relative runtime path.
Fix package.json:
"files": ["plugin.cjs", "plugin"]Fix plugin.cjs:
source: path.resolve(__dirname, "plugin");Verify:
npm pack --dry-runShim Import Cannot Resolve
Error:
no required module provides package github.com/microsoft/typescript-go/shim/astFix go.mod:
require github.com/microsoft/typescript-go/shim/ast v0.0.0For local editor support, also configure go.work; see Editor setup.
pnpm Cannot Find node_modules/ttsc
For plugin development repos, add one of:
node-linker=hoistedor:
public-hoist-pattern[]=ttscThen reinstall.
Bundled Go SDK Missing
Error (verbatim from the host):
ttsc: building plugin "<name>" failed because the Go toolchain was not found.
Reinstall ttsc with optional dependencies so the bundled Go compiler is present,
or set TTSC_GO_BINARY to an absolute path.ttsc looked for the Go compiler in all five resolution slots and found nothing. The most common cause is npm install --no-optional (or a lockfile that skipped optional deps), which silently leaves @ttsc/{platform}-{arch} un-installed. Reinstall without --no-optional, or pin a Go binary by exporting TTSC_GO_BINARY=/absolute/path/to/go. See Architecture → Build environment for how the resolution falls through.
Options Are Ignored
You declared --plugins-json but never parsed it. The user’s tsconfig options live there, not in environment variables.
Parse it and find your entry by mode or name. The host promises every key the consumer wrote under compilerOptions.plugins[...] appears in config (including transform and enabled, which ttsc owns and you should ignore). See Plugin protocol → --plugins-json.
Auto Plugin Did Not Run
Most common cause: the consumer’s dependency string does not exactly match the plugin’s package.json name. Three invariants must all hold:
// consumer/package.json
"devDependencies": { "my-ttsc-plugin": "^0.1.0" }
// my-ttsc-plugin/package.json
{ "name": "my-ttsc-plugin", "ttsc": { "plugin": { "transform": "my-ttsc-plugin" } } }For the full rule (explicit-wins, name-match, direct-list-only), see Concepts → Auto-discovery.
Combining Plugins
Multiple explicit plugin entries and package-enabled plugins are supported. The limit is about ownership of the emit pass, not the number of plugin entries.
This works with TypeScript-Go’s normal emit path:
{
"compilerOptions": {
"plugins": [
{ "transform": "@ttsc/banner", "text": "license" },
{ "transform": "@ttsc/strip", "calls": ["console.log"] },
],
},
}@ttsc/banner, @ttsc/paths, and @ttsc/strip point at linked non-main Go packages. ttsc links those packages into the selected native compiler host, or into a generic host when no executable transform host is present. This path is capability-based: any transform descriptor whose source package is non-main follows the same rules.
This works when a check plugin runs before one transform sidecar that owns the source/emit pass:
{
"compilerOptions": {
"plugins": [
{ "transform": "@ttsc/lint", "rules": { "no-var": "error" } },
{ "transform": "my-source-transform" },
],
},
}This fails — two unrelated executable transform binaries cannot share one source-to-source pass:
{
"compilerOptions": {
"plugins": [
{ "transform": "source-transform-a" },
{ "transform": "source-transform-b" },
],
},
}assertSharedHostCompatibility (packages/ttsc/src/compiler/internal/sharedHostHelpers.ts) aborts the spawn with one of:
ttsc: multiple compiler native backends cannot share one emit pass;
compose transform libraries through one aggregate native host
ttsc: multiple transform native backends cannot share one source-to-source pass;
compose transform libraries through one aggregate native hostThe first fires during ttsc build / ttsc check; the second during ttsc transform. The fix is the same: pick one aggregate executable plugin and list the others in its composes: [...] array, or make the additional transforms linked non-main packages. See Composition failures below for the per-rule error strings.
Composition failures
The composes validator enforces four invariants. Each rejection has a specific error string you can grep:
composes cycle between two plugins.
plugin composes cycle detected between "<A>" and "<B>";
each plugin lists the other in its "composes" array
— composition is one hop only, not transitiveComposition is one hop. If A.composes=[B] sends B to A’s binary, then B.composes=[A] would send A to B’s binary, silently swapping both bindings. ttsc refuses. Either drop one direction or merge the plugins. Source: loadProjectPlugins.ts::composePluginSources.
Plugin claimed by multiple aggregates.
plugin "<name>" is composed by multiple aggregate plugins;
each plugin entry can be redirected to only one aggregate native hostA composed plugin’s binary is rerouted to the aggregate’s binary. Two aggregates would split the routing. Pick one aggregate.
Empty / non-string composes target.
plugin "<name>" has an invalid "composes" target;
targets must be non-empty plugin names or transform specifiersThe validator strips whitespace and rejects empty or non-string entries before resolution. A typo’d entry like composes: [""] or composes: [null] is the common cause.
Contributors on a composed plugin.
plugin "<name>" is composed by "<aggregate>" but declares its own "contributors";
move the contributors onto the aggregate plugin or drop the composes redirectA composed plugin’s source is rerouted to the aggregate’s binary, so its own contributors would link into a host they were not authored against. Move the contributors to the aggregate.
Contributor merge failures
The contributor build path enforces three host-shape invariants:
Contributor ships its own go.mod.
plugin "<name>" contributor "<contrib>" must ship Go source as a package,
not a module (go.mod found at <path>/go.mod).
Remove go.mod so the contributor compiles inside the host module's dependency graph.Contributors are merged under the host’s module so init() blank imports work; an independent module would be invisible to the host’s dependency graph and would also open a supply-chain surface. Drop the go.mod from the contributor package. Source: buildSourcePlugin.ts::mergeContributors.
Host plugin already ships a contrib/ directory.
plugin "<name>" already ships a contrib/ directory in its source;
contributor merge would silently overwrite.
Rename the host plugin's directory to a different name.The contributor merge writes scratch contrib/<contributor> under the host module root. Pre-existing host directories at that path are refused rather than overwritten. Rename your directory (internal/, lib/, anything but contrib/).
Host plugin already ships ttsc_contributions.go.
plugin "<name>" already ships ttsc_contributions.go in its entry package;
that filename is reserved for the contributor blank-import generator.
Rename the host's file.The filename is reserved by the generator. Rename the host’s file to contributions.go, entries.go, etc.
AST and Checker
These bite first-time check-plugin authors and are not visible from the AST page alone.
node.Text() panics on unhandled kinds. internal/ast/ast.go:296 ends with panic("Unhandled case in Node.Text: %T"). Safe only on Identifier, *Literal, JsxText, TemplateLiteralLikeNode, and a handful of token-like kinds. For anything else, branch on node.Kind first or use shimscanner.GetSourceTextOfNodeFromSourceFile(file, node, false). The @ttsc/lint engine wraps every rule in recover() (packages/lint/linthost/engine.go:388) precisely because this panic is reachable in normal traversal.
node.Symbol() returns nil for non-declaration nodes. Symbols are attached during binding only for DeclarationBase kinds. An ExpressionStatement, BinaryExpression, or any non-declaration node will return nil and a .Name deref will crash the plugin. Either branch on node.Kind first, or use ctx.Checker.GetSymbolAtLocation(node) which is nil-safe and works on references as well as declarations.
Duplicate Kind in Visits() is silently deduplicated. packages/lint/linthost/engine.go builds a map[Kind]bool from each rule’s Visits(). Listing KindCallExpression twice registers it once — the second instance is dropped without warning. Combine all dispatch logic for one Kind in a single handler.
let / const / var are not on node.Flags. var x vs let x is stored on the parent VariableDeclarationList. Use shimast.IsLet(decl), shimast.IsConst(decl), shimast.IsVar(decl), or shimast.GetCombinedNodeFlags(decl) & shimast.NodeFlagsLet.
Bad Text Ranges
Diagnostic ranges anchored at node.Pos() start before leading trivia; use shimscanner.SkipTrivia(file.Text(), node.Pos()). See AST & Checker → Text Ranges and Trivia.
Windows Path Failures
Normalize before comparing:
filepath.ToSlash(path)Do not hard-code the cached binary name as plugin; on Windows it is plugin.exe.
Cache Did Not Rebuild
Files inside the plugin source directory affect the plugin binary cache:
Go source, go.mod / go.sum / go.work, embedded JSON/schema/data files,
and other package filesFor the full cache-key formula and the env vars that enter it, see Architecture → Cache key inputs. To force a cold rebuild:
npx ttsc cleanversion is a smoke verb
The shipped utility plugins implement version as a sanity check (each prints a build-id line) so a maintainer can go run ./plugin version to confirm the binary is wired up. ttsc itself never invokes version. Plugin authors who skip version lose the smoke-test path but break nothing on the host side.
Runtime Output Fails After Manual Emit
The public plugin contract does not provide an output-stage text hook. If a custom sidecar goes beyond source AST mutation and writes CommonJS manually, it must mirror TypeScript-Go’s expected boilerplate:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.name = void 0;Normal transform plugins avoid this by mutating TypeScript AST and letting TypeScript-Go print the final JavaScript through shimprinter.EmitSourceFile.
See also
- Architecture — what the build pipeline does so you can predict failure modes.
- Walkthroughs — worked examples of the patterns that work.
- Driver API — the supported Go façade.
- Authoring · Editor setup — fast iteration without
ttsc.