Skip to Content

@ttsc/strip: Statement Removal

@ttsc/strip deletes debugger; statements and call expressions whose callee matches a pattern like console.log or assert.* from TypeScript source before emit. The package introduces the patterns every “remove a syntactic construct” plugin uses: matching call shapes, filtering NodeList.Nodes, recursing into block-bodied statements, and the embedded-statement substitution trick (replacing if (x) console.log(y) with if (x); rather than if (x), which would be invalid).

If you completed @ttsc/banner, the standalone main.go dispatcher will look familiar, strip’s is identical except for the package name. The published descriptor points at linked driver/ source, so the new material on this page is the linked rewriter in driver/strip.go.

What it does for the consumer

With no plugin options, @ttsc/strip removes the default patterns: console.log, console.debug, assert.*, and debugger;.

// tsconfig.json — opt in with defaults { "compilerOptions": { "outDir": "dist", "plugins": [{ "transform": "@ttsc/strip" }], }, }
// src/main.ts (before) console.log("hello"); debugger; export const value = 1;
// dist/main.js (after) export const value = 1;

Both statements vanish. The export survives.

For a custom call list, drop a strip.config.ts next to your tsconfig.json:

// strip.config.ts import type { ITtscStripConfig } from "@ttsc/strip"; export default { calls: ["console.log", "console.debug", "myLogger.*"], statements: ["debugger"], } satisfies ITtscStripConfig;

Two configuration knobs:

  • calls: match call statements by dotted name. console.log matches exactly console.log(...); myLogger.* matches myLogger.<anything>(...) for any single property access. Wildcards are only supported at the end of a pattern.
  • statements: match statement-level syntax. Today only debugger is supported; the slot exists so future statement kinds can be added without a breaking change.

@ttsc/strip discovers its config by walking upward from the tsconfig directory (or from the launcher-provided TTSC_PLUGIN_CONFIG_DIR project root when the compile runs through a generated wrapper tsconfig), looking for strip.config.{ts,cts,mts,js,cjs,mjs,json}. To point at a specific file, set configFile on the tsconfig entry: { "transform": "@ttsc/strip", "configFile": "./config/strip.config.ts" }.

Directory layout

packages/strip/ ├── package.json ├── src/ │ ├── index.cjs ← JS descriptor (hand-written CommonJS) │ └── index.d.ts ← hand-written types (exports ITtscStripConfig) ├── go.mod ├── driver/ │ ├── strip.go ← Linked transform logic │ └── config.go ← strip.config.* file loading └── plugin/ ├── main.go ← 36-line dispatcher └── strip.go ← Blank-imports the linked driver package

Strip’s descriptor is a hand-written CommonJS file with a hand-written index.d.ts alongside it, no lib/, no compile step. The .d.ts exports ITtscStripConfig, the shape of a strip.config.* file.

The JS descriptor (src/index.cjs)

// @ts-check "use strict"; const path = require("node:path"); // Keys the tsconfig plugin entry accepts — all others are rejected so a // stale inline option surfaces as a clear error instead of a silent default. const ALLOWED_TSCONFIG_KEYS = new Set([ "configFile", "enabled", "name", "stage", "transform", ]); module.exports = function createTtscStrip(context) { const plugin = context && typeof context === "object" && context.plugin != null ? context.plugin : {}; for (const key of Object.keys(plugin)) { if (!ALLOWED_TSCONFIG_KEYS.has(key)) { throw new Error( `@ttsc/strip: tsconfig plugin entry contains unsupported key ` + `${JSON.stringify(key)}; strip configuration must be supplied via ` + `a strip.config.* file (use the "configFile" key for a custom path)`, ); } } return { name: "@ttsc/strip", source: path.resolve(context.dirname, "..", "driver"), stage: "transform", }; };

This is the same descriptor shape as banner, written in CommonJS. The // @ts-check pragma asks the TypeScript checker to validate this file even though it has no .ts source. Useful when the manifest is hand-edited.

module.exports is CommonJS’s “default export”. The host calls this function to get the descriptor object.

The standalone sidecar (plugin/main.go)

Identical structure to banner’s standalone sidecar, with @ttsc/strip in the package name and error messages. Consumer builds use the linked driver/ source through the selected native host; this command is still useful as a package test target and executable-sidecar example:

