Skip to Content

Plugin Protocol Reference

This page is the contract between ttsc and a plugin package. It is one page on purpose — linked transform packages, composes, contributors, factory generics, and the wire JSON are all validated in one pipeline, and splitting them would force forward references between two co-canonical files. Most readers can start with the Minimum contract section (descriptor, the five subcommands, --plugins-json, exit codes); skip linked packages / composes / contributors / factory generics on the first read.

Looking for the two-halves model, vocabulary, or the stage/subcommand matrix? Start at Concepts.

Manifest

The consumer points compilerOptions.plugins[] at a JavaScript module:

{ "compilerOptions": { "plugins": [ { "transform": "my-plugin", "mode": "strict", "enabled": true }, ], }, }

The module exports either a plugin object or a factory:

const path = require("node:path"); module.exports = (context) => ({ name: "my-plugin", source: path.resolve(__dirname, "go-plugin"), stage: "transform", });

context contains:

{ binary: string; cwd: string; plugin: ITtscProjectPluginConfig; projectRoot: string; tsconfig: string; }

context.plugin is the original tsconfig plugin entry. If you want stronger typing, specialize the context type in your factory:

context.binary is the absolute ttsc native helper selected for this invocation. It is not the plugin sidecar binary and not the JavaScript launcher. Most descriptors do not need it; it exists for advanced factories that need to inspect the active native host.

import * as path from "node:path"; import type { ITtscPluginFactoryContext } from "ttsc"; type MyPluginEntry = { transform: string; mode?: string; }; export function createTtscPlugin( context: ITtscPluginFactoryContext<MyPluginEntry>, ) { return { name: "my-plugin", source: path.resolve(__dirname, "go-plugin"), stage: "transform", }; }

Shape

interface ITtscPlugin { name?: string; source: string; composes?: string[]; stage?: "transform" | "check"; contributors?: ITtscPluginContributor[]; } interface ITtscPluginContributor { name: string; source: string; }

Field rules:

  • name: optional display label for diagnostics and build messages. Routing is not based on package identity.
  • source: Go package directory or go.mod file. A package main source builds as an executable sidecar. A non-main transform source is linked into the selected native host and must register itself through driver.RegisterPlugin. Relative paths are resolved from the consumer project root; package descriptors should usually return an absolute path based on __dirname.
  • composes: optional list of other plugin names (or original transform specifiers) whose source build should be redirected to this descriptor’s source. Composition is one hop only: A.composes = ["B"] sends B to A’s binary, but if B.composes = ["C"] then C is sent to B’s original binary, not A’s. Reciprocal entries (A.composes = ["B"] and B.composes = ["A"]) are rejected as a cycle.
  • stage: plugin kind. Omit for "transform".
  • contributors: optional list of additional Go source packages to statically link into this plugin’s binary at build time. Each entry’s source is copied into the scratch build tree as <scratch>/contrib/<name>/, and a synthesized blank import in the entry package triggers the contributor’s init() before main. See Contributors below.

ttsc accepts Go source only. It builds the source with the pinned Go toolchain and TypeScript-Go shim overlay, then caches the resulting executable.

Stages

Public stages are deliberately small:

StageHost behaviorSubcommands the host invokes
omitted / "transform"participates in the TypeScript-Go transform pathcheck, transform, build
"check"reports diagnostics before emitcheck; opt-in fix, format

There is no public output stage. Plugins do not receive generated JavaScript text or emitted file text for post-processing.

When the user runs ttsc fix, ttsc invokes check-stage plugins with the fix subcommand and keeps JavaScript/declaration emit disabled. fix is the run-everything entry point: edits from every enabled rule flow through it, lint-class and format-class together. See CLI Commands → fix for the subcommand contract.

When the user runs ttsc format, ttsc invokes the same check-stage plugins with the format subcommand. ttsc format is the format-only convenience that filters to format-class rule edits so lint rewrites are skipped — pick this subcommand when you want to reshape source without applying lint rewrites. See CLI Commands → format for the subcommand contract.

