Skip to Content

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:

NeedUseExample
Add a package bannersource JSDoc before TypeScript-Go parses the file@ttsc/banner
Remove console.log(...) statementssource AST statement filtering@ttsc/strip
Rewrite paths aliases for JS and declaration emitsource AST plus project config/Program@ttsc/paths
Report source diagnosticsProgram + AST + diagnostics writer@ttsc/lint
Generate code from T in foo<T>()Program + AST + Checkersemantic 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:

ShimUse
shim/astSourceFile, Node, Kind*, typed accessors like AsCallExpression
shim/parserparse JS or TS text into a SourceFile when the plugin owns that text
shim/scannertoken positions, trivia skipping, line/column mapping, source text helpers
shim/printerrender source from a (possibly mutated) AST. The only way to surface AST mutations through the transform subcommand.
shim/tsoptionsparse tsconfig.json
shim/tspathproject-native path comparison and combination (ComparePaths, CombinePaths, ChangeExtension). Prefer over filepath when paths must match tsgoโ€™s resolution behavior.
shim/compilerlow-level Program creation. Most plugins should call driver.LoadProgram instead.
shim/checkerquery symbols and types. The Checker type is a Go alias for tsgoโ€™s internal *Checker.
shim/diagnosticwriterrender compiler-like diagnostics. driver.WritePrettyDiagnostics wraps this.
shim/bundledTypeScript lib files for Program creation. driver.DefaultFS / driver.DefaultHost wrap this.
shim/vfsthe FS interface plus DirEntry, WalkDirFunc, SkipDir / SkipAll sentinels. Use when implementing a custom VFS.
shim/lspLegacy 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 query

prog.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:

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 whose Nodes field 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 on node.Kind first; 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 the SourceFile node 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 _ = col

Helpers worth borrowing from the lint plugin:

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.log
  • assert.equal
  • foo

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").T
  • declare 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 | B
  • KindIntersectionType: A & B
  • KindArrayType: T[]
  • KindTupleType: [A, B]
  • KindLiteralType: "x", 1, true
  • KindFunctionType: (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:

  1. Find a node in the AST.
  2. Get a symbol from the node or resolve a name through the Checker.
  3. Get a *checker.Type from the symbol or node.
  4. 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:

Study Path

Read these in order:

  1. packages/banner โ€” source JSDoc preamble insertion (consumer behavior).
  2. packages/banner/driver, packages/strip/driver, and packages/paths/driver โ€” linked transform implementations.
  3. packages/ttsc/utility/host.go โ€” the generic host that loads linked transforms.
  4. tests/projects/go-source-plugin-checker โ€” Program/Checker bootstrap.
  5. tests/projects/go-source-plugin-properties โ€” interface AST walk.
  6. packages/lint/linthost โ€” full diagnostics engine.
Last updated on