Skip to Content

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, name mismatch) 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.go

package.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 spawn transform and build subcommands.

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.EmitSourceFile renders each *SourceFile back to text. This is the only way to surface AST changes through the transform subcommand β€” the host treats the value at each typescript[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.
  • runBuild uses prog.EmitAllRaw(nil) to let tsgo own JavaScript, declaration, and source-map printing.
  • The version subcommand is a smoke verb (go run ./plugin version); the host never invokes it.
  • The default case rejects unknown commands with exit 2 β€” including fix / format, which the host only spawns on stage: "check" plugins.

5. Use It

Consumer install:

npm i -D ttsc @typescript/native-preview /path/to/ttsc-plugin-hello

Consumer 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 --emit

The 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.

Last updated on