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 namespacecompiles 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.tsfiles (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:
- 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.
- Barrel + sibling imports made it very easy to accidentally create circular
imports that only surface as
ReferenceErrorat 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:
- Uses ast-grep to locate the
export namespace Foo { ... }block accurately. - Removes the
export namespace Foo {line and the matching closing}. - Dedents the body by one indent level (2 spaces).
- Rewrites
Foo.Barself-references inside the file to justBar. - Appends
export * as Foo from "./<basename>"at the bottom of the file. - 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"orimport { 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; theindex.tsin that directory stays minimal or does not exist. - If a file needs a sibling, import the sibling file directly:
import * as Sibling from "./sibling", notfrom ".".
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/hasConfig,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.