Transform-stage plugins do not see fix or format. The host only spawns those subcommands against stage: "check" plugins; a transform-stage plugin’s default branch will never receive them.

Composition

Projects can enable multiple plugin entries. check entries run before emit and compose with transform entries.

Transform entries can share one compiler host in two ways:

  • Several entries resolve to the same executable native binary, usually because one descriptor uses composes.
  • One or more entries point at non-main Go packages. ttsc links those packages into the selected executable host. If there is no executable transform host, ttsc builds a generic host and links the packages there.

This is how linked transform packages compose:

{ "compilerOptions": { "plugins": [ { "transform": "@ttsc/banner", "text": "license" }, { "transform": "@ttsc/strip", "calls": ["console.log"] }, ], }, }

Distinct executable compiler hosts cannot be chained blindly, because each one would need to own Program creation and emit. If several transform modes must cooperate, expose them from one native binary, redirect with composes, or ship non-main linked packages that register against the driver host.

Combining plugins from different vendors

Before the host invokes any transform plugin, assertSharedHostCompatibility (packages/ttsc/src/compiler/internal/sharedHostHelpers.ts) checks the resolved plugin binaries after linked packages are removed from the compiler-owner set. If more than one distinct executable binary remains active, the host aborts 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 host

The first fires during ttsc build / ttsc check; the second during ttsc transform. To combine transform libraries from different vendors (typia + nestia-style), pick one aggregate executable plugin and list the others in its composes: [...] array, or make the additional transforms linked packages that implement the driver plugin hooks. See Reference → Pitfalls for the full failure catalogue.

Composing across binaries

Executable plugins that want to share one compiler host can opt in through the composes field on their descriptor:

module.exports = { name: "my-aggregate-plugin", source: path.resolve(__dirname, "go-plugin"), stage: "transform", composes: ["my-feature-a", "my-feature-b"], };

When ttsc loads the descriptors of my-feature-a and my-feature-b from the project’s compilerOptions.plugins, it reroutes their build target to the aggregate’s source. All three names remain in the --plugins-json payload so the aggregate sidecar can dispatch by name. The aggregate must implement the dispatch logic itself; ttsc only redirects the binary.

Rules enforced at load time:

  • One hop only. ttsc does not transitively follow composes arrays of composed plugins. A.composes = ["B"] sends B to A’s binary; if B.composes = ["C"] then C is sent to B’s original binary, not A’s.
  • Cycle rejected. Two plugins each listing the other in composes is a hard error (plugin composes cycle detected between "<A>" and "<B>").
  • Multi-aggregate rejected. A plugin claimed by two different aggregates is a hard error (plugin "<name>" is composed by multiple aggregate plugins).
  • Empty-string target rejected. Each entry in composes must be a non-empty string matching the target’s name or its tsconfig transform specifier.
  • No void aggregates. The aggregate’s own descriptor still needs a real source directory; ttsc never composes a plugin into nothing.

See Reference → Pitfalls for the exact error strings and recovery steps.

Contributors

composes is horizontal — it lets multiple top-level plugin entries dispatch to one binary by name. contributors is vertical — it lets one binary statically link additional Go sources that never appear as compilerOptions.plugins[] entries. The contributing npm packages are discovered through the host plugin’s own configuration (for @ttsc/lint, that is lint.config.ts’s plugins map).

A host plugin populates contributors from its factory:

import path from "node:path"; module.exports = (context) => ({ name: "@ttsc/lint", source: path.resolve(__dirname, "plugin"), stage: "check", contributors: [ { name: "demo", source: "/abs/path/to/lint-contributor-demo/rules" }, ], });

ttsc’s plugin builder then:

  1. Copies the host plugin’s source to a scratch directory.
  2. Copies each contributor’s source into <scratch>/contrib/<contributor.name>/.
  3. Synthesizes a ttsc_contributions.go next to the host’s entry package with one blank import per contributor: import _ "<host-module-path>/contrib/<name>".
  4. Hashes every contributor source directory into the binary cache key (so swapping a contributor invalidates the cache).
  5. Runs go build. The resulting binary has every contributor’s init() already executed by the time main starts.