package main import ( "fmt" "os" "github.com/samchon/ttsc/packages/ttsc/utility" ) const version = "0.0.1" func main() { os.Exit(run(os.Args[1:])) } func run(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "@ttsc/strip: command required (expected build|transform|check|version)") return 2 } switch args[0] { case "-v", "--version", "version": fmt.Fprintf(os.Stdout, "@ttsc/strip %s\n", version) return 0 case "build": return utility.RunBuild(args[1:]) case "transform": return utility.RunTransform(args[1:]) case "check": return utility.RunCheck(args[1:]) default: fmt.Fprintf(os.Stderr, "@ttsc/strip: unknown command %q\n", args[0]) return 2 } }

Same run shape, same exit codes, same subcommand surface as banner. The interesting code lives in the shared host.

The real logic: stripRewriter in driver/strip.go

The strip-specific code in packages/strip/driver/strip.go is roughly five components. We walk each one in the order they execute at runtime:

  1. Parse the config: load strip.config.* (or use defaults) into a *stripRewriter value.
  2. Apply to every source file: for each SourceFile in the Program, filter top-level statements and recurse.
  3. Recurse into block-bodied statements: function bodies, blocks, etc. Carry their own statement lists.
  4. Substitute embedded statements: if (x) <strip-me>; becomes if (x); (an EmptyStatement) so the parent syntax stays valid.
  5. Match call patterns: turn call.Expression into a dotted name and test against the pattern set.

1. Parsing the config

type stripRewriter struct { calls []callPattern stripDebugger bool } type callPattern struct { parts []string wildcard bool } func parseStrip(config map[string]any) (*stripRewriter, error) { _, hasCalls := config["calls"] _, hasStatements := config["statements"] if !hasCalls && !hasStatements { config = map[string]any{ "calls": []any{"console.log", "console.debug", "assert.*"}, "statements": []any{"debugger"}, } } calls, err := stringArrayConfig(config, "calls") if err != nil { return nil, fmt.Errorf("@ttsc/strip: %w", err) } statements, err := stringArrayConfig(config, "statements") if err != nil { return nil, fmt.Errorf("@ttsc/strip: %w", err) } out := &stripRewriter{} for _, call := range calls { pattern, err := parseCallPattern(call) if err != nil { return nil, fmt.Errorf("@ttsc/strip: %w", err) } out.calls = append(out.calls, pattern) } for _, statement := range statements { switch statement { case "debugger": out.stripDebugger = true default: return nil, fmt.Errorf("@ttsc/strip: unsupported statement pattern %q", statement) } } return out, nil }

Three details:

Default-when-empty. If both calls and statements are absent, strip injects the defaults. The check uses Go’s two-value map lookup:

_, hasCalls := config["calls"]

The second return value is a boolean, true if the key was present, even when the stored value is the zero value (e.g. nil for an any map). This is how Go distinguishes “key absent” from “key present with zero value.”

Error wrapping with %w. fmt.Errorf("@ttsc/strip: %w", err) wraps the inner error so errors.Is and errors.Unwrap can still recover the original. Use %w when you want callers to inspect the cause; %v when you just want a formatted string.

[]any is Go’s unknown[]. config arrives as a map[string]any (decoded from JSON). Every value pulled out needs a type assertion before use: text, ok := raw.(string), arr, ok := raw.([]any). Numbers come out as float64 regardless of the source. The , ok form is mandatory. A bare raw.(string) panics on the wrong type, surfacing as exit 2 with a stack trace the user cannot fix. The helper stringArrayConfig type-asserts each element to string and returns a typed []string; if any element is the wrong type, the helper returns an error. The kind that pops out of parseStrip as @ttsc/strip: "calls"[1] must be a non-empty string. The cleaner alternative for richer configs is json.Marshaljson.Unmarshal into a typed struct; see Recipes → Typed Config.

parseStrip returns *stripRewriter. A pointer to a freshly-allocated struct. The convention is that mutating methods take a pointer receiver:

func (s *stripRewriter) apply(file *shimast.SourceFile) { ... }

The s *stripRewriter is the receiver; calling r.apply(file) is sugar for the method expression (*stripRewriter).apply(r, file), Go passes the receiver r as the first argument behind the scenes. By taking *stripRewriter instead of stripRewriter, the method can mutate the value the caller holds without copying it.

