AST and Checker Guide
This is the deep plugin-author chapter. Read it when your plugin needs to understand TypeScript source, walk AST nodes, inspect declarations, query types, or produce diagnostics tied to source ranges.
This page covers the implementation โ how to bootstrap a Program, walk the AST, call the Checker, and emit diagnostics. If you havenโt yet decided whether you need the Checker at all, see Concepts โ Do I need the Checker? first.
For a small source-AST transform, start with Getting Started. For the four shipped examples, read Walkthroughs. For the curated Go-side surface, see Reference โ Driver API.
Choosing the Right Level
Use the smallest surface that answers your question:
| Need | Use | Example |
|---|---|---|
| Add a package banner | source JSDoc before TypeScript-Go parses the file | @ttsc/banner |
Remove console.log(...) statements | source AST statement filtering | @ttsc/strip |
Rewrite paths aliases for JS and declaration emit | source AST plus project config/Program | @ttsc/paths |
| Report source diagnostics | Program + AST + diagnostics writer | @ttsc/lint |
Generate code from T in foo<T>() | Program + AST + Checker | semantic transformer plugins |
The AST is not a string parser. Use it when structure matters: statement kind, callee shape, declaration members, type argument syntax, import/export syntax, or diagnostic ranges.
The Checker is not a faster AST. Use it when meaning matters: aliases, inherited properties, resolved symbols, unions, intersections, instantiated generics, or apparent properties.
Shim Model
TypeScript-Go internals are Go packages, many of them internal. ttsc exposes a narrow shim boundary under github.com/microsoft/typescript-go/shim/....
Plugin source imports the shim:
import (
shimast "github.com/microsoft/typescript-go/shim/ast"
shimchecker "github.com/microsoft/typescript-go/shim/checker"
shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
shimcore "github.com/microsoft/typescript-go/shim/core"
shimparser "github.com/microsoft/typescript-go/shim/parser"
shimprinter "github.com/microsoft/typescript-go/shim/printer"
shimscanner "github.com/microsoft/typescript-go/shim/scanner"
)Your go.mod must require every shim package you import:
module my-plugin
go 1.26
require (
github.com/microsoft/typescript-go/shim/ast v0.0.0
github.com/microsoft/typescript-go/shim/checker v0.0.0
github.com/microsoft/typescript-go/shim/compiler v0.0.0
github.com/microsoft/typescript-go/shim/core v0.0.0
github.com/microsoft/typescript-go/shim/parser v0.0.0
github.com/microsoft/typescript-go/shim/printer v0.0.0
github.com/microsoft/typescript-go/shim/scanner v0.0.0
)v0.0.0 is intentional. The modules are supplied by ttscโs generated go.work overlay at build time. For local editor support, create your own go.work; see Editor setup.
Useful shim sub-packages:
| Shim | Use |
|---|---|
shim/ast | SourceFile, Node, Kind*, typed accessors like AsCallExpression |
shim/parser | parse JS or TS text into a SourceFile when the plugin owns that text |
shim/scanner | token positions, trivia skipping, line/column mapping, source text helpers |
shim/printer | render source from a (possibly mutated) AST. The only way to surface AST mutations through the transform subcommand. |
shim/tsoptions | parse tsconfig.json |
shim/tspath | project-native path comparison and combination (ComparePaths, CombinePaths, ChangeExtension). Prefer over filepath when paths must match tsgoโs resolution behavior. |
shim/compiler | low-level Program creation. Most plugins should call driver.LoadProgram instead. |
shim/checker | query symbols and types. The Checker type is a Go alias for tsgoโs internal *Checker. |
shim/diagnosticwriter | render compiler-like diagnostics. driver.WritePrettyDiagnostics wraps this. |
shim/bundled | TypeScript lib files for Program creation. driver.DefaultFS / driver.DefaultHost wrap this. |
shim/vfs | the FS interface plus DirEntry, WalkDirFunc, SkipDir / SkipAll sentinels. Use when implementing a custom VFS. |
shim/lsp | Legacy in-process LSP access for custom host experiments โ not used by ttscserver and not for plugin authors. |
Do not import github.com/microsoft/typescript-go/internal/... directly. The shim is the plugin boundary.
Bootstrap with the driver
The canonical bootstrap is driver.LoadProgram. It returns a *driver.Program that already owns the parsed config, the leased *shimchecker.Checker, the compiler host, and the VFS โ and folds in ForceEmit / ForceNoEmit / OutDir / SourcePreamble overrides:
import "github.com/samchon/ttsc/packages/ttsc/driver"
prog, parseDiags, err := driver.LoadProgram(cwd, "tsconfig.json", driver.LoadProgramOptions{})
if err != nil { /* fatal: tsconfig parse error */ }
if len(parseDiags) > 0 {
driver.WritePrettyDiagnostics(os.Stderr, parseDiags, cwd)
return 2
}
defer prog.Close() // releases the leased Checker
for _, file := range prog.SourceFiles() { /* file is non-declaration only */ }
diags := prog.Diagnostics() // pre-deduped, unused-overload-signature noise filtered
checker := prog.Checker // ready to queryprog.SourceFiles() filters declaration files for you. prog.Diagnostics() runs GetDiagnosticsOfAnyProgram + SortAndDeduplicateDiagnostics. Always defer prog.Close() โ without it the checker pool fills up and subsequent loads stall.
Every shipped third-party-shaped consumer in this repo uses this path:
packages/ttsc/utility/host.goโ the generic linked transform host (loadUtilityProgram).packages/lint/linthost/host.goโ production diagnostics engine.tests/projects/go-source-plugin-checker/go-plugin/main.goโ compact Program/Checker fixture.
For the full driver surface (rewrites, emit, source preambles, LSP host), see Reference โ Driver API.
Finding the Target File
prog.SourceFiles() returns every user file in the Program (declaration files are filtered). Normalize paths before comparing:
func findSourceFile(prog *driver.Program, target string) *shimast.SourceFile {
want := filepath.ToSlash(target)
for _, file := range prog.SourceFiles() {
if file == nil {
continue
}
if filepath.ToSlash(file.FileName()) == want {
return file
}
}
return nil
}AST Basics
The main types are:
*shimast.SourceFile: one parsed file.*shimast.Node: generic AST node.shimast.Kind: enum describing the node shape.NodeList: list wrapper whoseNodesfield contains child nodes.
Core access pattern:
if node.Kind == shimast.KindCallExpression {
call := node.AsCallExpression()
if call != nil {
// call.Expression, call.Arguments, ...
}
}Use Kind before a typed accessor. Accessors usually return nil when the kind does not match.
Important Node data:
node.Kind: syntactic kind.node.Pos(): start offset, often including leading trivia.node.End(): end offset.node.Parent: parent node when available.node.Symbol(): bound symbol for declarations (nil on non-declarations).node.ForEachChild(fn): visit child nodes.node.Text(): source text for identifier/literal-like kinds โ branch onnode.Kindfirst; panics on other kinds. See Pitfalls โ AST and Checker for both nil-Symbol and Text-panic recovery patterns.
Important SourceFile data:
file.FileName(): normalized path.file.Text(): full source text.file.Statements.Nodes: top-level statements.file.IsDeclarationFile: true for.d.ts/ library declarations.file.AsNode(): use when a rule operates on theSourceFilenode itself.
Traversal Pattern
For top-level declarations:
func collectInterfaces(file *shimast.SourceFile) map[string]*shimast.InterfaceDeclaration {
out := map[string]*shimast.InterfaceDeclaration{}
if file == nil || file.Statements == nil {
return out
}
for _, stmt := range file.Statements.Nodes {
if stmt == nil || stmt.Kind != shimast.KindInterfaceDeclaration {
continue
}
decl := stmt.AsInterfaceDeclaration()
if decl == nil || decl.Name() == nil {
continue
}
out[decl.Name().Text()] = decl
}
return out
}For full-tree traversal:
func walk(node *shimast.Node, visit func(*shimast.Node)) {
if node == nil {
return
}
visit(node)
node.ForEachChild(func(child *shimast.Node) bool {
walk(child, visit)
return false // keep visiting siblings
})
}
func walkFile(file *shimast.SourceFile, visit func(*shimast.Node)) {
if file == nil || file.Statements == nil {
return
}
for _, stmt := range file.Statements.Nodes {
walk(stmt, visit)
}
}@ttsc/lint uses this shape and dispatches rules by node.Kind; see packages/lint/linthost/engine.go. Note that the engine builds a map[Kind]bool from each ruleโs Visits() โ duplicate kinds are silently deduplicated, see Pitfalls.
Text Ranges and Trivia
node.Pos() includes leading whitespace and comments. For diagnostic ranges, advance past trivia with SkipTrivia:
// Canonical token-start. Used everywhere in packages/lint/linthost/ โ
// ast_helpers.go, engine.go, every rules_format_*.go.
start := shimscanner.SkipTrivia(file.Text(), node.Pos())
end := node.End()Node.Pos() is the position before leading trivia; SkipTrivia walks past whitespace and comment trivia and returns the first significant tokenโs offset. GetTokenPosOfNode is also valid but no production code in this repo uses it for diagnostic ranges โ prefer SkipTrivia for consistency.
For line/column diagnostics:
line, col := shimscanner.GetECMALineAndByteOffsetOfPosition(file, node.Pos())
_ = line
_ = colHelpers worth borrowing from the lint plugin:
packages/lint/linthost/ast_helpers.gonodeTextidentifierTextstripParensisMatchingPropertyAccess
Recognizing Calls
A call expression has a callee in call.Expression and arguments in call.Arguments.
func callName(expr *shimast.Node) (string, bool) {
if expr == nil || expr.Kind != shimast.KindCallExpression {
return "", false
}
call := expr.AsCallExpression()
return dottedName(call.Expression)
}
func dottedName(node *shimast.Node) (string, bool) {
if node == nil {
return "", false
}
switch node.Kind {
case shimast.KindIdentifier:
return node.Text(), true
case shimast.KindPropertyAccessExpression:
access := node.AsPropertyAccessExpression()
left, ok := dottedName(access.Expression)
if !ok || access.Name() == nil {
return "", false
}
return left + "." + access.Name().Text(), true
default:
return "", false
}
}node.Text() is safe inside the case shimast.KindIdentifier: branch because the kind check above filters out the panic-prone shapes. This recognizes:
console.logassert.equalfoo
It does not treat obj["log"]() or optional chaining as the same shape. Add those cases only when your plugin needs them.
Reference: packages/strip/driver/strip.go for stripRewriter, callExpressionName, and statement filtering.
Recognizing Imports and Module Specifiers
@ttsc/paths rewrites specifiers in many syntax forms:
import x from "pkg"export { x } from "pkg"import x = require("pkg")type T = import("pkg").Tdeclare module "pkg"require("pkg")await import("pkg")
The common operation is: find a string literal node, compute the literal text range, replace only that range, and preserve quote style.
func stringLiteralRange(text string, node *shimast.Node) (int, int, byte, bool) {
start := clamp(node.Pos(), 0, len(text))
end := clamp(node.End(), start, len(text))
for start < end && text[start] != '"' && text[start] != '\'' {
start++
}
if start >= end {
return 0, 0, 0, false
}
quote := text[start]
for end > start+1 && text[end-1] != quote {
end--
}
if end <= start+1 {
return 0, 0, 0, false
}
return start, end, quote, true
}
func clamp(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}Reference: packages/paths/driver/paths.go for pathsRewriter, module-specifier walking, and source-to-output path mapping.
TypeScript Type Syntax
Type nodes are AST syntax. They tell you what the user wrote, not necessarily what the type means after alias resolution.
Common type-node kinds:
KindTypeReference:User,Array<User>,Record<string, User>KindTypeLiteral:{ id: string }KindInterfaceDeclaration:interface User { ... }KindTypeAliasDeclaration:type User = ...KindUnionType:A | BKindIntersectionType:A & BKindArrayType:T[]KindTupleType:[A, B]KindLiteralType:"x",1,trueKindFunctionType:(x: string) => number
Lexical interface property extraction:
func propertyNames(decl *shimast.InterfaceDeclaration) []string {
if decl == nil || decl.Members == nil {
return nil
}
out := []string{}
for _, member := range decl.Members.Nodes {
if member == nil || member.Kind != shimast.KindPropertySignature {
continue
}
prop := member.AsPropertySignatureDeclaration()
if prop == nil || prop.Name() == nil {
continue
}
out = append(out, prop.Name().Text())
}
return out
}This only sees properties written directly in that interface body. It does not expand extends, intersections, mapped types, or aliases. Use the Checker for that.
Reference: tests/projects/go-source-plugin-properties/go-plugin/main.go.
Checker Basics
The Checker answers semantic questions.
The shimโs Checker is a Go type alias for tsgoโs internal *Checker (see packages/ttsc/shim/checker/shim.go, which declares type Checker = innerchecker.Checker). Every exported method on the inner checker is callable directly on ctx.Checker or prog.Checker: GetTypeAtLocation(node), GetPropertyOfType(t, name), GetSignaturesOfType(t, kind), GetReturnTypeOfSignature(sig), GetAwaitedType(t), and so on. The Checker_* wrappers below exist to reach unexported tsgo helpers via go:linkname; reach for them only when the direct method form is missing. The repoโs most type-aware rule, packages/lint/linthost/rules_promise.go, uses zero Checker_* wrappers and is the canonical model.
The AST-to-Checker flow usually looks like this:
- Find a node in the AST.
- Get a symbol from the node or resolve a name through the Checker.
- Get a
*checker.Typefrom the symbol or node. - Query properties, type arguments, flags, or index info.
Common Checker patterns
Each pattern below cites the production-rule call site so you can read the working form.
Signatures.
sigs := checker.GetSignaturesOfType(t, shimchecker.SignatureKindCall)
if len(sigs) > 0 {
ret := checker.GetReturnTypeOfSignature(sigs[0])
_ = checker.GetParameterType(sigs[0], 0)
_ = checker.GetTypePredicateOfSignature(sigs[0])
_ = ret
}Used by rules_promise.go to detect callable .then shapes.
Contextual types.
ctx := checker.GetContextualType(expr, 0)
// Adjacent: GetContextualTypeForObjectLiteralElement, GetContextualTypeForArgumentAtIndex.Used when the surrounding expression carries type information the node itself does not.
Narrowing.
narrowed := checker.GetNarrowedTypeOfSymbol(sym, location)
// Adjacent: GetFlowTypeOfReference(reference, declaredType) for flow-sensitive types.Type predicates / assertions.
if checker.IsTypeAssignableTo(a, b) { /* ... */ }
// Adjacent: IsTypeSubtypeOf, GetTypePredicateOfSignature.Awaited / promise.
awaited := checker.GetPromisedTypeOfPromise(t, nil)
// Adjacent: GetAwaitedType(t).Used by rules_promise.go to validate await expr for non-thenable expressions.
Long-tail wrappers: Checker_*
When a tsgo method you need is unexported, the shim provides a Checker_* wrapper that reaches it via go:linkname. Today this includes:
Checker_getPropertiesOfType(checker, typ)Checker_getApparentProperties(checker, typ)Checker_getTypeOfSymbol(checker, symbol)Checker_getTypeOfSymbolAtLocation(checker, symbol, node)Checker_getTypeOfPropertyOfType(checker, typ, name)Checker_getTypeArguments(checker, typ)Checker_getIndexInfosOfType(checker, typ)Checker_resolveEntityName(checker, name, meaning, ignoreErrors, dontResolveAlias, location)Checker_getAliasedSymbol(checker, symbol)Checker_isArrayType(checker, typ)IsTupleType(typ)Type_getTypeNameSymbol(typ)
If you need an unexported method that is not yet shimmed, prefer the direct-method form on a related exported alternative; failing that, file a shim addition.
Worked example: await-thenable
// Detect: await expr where expr's type has no callable `.then` member.
func isThenable(checker *shimchecker.Checker, expr *shimast.Node) bool {
t := checker.GetTypeAtLocation(expr)
thenSym := checker.GetPropertyOfType(t, "then")
if thenSym == nil {
return false
}
thenT := checker.GetTypeOfSymbol(thenSym)
sigs := checker.GetSignaturesOfType(thenT, shimchecker.SignatureKindCall)
return len(sigs) > 0
}Source: packages/lint/linthost/rules_promise.go::awaitableType. The example exercises three Checker categories (GetTypeAtLocation, GetPropertyOfType, GetSignaturesOfType) on real production code.
Mutating the AST
Structural mutations โ filtering Statements.Nodes, swapping children โ round-trip cleanly through shimprinter.EmitSourceFile because the printer iterates statement lists directly without reading source spans. Leaf-text mutations (identifier, string-literal, or numeric-literal .Text) need the synthesize-flag + reset-Loc invariant; see Recipes โ Mutating leaf-text nodes.
Building Diagnostics
For lint-style diagnostics through the driver, the path is three lines:
var diags []driver.Diagnostic
diags = append(diags, driver.NewLintDiagnostic(
file, start, end,
/*code*/ 9001, driver.SeverityError,
"[my-rule] explanation here",
))
driver.WritePrettyDiagnostics(os.Stderr, diags, cwd)
if driver.CountErrors(diags) > 0 { os.Exit(1) }driver.NewLintDiagnostic wraps (pos, end) into a Diagnostic with the fileโs path. driver.CountErrors counts the diagnostics that should fail the build (every entry that isnโt an explicit SeverityWarning). driver.WritePrettyDiagnostics renders to any io.Writer using the same colorful format ttsc itself uses; it returns nothing. See Reference โ Driver API โ Diagnostics.
If your plugin exits non-zero, write clear diagnostics to stderr. ttsc surfaces stderr directly. Unrecovered Go panics surface as exit-2 with the stack on stderr; the host does not catch them.
Raw form (without the driver)
For plugins that bypass driver.LoadProgram, the raw pipeline is:
diags := shimcompiler.GetDiagnosticsOfAnyProgram(program)
diags = append(diags, lintFindings...)
diags = shimcompiler.SortAndDeduplicateDiagnostics(diags)
shimdiagnosticwriter.FormatMixedDiagnostics(os.Stderr, diags, cwd)Use only when you skipped driver.LoadProgram. Reference: packages/lint/linthost/compile.go and packages/ttsc/shim/diagnosticwriter/lint.go.
Text Edits
Prefer AST mutation for syntax changes. Use text edits only when the plugin owns the source text workflow and the change is naturally range-based.
Edit type:
type textEdit struct {
start int
end int
text string
}Apply from the end of the file to the start:
func applyTextEdits(text string, edits []textEdit) string {
sort.SliceStable(edits, func(i, j int) bool {
if edits[i].start == edits[j].start {
return edits[i].end > edits[j].end
}
return edits[i].start > edits[j].start
})
out := text
for _, edit := range edits {
if edit.start < 0 || edit.end < edit.start || edit.end > len(out) {
continue
}
out = out[:edit.start] + edit.text + out[edit.end:]
}
return out
}Why reverse order: earlier edits do not shift the offsets of later edits that are already applied.
For statement removal, prefer filtering the parent NodeList instead of slicing emitted text. @ttsc/strip is the reference.
Parsing Text Without a Program
A standalone tool can parse a single JS or TS text file without loading a full Program:
func parseJS(fileName, text string) *shimast.SourceFile {
opts := shimast.SourceFileParseOptions{
FileName: filepath.ToSlash(fileName),
}
return shimparser.ParseSourceFile(opts, text, shimcore.ScriptKindJS)
}Use this only when the text itself is the plugin-owned input. The public ttsc plugin contract does not expose generated JavaScript text as a transform target.
Use a Program when you need project-level facts, such as compilerOptions.paths, source file membership, declaration emit mapping, or semantic types. @ttsc/paths and @ttsc/lint are the models.
Common AST Mistakes
The page-level traps:
- Comparing raw paths without
filepath.ToSlash. - Slicing
file.Text()[node.Pos():node.End()]and accidentally including comments/whitespace. - Calling
AsX()and assuming it cannot be nil. - Walking declaration files when you meant user code (use
prog.SourceFiles(); it filters them). - Treating a type node as a resolved type.
- Editing text when an AST mutation would express the same change.
- Editing text from the start of the file toward the end.
The runtime traps โ node.Text() panic, node.Symbol() nil deref, duplicate-Kind in Visits(), leaf-text mutation without the synthesize flag, missing defer prog.Close() โ live in Reference โ Pitfalls โ AST and Checker with the exact error strings.
If you cannot use the driver
For plugins that need a custom checker pool, an in-memory-only Program (no tsconfig.json), or that swap the compiler host entirely, drop down to the raw shim:
import (
"context"
"fmt"
"path/filepath"
"github.com/microsoft/typescript-go/shim/bundled"
shimast "github.com/microsoft/typescript-go/shim/ast"
shimchecker "github.com/microsoft/typescript-go/shim/checker"
shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
shimcore "github.com/microsoft/typescript-go/shim/core"
"github.com/microsoft/typescript-go/shim/tsoptions"
"github.com/microsoft/typescript-go/shim/vfs/cachedvfs"
"github.com/microsoft/typescript-go/shim/vfs/osvfs"
)
type loadedProgram struct {
cwd string
program *shimcompiler.Program
checker *shimchecker.Checker
release func()
}
func loadProgramRaw(cwd, tsconfigPath string) (*loadedProgram, []*shimast.Diagnostic, error) {
if !filepath.IsAbs(cwd) {
abs, err := filepath.Abs(cwd)
if err != nil { return nil, nil, err }
cwd = abs
}
if !filepath.IsAbs(tsconfigPath) {
tsconfigPath = filepath.Join(cwd, tsconfigPath)
}
fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
host := shimcompiler.NewCompilerHost(cwd, fs, bundled.LibPath(), nil, nil)
parsed, parseDiags := tsoptions.GetParsedCommandLineOfConfigFile(
tsconfigPath, &shimcore.CompilerOptions{}, nil, host, nil,
)
if parsed == nil { return nil, nil, fmt.Errorf("tsconfig parse returned nil for %s", tsconfigPath) }
if len(parseDiags) > 0 { return nil, parseDiags, nil }
if len(parsed.Errors) > 0 { return nil, parsed.Errors, nil }
program := shimcompiler.NewProgram(shimcompiler.ProgramOptions{
Config: parsed,
SingleThreaded: shimcore.TSTrue,
Host: host,
UseSourceOfProjectReference: true,
})
if program == nil { return nil, nil, fmt.Errorf("compiler.NewProgram returned nil") }
checker, release := program.GetTypeChecker(context.Background())
return &loadedProgram{
cwd: cwd,
program: program,
checker: checker,
release: release,
}, nil, nil
}Always call defer release() after a successful raw load. TypeScript-Go leases checker resources internally.
Reference for the raw bootstrap:
tests/projects/go-source-plugin-checker/go-plugin/main.goโ compact fixture.
Study Path
Read these in order:
packages/bannerโ source JSDoc preamble insertion (consumer behavior).packages/banner/driver,packages/strip/driver, andpackages/paths/driverโ linked transform implementations.packages/ttsc/utility/host.goโ the generic host that loads linked transforms.tests/projects/go-source-plugin-checkerโ Program/Checker bootstrap.tests/projects/go-source-plugin-propertiesโ interface AST walk.packages/lint/linthostโ full diagnostics engine.