Constraints enforced at load time:

  • Package, not module. Contributors ship Go source as a package. A contributor with its own go.mod is rejected at build time, not silently pruned — embed the contributor’s source as a sub-package, not a separate Go module. The host plugin’s go.mod supplies every transitive Go dependency, which also closes the supply-chain hole where a contributor could otherwise pull in arbitrary Go modules at build time.
  • At least one non-test .go file. A contributor directory with only *_test.go files (or no .go files at all) is rejected at load time. Test fixtures alone are not a contributor.
  • Host plugin must have a resolvable Go module path. The aggregate plugin’s source directory must resolve to a go.mod module path (or be co-located with one through the workspace overlay); the builder errors out if it cannot derive one.
  • Name regex. contributor.name must match /^[a-z][a-z0-9_]*$/ (it forms the final import-path suffix and must be a valid Go identifier). The lint factory derives this by mapping the user-facing namespace’s hyphens to underscores — namespace react-hooks becomes contributor name react_hooks. The Go source’s package declaration must match the post-transform name.
  • Absolute source path. contributor.source must be an absolute path to an existing directory.
  • Unique names per build. Contributor names must be unique within one plugin build.
  • Reserved scratch paths. The host plugin’s source must not already ship a contrib/ directory or a ttsc_contributions.go file at its entry root; both are scratch-space reserved for the build pipeline.
  • Not on composed plugins. A composed plugin (one redirected by another’s composes) cannot declare its own contributors — move them onto the aggregate, or drop the composes redirect.

See Reference → Pitfalls for the exact rejection error strings.

Contributor source hashes fold into the plugin cache key, so consumers with the same logical set of contributors share one cached binary regardless of declaration order. The full cache-key formula (Go binary identity, GO_BUILD_ENV_KEYS, overlay hashes) lives in Architecture → Cache key inputs.

Plugin Config Keys

ttsc owns transform and enabled. Every other key on a compilerOptions.plugins[] entry is plugin-owned config — passed through unchanged via --plugins-json (see below for the verbatim-passthrough rule). ts-patch words such as before, after, or phase carry no special meaning to ttsc. Descriptors choose only between the public "transform" and "check" stages.

Disabled Entries

enabled: false disables a plugin entry before loading:

{ "compilerOptions": { "plugins": [ { "transform": "my-plugin", "enabled": false }, { "transform": "other-plugin" }, ], }, }

Disabled entries are not resolved, built, or included in --plugins-json.

CLI Commands

The built Go binary receives subcommands. Unknown flags should be ignored so future ttsc minors can add optional flags. The host emits flags in equals form when spawning plugin binaries (--cwd=/abs/path, --tsconfig=/abs/path, --plugins-json=[...]); space-form (--cwd /abs/path) appears only in tsgo’s own arg list and never reaches a plugin. Go’s flag.NewFlagSet accepts both forms; hand-rolled parsers should accept equals-form at minimum.

ttsc never spawns the version / -v / --version subcommand on a plugin process. First-party plugins implement it as a smoke verb for go run ./plugin version; third-party plugins may but are not required to.

version

my-plugin version my-plugin -v my-plugin --version

Print a human-readable version and exit 0. Smoke verb only — the host never invokes it.

check

my-plugin check \ --cwd=/project \ --tsconfig=/project/tsconfig.json \ --plugins-json='[...]'

Run diagnostics only. Write diagnostics to stderr. Exit non-zero for errors.

fix

my-plugin fix \ --cwd=/project \ --tsconfig=/project/tsconfig.json \ --plugins-json='[...]'

Optional for check-stage plugins. Invoked when the user runs ttsc fix. Apply autofixes to source files in place, then render any remaining diagnostics through the same renderer contract as check. Emit stays disabled — fix plugins must not write JavaScript or declaration output.

Found nothing to apply? Exit 0 with empty stderr. Do not support fix? Exit non-zero with a human-readable stderr message — see Unsupported fix/format below.

format

