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 main.go dispatcher will look familiar โ€” stripโ€™s is identical except for the package name. 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, override on the compilerOptions.plugins entry:

{ "compilerOptions": { "plugins": [ { "transform": "@ttsc/strip", "calls": ["console.log", "console.debug", "myLogger.*"], "statements": ["debugger"] } ] } }

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.

Directory layout

packages/strip/ โ”œโ”€โ”€ package.json โ”œโ”€โ”€ src/ โ”‚ โ””โ”€โ”€ index.cjs โ† JS descriptor (CommonJS, no TypeScript types) โ”œโ”€โ”€ go.mod โ”œโ”€โ”€ driver/ โ”‚ โ””โ”€โ”€ strip.go โ† Linked transform logic โ””โ”€โ”€ plugin/ โ”œโ”€โ”€ main.go โ† 36-line dispatcher โ””โ”€โ”€ strip.go โ† Blank-imports the linked driver package

Strip has no TypeScript-side configuration types, so the descriptor is a plain CommonJS file โ€” no lib/, no compile step.

The JS descriptor (src/index.cjs)

// @ts-check "use strict"; const path = require("node:path"); module.exports = function createTtscStrip() { return { name: "@ttsc/strip", source: path.resolve(__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 sidecar (plugin/main.go)

Identical structure to banner, with @ttsc/strip in the package name and error messages:

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 โ€” turn --plugins-jsonโ€™s config object 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 whether the user wrote 1 or 1.0 in tsconfig. 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.Marshal โ†’ json.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 driver returns the parsed Program. ApplyProgram walks every source file:

func applySourceTransforms(prog *driver.Program, state transformState) error { for _, file := range prog.SourceFiles() { if state.paths != nil { state.paths.apply(file) } if state.strip != nil { state.strip.apply(file) } } return nil }

prog.SourceFiles() already drops declaration files, so file is always user-authored TypeScript. The order is important โ€” paths first, then strip โ€” but for strip in isolation that does not matter.

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 newStripRewriter, 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 pathsRewriter.

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