Skip to content

Tree-shaking

The ZNTC bundler runs tree-shaking in two passes. Module-level narrows the set of reachable modules and exports through fixpoint iteration. Statement-level then decides which top-level statements survive inside each module via symbol-graph BFS.

The goal is Rollup/Rolldown accuracy with esbuild-class speed. ZNTC reuses the index-based AST and the semantic analyzer’s scope/symbol tables to get both.

Terminal window
# Tree-shaking is on by default in bundle mode — no flag needed.
zntc --bundle src/index.ts -o dist/bundle.js
# package.json sideEffects is honored automatically.
# @__PURE__ / @__NO_SIDE_EFFECTS__ comments are recognized.
# Add user-supplied pure hints:
zntc --bundle src/index.ts -o dist/bundle.js --pure=myUtil --pure=invariant

Starting from entry points, fixpoint iteration narrows reachable modules and exports.

Each module records (module_idx, export_name) keys in a used_exports map.

  • Entry points + dynamic-import targets: outside static analysis, conservatively marked as using all exports (the * sentinel).
  • Import-specifier scan in included modules: registers which exports are imported under which local names.
  • Re-export chain cascade: export * from './a' and export { X } from './a' propagate upstream usage to downstream modules.
a.ts
export const used = 1;
export const unused = 2; // unreachable → removal candidate
// entry.ts
import { used } from './a';
console.log(used);

A module can be dropped entirely only if all of these hold:

  • No entries in used_exports
  • Not an entry point
  • Evaluating the module itself has no side effects (every top-level statement is pure)
{
"name": "my-lib",
"sideEffects": false
}

When a library declares sideEffects: false, ZNTC is free to drop unused imports. Glob patterns are also supported:

{
"sideEffects": ["*.css", "./src/polyfills.ts"]
}

Even without sideEffects in package.json, ZNTC infers side_effects = false for non-entry modules whose top-level is entirely pure.

Once a module is kept, ZNTC decides which top-level statements are actually reachable. The semantic analyzer’s symbol_id mapping is reused to build a per-statement symbol graph.

Each top-level statement records the symbols it declares and the symbols it references:

pub const StmtInfo = struct {
node_idx: u32,
has_side_effects: bool,
declared_symbols: []const u32, // symbols this stmt declares
referenced_symbols: []const u32, // references (excluding declared)
};

From this, ZNTC builds reverse indices: symbol_to_stmt, sym_to_referencing_stmts, sym_to_writer_stmts.

Seed:
- side-effectful statements
- declaring statements of used exports
- non-declaring writer statements (TS-emit pattern: var _a; ... _a = AST;)
Propagate:
- referenced_symbols → enqueue dependent stmts via symbol_to_stmt
- only statements reachable inside the module survive
utils.ts
export function used() { return 1; }
export function unused() { return 2; }
const helper = () => 'helper'; // referenced only by unused → unreachable
function unused() { return helper(); }

Both unused and helper are removed in the output — they are disconnected from used’s reachability graph.

@__PURE__ / @__NO_SIDE_EFFECTS__ annotations combined with a builtin allow-list drive expression-level purity (recursion limit: 128).

const x = /* @__PURE__ */ createComponent(); // dropped if x is unused

The lexer sets is_pure on the next call/new node; the tree-shaker then ignores it for side-effect purposes.

// @__NO_SIDE_EFFECTS__
function compute(x) { return x * 2; }
const a = compute(1); // if a is unused, the call itself is removed
const b = compute(2);

Marking the function declaration treats every call site as pure.

The following are auto-pure when bound to an unresolved global (no user redefinition):

ConstructorConstraint
Set, Map, WeakSet, WeakMapnew only; arg must be empty / null / undefined / ArrayExpression (avoids iterator-protocol side effects)
Array, Date, StringArgs must be recursively pure
Error familyMust statically prove the message arg is not a Symbol
Object.freeze, Object.assignFresh-literal constraint (special case)

Mark functions as pure via CLI or build options:

Terminal window
zntc --bundle entry.ts --pure=invariant --pure=warning
import { invariant } from 'tiny-invariant';
invariant(condition, "msg"); // call is removable when condition is statically truthy

TypeScript’s import type and inline type modifier produce no runtime bindings.

import type { User } from './types'; // fully removed
import { type Config, helper } from './x'; // type Config removed, helper kept based on usage

ZNTC performs elision via two paths:

  • Bundler path: binding_scanner.zig checks the SPEC_FLAG_TYPE_ONLY flag and skips creating a BindingRecord altogether.
  • Transpile fast path (BindingLite): without running full semantic analysis, BindingLite tracks value-use of named imports and removes only the truly-unused ones.

When tsconfig.json has "verbatimModuleSyntax": true, ZNTC removes only import type and leaves regular imports intact (matching TypeScript’s standard behavior).