Go has no class keyword and no constructors. You call a function like parseStrip that returns the value. The convention is newX or parseX for these “constructor-like” functions.

2. Apply to every source file

The linked driver registers itself with the host in init() (driver.RegisterPlugin(plugin{})). At build time the host hands the parsed Program to ApplyProgram, the plugin’s single entry point:

func (plugin) ApplyProgram(prog *driver.Program, ctx driver.PluginContext) error { config, err := loadStripConfigMap(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig) if err != nil { return err } rewriter, err := parseStrip(config) if err != nil { return err } for _, file := range prog.SourceFiles() { rewriter.apply(file) } return nil }

loadStripConfigMap (in driver/config.go) rejects unsupported inline keys on the tsconfig entry, then resolves the config file: an explicit configFile wins, otherwise strip.config.* is discovered by upward walk. The resulting map feeds parseStrip from step 1.

prog.SourceFiles() already drops declaration files, so file is always user-authored TypeScript.

Then (*stripRewriter).apply:

func (s *stripRewriter) apply(file *shimast.SourceFile) { if s == nil || file == nil || (len(s.calls) == 0 && !s.stripDebugger) { return } filterStatements(file.Statements, s) }

s == nil check. Defensive. Strip is constructed by parseStrip, so s is normally non-nil; the check is there because Go dispatches a method even when the receiver is nil. The panic only happens at the first field deref. The body does deref s.calls on the next line, but the || short-circuit fires the early return before that ever runs. Catching nil-receivers explicitly is the convention in this codebase; it makes the body’s invariants easy to read.

file.Statements is a *shimast.NodeList. NodeList is a small wrapper: { Pos, End, Nodes []*shimast.Node }. Mutating the slice underneath is how strip removes statements.

3. Filter top-level statements

func filterStatements(list *shimast.NodeList, strip *stripRewriter) { if list == nil || len(list.Nodes) == 0 { return } out := make([]*shimast.Node, 0, len(list.Nodes)) for _, stmt := range list.Nodes { if shouldStripStatement(stmt, strip) { continue } filterChildStatements(stmt, strip) out = append(out, stmt) } list.Nodes = out }
  • make([]T, 0, capacity): allocates the backing array with capacity slots but exposes length 0; append fills slots without reallocation until the cap is exhausted. Useful when you know an upper bound on the final length (here, len(list.Nodes)). Length and capacity are independent; newcomers conflate them.
  • for _, stmt := range list.Nodes: iterate over a slice. The first value is the index (we discard it with _); the second is a copy of the element. For pointer-element slices like []*Node, the copy is the pointer, not the underlying struct. Since Go 1.22 the loop variable stmt is scoped per iteration, so closures capture distinct values each pass. Pre-1.22 code reused one variable across iterations, a classic foot-gun when saving stmt into a goroutine or callback.
  • continue: skip to the next iteration. Here it means “drop this statement; do not append to out.”
  • append(out, stmt): Go’s variadic list-extend. append returns a (possibly new) slice; you must reassign the result.
  • list.Nodes = out: replace the underlying slice. The original list.Nodes is garbage-collected once nothing references it.

The function is in-place: it mutates list.Nodes to a smaller slice and is therefore visible to anyone holding a pointer to the same *NodeList. This is exactly what the printer reads when it renders the file back to TypeScript text.

There is no Node.Remove() API. The shimast package treats AST nodes as plain data; structural mutation is just slice manipulation. This keeps the model simple, the printer iterates Statements.Nodes directly without any deletion bookkeeping, at the cost of forcing the plugin author to think in terms of list rewrites.

For leaf-text mutations (changing an identifier or string literal in place), the picture is different and you need the synthesize-flag invariant. See the @ttsc/paths walkthrough for the canonical pattern.

4. Recurse into block-bodied statements

func filterChildStatements(node *shimast.Node, strip *stripRewriter) { if node == nil { return } filterEmbeddedStatements(node, strip) if node.CanHaveStatements() { filterStatements(node.StatementList(), strip) } node.ForEachChild(func(child *shimast.Node) bool { filterChildStatements(child, strip) return false }) }

