Getting Started: Hello Plugin
This page builds the smallest plugin that proves the ttsc plumbing works: a transform-stage sidecar that walks every TypeScript source file, prints it back through tsgoβs printer, and returns the JSON envelope to the host. No AST mutation, no Checker β just the bootstrap.
Stuck on the first build? Jump to Pitfalls before debugging. The most common first-hour failures (cache, pnpm linker, missing platform package,
namemismatch) are catalogued there.
When this Hello plugin runs end to end, move on to End-to-end source-transform plugin β the same shape with a real AST mutation (debugger removal) and the bug fixes that come with it.
1. Create the Package
ttsc-plugin-hello/
βββ package.json
βββ plugin.cjs
βββ plugin/
βββ go.mod
βββ main.gopackage.json:
{
"name": "ttsc-plugin-hello",
"version": "0.1.0",
"main": "plugin.cjs",
"ttsc": {
"plugin": {
"transform": "ttsc-plugin-hello"
}
},
"files": ["plugin.cjs", "plugin"],
"engines": {
"node": ">=18"
}
}The files field includes the Go source because ttsc builds it on the consumer machine. The name field must equal the string consumers will install you under β see Concepts β Auto-discovery for the rule and why a mismatch silently no-ops. ttsc.plugin is the auto-discovery marker; a matching compilerOptions.plugins[] entry in tsconfig.json takes priority.
2. Write the Descriptor
plugin.cjs:
const path = require("node:path");
module.exports = function createHelloPlugin() {
return {
name: "ttsc-plugin-hello",
source: path.resolve(__dirname, "plugin"),
stage: "transform",
};
};nameβ human-readable name shown in errors and logs.sourceβ Go command package directory. Absolute, derived from__dirname.stage: "transform"β participate in the AST transform path. The host will spawntransformandbuildsubcommands.
3. Write the Go Module
plugin/go.mod:
module ttsc-plugin-hello
go 1.26
require (
github.com/samchon/ttsc/packages/ttsc v0.0.0
github.com/microsoft/typescript-go/shim/printer v0.0.0
)ttsc supplies the v0.0.0 modules through its generated go.work overlay while it builds the plugin. The go 1.26 directive matches the bundled toolchain that ships with ttsc; you do not need a system Go install at that version because the bundled SDK satisfies the directive at build time. For local editor support (gopls, go test directly), see Local development.
4. Implement the Plugin Commands
plugin/main.go:
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
shimprinter "github.com/microsoft/typescript-go/shim/printer"
"github.com/samchon/ttsc/packages/ttsc/driver"
)
type transformResult struct {
Diagnostics []any `json:"diagnostics,omitempty"`
TypeScript map[string]string `json:"typescript"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "ttsc-plugin-hello: command required")
os.Exit(2)
}
switch os.Args[1] {
case "version", "-v", "--version":
fmt.Println("ttsc-plugin-hello 0.1.0")
case "check":
// Transform-stage plugins receive "check" with no-op semantics.
case "transform":
os.Exit(runTransform(os.Args[2:]))
case "build":
os.Exit(runBuild(os.Args[2:]))
default:
fmt.Fprintf(os.Stderr, "ttsc-plugin-hello: unknown command %q\n", os.Args[1])
os.Exit(2)
}
}
func runTransform(args []string) int {
cwd, tsconfig := readFlags(args)
prog, _, err := driver.LoadProgram(cwd, tsconfig, driver.LoadProgramOptions{})
if err != nil {
fmt.Fprintf(os.Stderr, "ttsc-plugin-hello: %v\n", err)
return 2
}
defer prog.Close()
printer := shimprinter.NewPrinter(
shimprinter.PrinterOptions{},
shimprinter.PrintHandlers{},
nil,
)
out := transformResult{TypeScript: map[string]string{}}
for _, file := range prog.SourceFiles() {
if file == nil || file.IsDeclarationFile {
continue
}
out.TypeScript[outputKey(cwd, file.FileName())] = shimprinter.EmitSourceFile(printer, file)
}
data, err := json.Marshal(out)
if err != nil {
fmt.Fprintf(os.Stderr, "ttsc-plugin-hello: marshal failed: %v\n", err)
return 3
}
fmt.Println(string(data))
return 0
}
func runBuild(args []string) int {
cwd, tsconfig := readFlags(args)
prog, _, err := driver.LoadProgram(cwd, tsconfig, driver.LoadProgramOptions{})
if err != nil {
fmt.Fprintf(os.Stderr, "ttsc-plugin-hello: %v\n", err)
return 2
}
defer prog.Close()
if _, emitDiags, err := prog.EmitAllRaw(nil); err != nil {
fmt.Fprintf(os.Stderr, "ttsc-plugin-hello: emit failed: %v\n", err)
return 3
} else if len(emitDiags) > 0 {
for _, d := range emitDiags {
fmt.Fprintln(os.Stderr, d.String())
}
return 2
}
return 0
}
// readFlags is a minimal parser: it reads --cwd and --tsconfig in equals form
// and ignores every other host flag. The end-to-end page upgrades this to a
// table-driven version that handles space-form values too.
func readFlags(args []string) (cwd, tsconfig string) {
tsconfig = "tsconfig.json"
for _, a := range args {
switch {
case strings.HasPrefix(a, "--cwd="):
cwd = strings.TrimPrefix(a, "--cwd=")
case strings.HasPrefix(a, "--tsconfig="):
tsconfig = strings.TrimPrefix(a, "--tsconfig=")
}
}
if cwd == "" {
cwd, _ = os.Getwd()
}
if !filepath.IsAbs(tsconfig) {
tsconfig = filepath.Join(cwd, tsconfig)
}
return cwd, tsconfig
}
func outputKey(cwd, fileName string) string {
rel, err := filepath.Rel(cwd, fileName)
if err != nil || strings.HasPrefix(rel, "..") {
return filepath.ToSlash(fileName)
}
return filepath.ToSlash(rel)
}What matters:
- The plugin calls
driver.LoadProgramβ the canonical bootstrap. See Reference β Driver API. shimprinter.EmitSourceFilerenders each*SourceFileback to text. This is the only way to surface AST changes through thetransformsubcommand β the host treats the value at eachtypescript[path]as opaque text and does not re-render.defer prog.Close()releases the leased checker back to the pool. Without it, subsequent loads stall.runBuildusesprog.EmitAllRaw(nil)to let tsgo own JavaScript, declaration, and source-map printing.- The
versionsubcommand is a smoke verb (go run ./plugin version); the host never invokes it. - The
defaultcase rejects unknown commands with exit 2 β includingfix/format, which the host only spawns onstage: "check"plugins.
5. Use It
Consumer install:
npm i -D ttsc @typescript/native-preview /path/to/ttsc-plugin-helloConsumer package.json:
{
"name": "hello-consumer",
"private": true,
"devDependencies": {
"ttsc": "^0.11.0",
"@typescript/native-preview": "^0.4.0",
"ttsc-plugin-hello": "0.1.0"
}
}Consumer tsconfig.json β use the explicit form so the wiring is unambiguous:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"plugins": [{ "transform": "ttsc-plugin-hello" }]
},
"include": ["src"]
}Auto-discovery via package.json#dependencies also works (Concepts β Auto-discovery), but the explicit form mirrors every fixture under tests/projects/.
Consumer src/main.ts:
export const value = 1;Run:
npx ttsc --emitThe first run builds and caches the Go binary. Later runs reuse it until the plugin source, ttsc version, TypeScript-Go version, platform, or source entry changes. dist/main.js should contain export const value = 1 rendered through tsgo.
6. Verify with a Test
End the cycle with a real test that asserts on observable output β every regression in this repo follows the AGENTS.md Β§2.2 testing shape. One Go unit test for the pure transform logic, one end-to-end test that spawns ttsc against a fixture. See Testing for the full pattern.
Next
β End-to-end source-transform plugin β the next exercise: the same shape with debugger removal via real AST mutation.
β Recipes β copyable patterns (auto-discovery, driver.NewLintDiagnostic, leaf-text mutation invariant, re-spawning ttsx / tsgo).
β AST & Checker β when you need to inspect node kinds, walk the tree, or query types.