my-plugin format \ --cwd=/project \ --tsconfig=/project/tsconfig.json \ --plugins-json='[...]'

Optional for check-stage plugins. Invoked when the user runs ttsc format. Apply formatter-class edits (whitespace, punctuation, ordering) to source files in place. Write-only by contract: format subcommands must not print diagnostics and must keep JavaScript / declaration emit disabled.

The split between fix and format is the apply-time filter, not a plugin boundary. A check-stage plugin may host both lint and format rules in one binary: fix applies every category’s edits; format filters to format-class only. The two subcommands share the engine and the protocol; only the post-engine filter differs.

Unsupported fix / format

A check-stage plugin that does not implement fix or format should exit non-zero with a human-readable stderr message. ttsc does not parse any specific phrase — the host prints stderr verbatim and aborts. The @ttsc/lint convention of <plugin-name>: fix not supported / format not supported and exit 2 is a project convention, not a host contract; any non-zero exit suffices.

transform

my-plugin transform \ --cwd=/project \ --tsconfig=/project/tsconfig.json \ --plugins-json='[...]'

Project-wide source transform used by ttsc.transform() and in-memory callers. Write JSON to stdout:

{ "diagnostics": [], "typescript": { "src/main.ts": "export const value = 1;\n" } }

Each value in typescript is written to disk verbatim — the host does not re-parse or re-render. If your plugin mutated the AST, you must run a printer over the file before placing its text in the map; the canonical path is shimprinter.NewPrinter(...)shimprinter.EmitSourceFile(printer, file). Returning file.Text() after AST mutation will silently round-trip the original source. See packages/ttsc/utility/host.go::RunTransform for the reference implementation.

For leaf-text mutations (string-literal, identifier, numeric-literal .Text), the printer reads original source text via getTextOfNode when the node is non-synthesized and has a parent — see Recipes → Mutating leaf-text nodes for the synthesize-flag invariant the rewrite must follow.

build

my-plugin build \ --cwd=/project \ --tsconfig=/project/tsconfig.json \ --plugins-json='[...]' \ --emit \ --outDir=/project/dist

Project-wide transform build. Run diagnostics and write TypeScript-Go outputs.

--plugins-json

--plugins-json is a JSON array of loaded plugin descriptors for the current command:

[ { "name": "my-plugin", "stage": "transform", "config": { "transform": "my-plugin", "mode": "strict" } } ]

config is the consumer’s compilerOptions.plugins[i] entry passed through verbatim, modulo JSON normalization — every key the consumer wrote, including ttsc-owned transform and enabled, will appear. Plugins should ignore transform and enabled: ttsc owns them. (enabled: false plugins are filtered before serialization, so plugins will only ever see enabled: true or absent — but should not depend on it.) Read user options from any other key in config.

When multiple entries resolve to the same binary, ttsc sends them together. Select the entry you need by name, mode, or plugin-owned option fields.

Plugin order. The array is deterministically ordered: all check-stage plugins first, in their tsconfig order, then all transform-stage plugins, in their tsconfig order. A plugin that needs to know its position in the pipeline can rely on this rule (see packages/ttsc/src/plugin/internal/loadProjectPlugins.ts::orderNativePlugins).

Exit and Output

  • 0: success.
  • 2: argument/config/diagnostic failure.
  • Any other non-zero: runtime failure.
  • stderr is shown to users; format errors for humans.
  • transform stdout must be the JSON shape above.
  • build writes project outputs through TypeScript-Go emit.

Unrecovered Go panics in a plugin process surface as a normal exit-2 with the panic stack on stderr. The host prints stderr verbatim and propagates exit code 2 as ttsc’s own exit code; it does not catch, symbolize, or aggregate plugin panics. Wrap rule bodies in recover() if you need graceful per-rule reporting — see packages/lint/linthost/engine.go for the reference pattern.

Compatibility Rules

Within the current protocol:

  • ttsc may add optional flags.
  • ttsc may add JSON fields.
  • ttsc will not rename or remove current fields without a protocol bump.

So plugin binaries should ignore unknown flags and unknown JSON fields.

See also

Last updated on