Files
zmo-cli/packages/opencode/specs/effect/namespace-treeshake.md

8.5 KiB

Namespace → self-reexport migration

Migrate every export namespace Foo { ... } to flat top-level exports plus a single self-reexport line at the bottom of the same file:

export * as Foo from "./foo"

No barrel index.ts files. No cross-directory indirection. Consumers keep the exact same import { Foo } from "../foo/foo" ergonomics.

Why this pattern

We tested three options against Bun, esbuild, Rollup (what Vite uses under the hood), Bun's runtime, and Node's native TypeScript runner.

                                       heavy.ts loaded?
                                       A. namespace   B. barrel   C. self-reexport
Bun bundler                            YES            YES         no
esbuild                                YES            YES         no
Rollup (Vite)                          YES            YES         no
Bun runtime                            YES            YES         no
Node --experimental-strip-types        SYNTAX ERROR   YES         no
  • export namespace compiles to an IIFE. Bundlers see one opaque function call and can't analyze what's used. Node's native TS runner rejects the syntax outright: SyntaxError: TypeScript namespace declaration is not supported in strip-only mode.
  • Barrel index.ts files (export * as Foo from "./foo" in a separate file) force every re-exported sibling to evaluate when you import one name. Siblings with side effects (top-level imports of SDKs, etc.) always load.
  • Self-reexport keeps the file as plain ESM. Bundlers see static named exports. The module is only pulled in when something actually imports from it. There is no barrel hop, so no sibling contamination and no circular import hazard.

Bundle overhead for the self-reexport wrapper is roughly 240 bytes per module (Object.defineProperty namespace proxy). At ~100 modules that's ~24KB — negligible for a CLI binary.

The pattern

Before

// src/permission/arity.ts
export namespace BashArity {
  export function prefix(tokens: string[]) { ... }
}

After

// src/permission/arity.ts
export function prefix(tokens: string[]) { ... }

export * as BashArity from "./arity"

Consumers don't change at all:

import { BashArity } from "@/permission/arity"
BashArity.prefix(...) // still works

Editors still auto-import BashArity like any named export, because the file does have a named BashArity export at the module top level.

Odd but harmless

BashArity.BashArity.BashArity.prefix(...) compiles and runs because the namespace contains a re-export of itself. Nobody would write that. Not a problem.

Why this is different from what we tried first

An earlier pass used sibling barrel files (index.ts with export * as ...). That turned out to be wrong for our constraints:

  1. The barrel file always loads all its sibling modules when you import through it, even if you only need one. For our CLI this is exactly the cost we're trying to avoid.
  2. Barrel + sibling imports made it very easy to accidentally create circular imports that only surface as ReferenceError at runtime, not at typecheck.

The self-reexport has none of those issues. There is no indirection. The file and the namespace are the same unit.

Why this matters for startup

The worst import chain in the codebase looks like:

src/index.ts
  └── FormatError from src/cli/error.ts
        ├── { Provider } from provider/provider.ts     (~1700 lines)
        │     ├── 20+ @ai-sdk/* packages
        │     ├── @aws-sdk/credential-providers
        │     ├── google-auth-library
        │     └── more
        ├── { Config } from config/config.ts           (~1600 lines)
        └── { MCP } from mcp/mcp.ts                    (~900 lines)

All of that currently gets pulled in just to do .isInstance() on a handful of error classes. The namespace IIFE shape is the main reason bundlers cannot strip the unused parts. Self-reexport + flat ESM fixes it.

Automation

From packages/opencode:

bun script/unwrap-namespace.ts <file> [--dry-run]

The script:

  1. Uses ast-grep to locate the export namespace Foo { ... } block accurately.
  2. Removes the export namespace Foo { line and the matching closing }.
  3. Dedents the body by one indent level (2 spaces).
  4. Rewrites Foo.Bar self-references inside the file to just Bar.
  5. Appends export * as Foo from "./<basename>" at the bottom of the file.
  6. Never creates a barrel index.ts.

Typical flow for one file

# 1. Preview
bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run

# 2. Apply
bun script/unwrap-namespace.ts src/permission/arity.ts

# 3. Verify
cd packages/opencode
bunx --bun tsgo --noEmit
bun run --conditions=browser ./src/index.ts generate
bun run test <affected test files>

Consumer imports usually don't need to change

Most consumers already import straight from the file, e.g.:

import { BashArity } from "@/permission/arity"
import { Config } from "@/config/config"

Because the file itself now does export * as Foo from "./foo", those imports keep working with zero edits.

The only edits needed are when a consumer was importing through a previous barrel ("@/config" or "../config" resolving to config/index.ts). In that case, repoint it at the file:

// before
import { Config } from "@/config"

// after
import { Config } from "@/config/config"

Dynamic imports in tests

If a test did const { Foo } = await import("../../src/x/y"), the destructure still works because of the self-reexport. No change required.

Verification checklist (per PR)

Run all of these locally before pushing:

cd packages/opencode
bunx --bun tsgo --noEmit
bun run --conditions=browser ./src/index.ts generate
bun run test <affected test files>

Also do a quick grep in src/, test/, and script/ to make sure no consumer is still importing the namespace from an old barrel path that no longer exports it.

The SDK build step (bun run --conditions=browser ./src/index.ts generate) evaluates every module eagerly and is the most reliable way to catch circular import regressions at runtime — the typechecker does not catch these.

Rules for new code

  • No new export namespace.
  • Every module directory has a single canonical file — typically dir/index.ts — with flat top-level exports and a self-reexport at the bottom: export * as Foo from "."
  • Consumers import from the directory: import { Foo } from "@/dir" or import { Foo } from "../dir".
  • No sibling barrel files. If a directory has multiple independent namespaces, they each get their own file (e.g. config/config.ts, config/plugin.ts) and their own self-reexport; the index.ts in that directory stays minimal or does not exist.
  • If a file needs a sibling, import the sibling file directly: import * as Sibling from "./sibling", not from ".".

Why dir/index.ts + "." is fine for us

A single-file module (e.g. pty/) can live entirely in dir/index.ts with export * as Foo from "." at the bottom. Consumers write the short form:

import { Pty } from "@/pty"

This works in Bun runtime, Bun build, esbuild, and Rollup. It does NOT work under Node's --experimental-strip-types runner:

node --experimental-strip-types entry.ts
  ERR_UNSUPPORTED_DIR_IMPORT: Directory import '/.../pty' is not supported

Node requires an explicit file or a package.json#exports map for ESM. We don't care about that target right now because the opencode CLI is built with Bun and the web apps are built with Vite/Rollup. If we ever want to run raw .ts through Node, we'll need to either use explicit .ts extensions everywhere or add per-directory package.json exports maps.

When NOT to collapse to index.ts

Some directories contain multiple independent namespaces where dir/index.ts would be misleading. Examples:

  • config/ has Config, ConfigPaths, ConfigMarkdown, ConfigPlugin, ConfigKeybinds. Each lives in its own file with its own self-reexport (config/config.ts, config/plugin.ts, etc.). Consumers import the specific one: import { ConfigPlugin } from "@/config/plugin".
  • Same shape for session/, server/, etc.

Collapsing one of those into index.ts would mean picking a single "canonical" namespace for the directory, which breaks the symmetry and hides the other files.

Scope

There are still dozens of export namespace files left across the codebase. Each one is its own small PR. Do them one at a time, verified locally, rather than batching by directory.