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.
At a glance
Section titled “At a glance”# 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=invariantStage 1 — Module level
Section titled “Stage 1 — Module level”Starting from entry points, fixpoint iteration narrows reachable modules and exports.
Used-export tracking
Section titled “Used-export tracking”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'andexport { X } from './a'propagate upstream usage to downstream modules.
export const used = 1;export const unused = 2; // unreachable → removal candidate
// entry.tsimport { used } from './a';console.log(used);Side-effect verdict
Section titled “Side-effect verdict”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)
package.json sideEffects
Section titled “package.json sideEffects”{ "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"]}Auto-purity inference
Section titled “Auto-purity inference”Even without sideEffects in package.json, ZNTC infers side_effects = false for non-entry modules whose top-level is entirely pure.
Stage 2 — Statement level
Section titled “Stage 2 — Statement level”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.
StmtInfo
Section titled “StmtInfo”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.
Reachability BFS
Section titled “Reachability BFS”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 surviveExample
Section titled “Example”export function used() { return 1; }export function unused() { return 2; }
const helper = () => 'helper'; // referenced only by unused → unreachablefunction unused() { return helper(); }Both unused and helper are removed in the output — they are disconnected from used’s reachability graph.
Purity analysis
Section titled “Purity analysis”@__PURE__ / @__NO_SIDE_EFFECTS__ annotations combined with a builtin allow-list drive expression-level purity (recursion limit: 128).
@__PURE__ annotation
Section titled “@__PURE__ annotation”const x = /* @__PURE__ */ createComponent(); // dropped if x is unusedThe lexer sets is_pure on the next call/new node; the tree-shaker then ignores it for side-effect purposes.
@__NO_SIDE_EFFECTS__ annotation
Section titled “@__NO_SIDE_EFFECTS__ annotation”// @__NO_SIDE_EFFECTS__function compute(x) { return x * 2; }
const a = compute(1); // if a is unused, the call itself is removedconst b = compute(2);Marking the function declaration treats every call site as pure.
Builtin pure constructors
Section titled “Builtin pure constructors”The following are auto-pure when bound to an unresolved global (no user redefinition):
| Constructor | Constraint |
|---|---|
Set, Map, WeakSet, WeakMap | new only; arg must be empty / null / undefined / ArrayExpression (avoids iterator-protocol side effects) |
Array, Date, String | Args must be recursively pure |
Error family | Must statically prove the message arg is not a Symbol |
Object.freeze, Object.assign | Fresh-literal constraint (special case) |
User-supplied pure hints
Section titled “User-supplied pure hints”Mark functions as pure via CLI or build options:
zntc --bundle entry.ts --pure=invariant --pure=warningimport { invariant } from 'tiny-invariant';
invariant(condition, "msg"); // call is removable when condition is statically truthyType-only import elision
Section titled “Type-only import elision”TypeScript’s import type and inline type modifier produce no runtime bindings.
import type { User } from './types'; // fully removedimport { type Config, helper } from './x'; // type Config removed, helper kept based on usageZNTC performs elision via two paths:
- Bundler path:
binding_scanner.zigchecks theSPEC_FLAG_TYPE_ONLYflag 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.
verbatimModuleSyntax
Section titled “verbatimModuleSyntax”When tsconfig.json has "verbatimModuleSyntax": true, ZNTC removes only import type and leaves regular imports intact (matching TypeScript’s standard behavior).
Limitations
Section titled “Limitations”Further reading
Section titled “Further reading”- Internal design doc:
docs/BUNDLER.md§ Tree-shaking 구현 - Architecture overview:
docs/ARCHITECTURE.md§ Tree-shaking Design