Three things happen per node:

  1. filterEmbeddedStatements handles statements that live in single-statement slots (the body of if (x) <stmt>, while (x) <stmt>, etc.). See step 5 below.
  2. node.CanHaveStatements() is true for Block, SourceFile, ModuleDeclaration, CaseClause, etc.: every node kind whose body is a NodeList of statements. When true, recurse into the body’s statement list with the same filterStatements. (CanHaveStatements and StatementList are methods on *shimast.Node itself; the full surface is in AST & Checker.)
  3. node.ForEachChild(fn) iterates child nodes regardless of kind. We recurse into each one to find nested function bodies, lambdas, arrow expressions, etc.

The visitor passes func(child) bool returning false. The contract: returning true short-circuits the iteration; returning false keeps going. Strip never short-circuits, so it always returns false.

node.ForEachChild takes a function literal, Go’s closure syntax:

node.ForEachChild(func(child *shimast.Node) bool { filterChildStatements(child, strip) return false })

Closures in Go capture variables by reference, not by value. The inner function sees later mutations to the captured variable. Here strip is captured so the recursive call still has the rewriter state; nothing mutates strip after the closure is created, so the capture-by-reference detail is invisible. It matters when you capture a loop variable you intend to reassign or pass into a goroutine. The pre-1.22 loop-variable trap mentioned above is exactly this. This closure shape is the standard Go pattern for tree walking; you will see it again in the @ttsc/paths rewriter.

node.AsCallExpression(), node.AsIfStatement(), etc. Return nil when node.Kind does not match instead of panicking. The case shimast.KindIfStatement: stmt := node.AsIfStatement() pattern in filterEmbeddedStatements is safe only because the case already proved the kind. Outside a kind-checked switch, for example after a ForEachChild that yields heterogeneous children. Check if stmt == nil { return } before dereferencing any field. node.AsCallExpression().Expression on a non-call panics with a nil-deref, which the host surfaces as exit 2 with a Go stack on stderr. The same rule applies to node.Symbol(), which is nil for non-declaration nodes; see Pitfalls → AST and Checker.

5. Substitute embedded statements

This is the subtle case:

if (x) console.log("oops");

If we removed the console.log outright, we would be left with if (x) followed by no statement. A syntax error. Strip’s solution is to swap the embedded statement for an EmptyStatement (;):

if (x);

The code:

func filterEmbeddedStatements(node *shimast.Node, strip *stripRewriter) { switch node.Kind { case shimast.KindIfStatement: stmt := node.AsIfStatement() stmt.ThenStatement = filterEmbeddedStatement(stmt.ThenStatement, strip) stmt.ElseStatement = filterEmbeddedStatement(stmt.ElseStatement, strip) case shimast.KindDoStatement: stmt := node.AsDoStatement() stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip) case shimast.KindWhileStatement: stmt := node.AsWhileStatement() stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip) case shimast.KindForStatement: // ... same pattern case shimast.KindForInStatement, shimast.KindForOfStatement: stmt := node.AsForInOrOfStatement() stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip) case shimast.KindWithStatement: // ... same pattern case shimast.KindLabeledStatement: // ... same pattern } } func filterEmbeddedStatement(stmt *shimast.Statement, strip *stripRewriter) *shimast.Statement { if stmt == nil { return nil } if shouldStripStatement(stmt, strip) { return emptyStatement(stmt) } filterChildStatements(stmt, strip) return stmt } func emptyStatement(original *shimast.Node) *shimast.Statement { empty := shimast.NewNodeFactory(shimast.NodeFactoryHooks{}).NewEmptyStatement() empty.Flags |= shimast.NodeFlagsSynthesized if original != nil { empty.Loc = original.Loc } return empty }

Three details in emptyStatement:

  • shimast.NewNodeFactory(...) is the canonical way to construct synthesized nodes. Direct struct literals work for some kinds but not all; the factory ensures internal fields are properly initialized.
  • empty.Flags |= shimast.NodeFlagsSynthesized marks the node as printer-friendly. Without this flag, the printer tries to read the original source text for this position and fails because the position is reused from a different node.
  • empty.Loc = original.Loc preserves the source location so diagnostics and source maps still point at the original console.log. The synthesize flag covers the printer; this preserves debuggability.

Three rules of thumb:

  1. Structural mutations (filtering a NodeList, swapping a child pointer): no flag needed. The printer iterates lists directly without source spans.
  2. New nodes constructed via NewNodeFactory.NewX(): set NodeFlagsSynthesized. The printer needs to know the node has no original source-text mapping.
  3. Leaf-text mutations on an existing node (changing an identifier’s Text field). Set NodeFlagsSynthesized and reset Loc to shimcore.UndefinedTextRange(). Without this, the printer reads the old text from the file’s source span and silently ignores your change. The @ttsc/paths walkthrough has the worked example.

6. Match call patterns

The dotted-name match is the cleanest call-shape recognizer in the repo:

func shouldStripStatement(node *shimast.Node, strip *stripRewriter) bool { if node == nil { return false } switch node.Kind { case shimast.KindDebuggerStatement: return strip.stripDebugger case shimast.KindExpressionStatement: expr := node.AsExpressionStatement().Expression name, ok := callExpressionName(expr) return ok && strip.matchesCall(name) default: return false } } func callExpressionName(expr *shimast.Node) (string, bool) { if expr == nil || expr.Kind != shimast.KindCallExpression { return "", false } call := expr.AsCallExpression() return dottedName(call.Expression) } func dottedName(expr *shimast.Node) (string, bool) { if expr == nil { return "", false } switch expr.Kind { case shimast.KindIdentifier: return expr.Text(), true case shimast.KindPropertyAccessExpression: prop := expr.AsPropertyAccessExpression() left, ok := dottedName(prop.Expression) if !ok || prop.Name() == nil { return "", false } return left + "." + prop.Name().Text(), true default: return "", false } }

dottedName recursively converts console.log into the string "console.log":

  • console.log is a PropertyAccessExpression with expression console (an Identifier) and name log (another Identifier).
  • The recursion: dottedName(console.log)dottedName(console) returns "console", then "console" + "." + "log""console.log".

For more complex shapes, obj["log"](), (this as Logger).log(), optional chaining, dottedName returns ("", false) because those node kinds are not in the switch. Strip deliberately handles only the simple syntactic shape; if a user wants to strip a complex callee, they can use a regular console.log (or change the call site).

expr.Text() is safe inside the case shimast.KindIdentifier: branch because the kind check filters out kinds where Text() would panic. See Pitfalls → AST and Checker.

The matcher itself:

func (s *stripRewriter) matchesCall(name string) bool { for _, pattern := range s.calls { if pattern.matches(name) { return true } } return false } func (p callPattern) matches(name string) bool { parts := strings.Split(name, ".") if p.wildcard { if len(parts) <= len(p.parts) { return false } return equalStringSlices(parts[:len(p.parts)], p.parts) } return equalStringSlices(parts, p.parts) }

For console.log, parts = ["console", "log"]. A pattern console.log with wildcard: false requires exact length and element-equality. A pattern console.* (parsed into parts: ["console"], wildcard: true) requires strictly more name segments than pattern segments. Note the early-return if len(parts) <= len(p.parts) { return false }, so console.* matches console.log but not the bare identifier console. After that guard, the prefix segments must match element-wise.

That’s the whole matcher, 15 lines of Go for pkg.fn and pkg.* semantics.

What to copy, what to ignore

Copy:

  • The filterStatements shape: make a fresh slice, conditionally append, then assign back. This is the canonical Go pattern for in-place list filtering.
  • The filterEmbeddedStatements enumeration of statement kinds. If you ever need to walk single-statement-bearing nodes, this list is exhaustive for ES2022.
  • The emptyStatement constructor pattern: factory → set Synthesized flag → preserve Loc. This is the right shape whenever your rewrite must substitute a placeholder for a removed node.
  • The dottedName recursion. It is the same shape @ttsc/lint’s console rule uses; copy it whenever you need to recognize pkg.fn or obj.method callees.

Do not copy:

  • The package-specific default-config injection in parseStrip unless your plugin intentionally has default removal patterns.
  • The standalone plugin/strip.go wrapper when your descriptor points directly at an executable command package.

Test coverage

The stripRewriter is exercised two ways:

  • Package-local Go tests in packages/strip/test: run the binary through go run ./plugin against fixture projects, one assertion per file in the AGENTS.md §2.2 shape.
  • End-to-end TypeScript fixtures under tests/test-ttsc/src/features/: spawn the real ttsc launcher and assert on emitted dist/ files.

Pick the strip tests as your template when you write your own. They cover the structural-mutation shape your plugin probably also has.

Last updated on