19 KiB
Namespace → flat export migration
Migrate export namespace to the export * as / flat-export pattern used by
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect
conventions, LLM-friendliness for future migrations.
What changes and what doesn't
The consumer API stays the same. You still write Provider.ModelNotFoundError,
Config.JsonError, Bus.publish, etc. The namespace ergonomics are preserved.
What changes is how the namespace is constructed — the TypeScript
export namespace keyword is replaced by export * as in a barrel file. This
is a mechanical change: unwrap the namespace body into flat exports, add a
one-line barrel. Consumers that import { Provider } don't notice.
Import paths actually get nicer. Today most consumers import from the
explicit file ("../provider/provider"). After the migration, each module has a
barrel index.ts, so imports become "../provider" or "@/provider":
// BEFORE — points at the file directly
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider namespace
import { Provider } from "../provider"
Why this matters right now
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading
massive dependency graphs that are never actually used at runtime — because
bundlers cannot tree-shake TypeScript export namespace bodies.
The problem in one sentence
cli/error.ts needs 6 lightweight .isInstance() checks on error classes, but
importing { Provider } from provider.ts forces the bundler to include all
20+ @ai-sdk/* packages, @aws-sdk/credential-providers,
google-auth-library, and every other top-level import in that 1709-line file.
Why export namespace defeats tree-shaking
TypeScript compiles export namespace Foo { ... } to an IIFE:
// TypeScript output
export var Provider;
(function (Provider) {
Provider.ModelNotFoundError = NamedError.create(...)
// ... 1600 more lines of assignments ...
})(Provider || (Provider = {}))
This is opaque to static analysis. The bundler sees one big function call
whose return value populates an object. It cannot determine which properties are
used downstream, so it keeps everything. Every import statement at the top of
provider.ts executes unconditionally — that's 20+ AI SDK packages loaded into
memory just so the CLI can check Provider.ModelNotFoundError.isInstance(x).
What export * as does differently
export * as Provider from "./provider" compiles to a static re-export. The
bundler knows the exact shape of Provider at compile time — it's the named
export list of ./provider.ts. When it sees Provider.ModelNotFoundError used
but Provider.layer unused, it can trace that ModelNotFoundError doesn't
reference createAnthropic or any AI SDK import, and drop them. The namespace
object still exists at runtime — same API — but the bundler can see inside it.
Concrete impact
The worst import chain in the codebase:
src/index.ts (entry point)
└── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (1709 lines)
│ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers
│ ├── google-auth-library
│ ├── gitlab-ai-provider, venice-ai-sdk-provider
│ └── fuzzysort, remeda, etc.
├── { Config } from config/config.ts (1663 lines)
│ ├── jsonc-parser
│ ├── LSPServer (all server definitions)
│ └── Plugin, Auth, Env, Account, etc.
└── { MCP } from mcp/index.ts (930 lines)
├── @modelcontextprotocol/sdk (3 transports)
└── open (browser launcher)
All of this gets pulled in to check .isInstance() on 6 error classes — code
that needs maybe 200 bytes total. This inflates the binary, increases startup
memory, and slows down initial module evaluation.
Why this also hurts memory
Every module-level import is eagerly evaluated. Even with Bun's fast module loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and Google's auth library allocates objects, closures, and prototype chains that persist for the lifetime of the process. Most CLI commands never use a provider at all.
What effect-smol does
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
1. Each module is a separate file with flat named exports
// Effect.ts — no namespace wrapper, just flat exports
export const gen: { ... } = internal.gen
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
2. Barrel file uses export * as (not export namespace)
// index.ts
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
This creates a namespace-like API (Effect.gen, Schema.parse) but the
bundler knows the exact shape at compile time — it's the static export list
of that file. It can trace property accesses (Effect.gen → keep gen,
drop timeout if unused). With export namespace, the IIFE is opaque and
nothing can be dropped.
3. sideEffects: [] and deep imports
// package.json
{ "sideEffects": [] }
Plus "./*": "./src/*.ts" in the exports map, enabling
import * as Effect from "effect/Effect" to bypass the barrel entirely.
4. Errors as flat exports, not class declarations
// Cause.ts
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
export interface NoSuchElementError extends YieldableError { ... }
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
Each error is 4 independent exports: TypeId, interface, constructor (as const), type guard. All individually shakeable.
The plan
The core migration is Phase 1 — convert export namespace to
export * as. Once that's done, the bundler can tree-shake individual exports
within each module. You do NOT need to break things into subfiles for
tree-shaking to work — the bundler traces which exports you actually access on
the namespace object and drops the rest, including their transitive imports.
Splitting errors/schemas into separate files (Phase 0) is optional — it's a lower-risk warmup step that can be done before or after the main conversion, and it provides extra resilience against bundler edge cases. But the big win comes from Phase 1.
Phase 0 (optional): Pre-split errors into subfiles
This is a low-risk warmup that provides immediate benefit even before the full
export * as conversion. It's optional because Phase 1 alone is sufficient for
tree-shaking. But it's a good starting point if you want incremental progress:
For each namespace that defines errors (15 files, ~30 error classes total):
-
Create a sibling
errors.tsfile (e.g.provider/errors.ts) with the error definitions as top-level named exports:// provider/errors.ts import z from "zod" import { NamedError } from "@opencode-ai/shared/util/error" import { ProviderID, ModelID } from "./schema" export const ModelNotFoundError = NamedError.create( "ProviderModelNotFoundError", z.object({ providerID: ProviderID.zod, modelID: ModelID.zod, suggestions: z.array(z.string()).optional(), }), ) export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod })) -
In the namespace file, re-export from the errors file to maintain backward compatibility:
// provider/provider.ts — inside the namespace export { ModelNotFoundError, InitError } from "./errors" -
Update
cli/error.ts(and any other light consumers) to import directly:// BEFORE import { Provider } from "../provider/provider" Provider.ModelNotFoundError.isInstance(input) // AFTER import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors" ProviderModelNotFoundError.isInstance(input)
Files to split (Phase 0):
| Current file | New errors file | Errors to extract |
|---|---|---|
provider/provider.ts |
provider/errors.ts |
ModelNotFoundError, InitError |
provider/auth.ts |
provider/auth-errors.ts |
OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
config/config.ts |
(already has config/paths.ts) |
ConfigDirectoryTypoError → move to paths.ts |
config/markdown.ts |
config/markdown-errors.ts |
FrontmatterError |
mcp/index.ts |
mcp/errors.ts |
Failed |
session/message-v2.ts |
session/message-errors.ts |
OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
session/message.ts |
(shares with message-v2) | OutputLengthError, AuthError |
cli/ui.ts |
cli/ui-errors.ts |
CancelledError |
skill/index.ts |
skill/errors.ts |
InvalidError, NameMismatchError |
worktree/index.ts |
worktree/errors.ts |
NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
storage/storage.ts |
storage/errors.ts |
NotFoundError |
npm/index.ts |
npm/errors.ts |
InstallFailedError |
ide/index.ts |
ide/errors.ts |
AlreadyInstalledError, InstallFailedError |
lsp/client.ts |
lsp/errors.ts |
InitializeError |
Phase 1: The real migration — export namespace → export * as
This is the phase that actually fixes tree-shaking. For each module:
- Unwrap the
export namespace Foo { ... }— remove the namespace wrapper, keep all the members as top-levelexport const/export function/ etc. - Rename the file if it's currently
index.ts(e.g.bus/index.ts→bus/bus.ts), so the barrel can takeindex.ts. - Create the barrel
index.tswith one line:export * as Foo from "./foo"
The file structure change for a module that's currently a single file:
# BEFORE
provider/
provider.ts ← 1709-line file with `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← SAME file, same name, just unwrap the namespace
And the code change is purely removing the wrapper:
// BEFORE: provider/provider.ts
export namespace Provider {
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
}
// AFTER: provider/provider.ts — identical exports, no namespace keyword
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
// NEW: provider/index.ts
export * as Provider from "./provider"
Consumer code barely changes — import path gets shorter:
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object
import { Provider } from "../provider"
All access like Provider.ModelNotFoundError, Provider.Service,
Provider.layer works exactly as before. The difference is invisible to
consumers but lets the bundler see inside the namespace.
Once this is done, you don't need to break anything into subfiles for
tree-shaking. The bundler traces that Provider.ModelNotFoundError only
depends on NamedError + zod + the schema file, and drops
Provider.layer + all 20 AI SDK imports when they're unused. This works because
export * as gives the bundler a static export list it can do inner-graph
analysis on — it knows which exports reference which imports.
Order of conversion (by risk / size, do small modules first):
- Tiny utilities:
Archive,Color,Token,Rpc,LocalContext(~7-66 lines each) - Small services:
Auth,Env,BusEvent,SessionStatus,SessionRunState,Editor,Selection(~25-91 lines) - Medium services:
Bus,Format,FileTime,FileWatcher,Command,Question,Permission,Vcs,Project - Large services:
Config,Provider,MCP,Session,SessionProcessor,SessionPrompt,ACP
Phase 2: Build configuration
After the module structure supports tree-shaking:
- Add
"sideEffects": []topackages/opencode/package.json(or"sideEffects": false) — this is safe because our services use explicit layer composition, not import-time side effects. - Verify Bun's bundler respects the new structure. If Bun's tree-shaking is insufficient, evaluate whether the compiled binary path needs an esbuild pre-pass.
- Consider adding
/*#__PURE__*/annotations toNamedError.create(...)calls — these are factory functions that return classes, and bundlers may not know they're side-effect-free without the annotation.
Automation
The transformation is scripted. From packages/opencode:
bun script/unwrap-namespace.ts <file> [--dry-run]
The script uses ast-grep for accurate AST-based namespace boundary detection (no false matches from braces in strings/templates/comments), then:
- Removes the
export namespace Foo {line and its closing} - Dedents the body by one indent level (2 spaces)
- If the file is
index.ts, renames it to<name>.tsand creates a newindex.tsbarrel - If the file is NOT
index.ts, rewrites it in place and createsindex.ts - Prints the exact commands to find and rewrite import paths
Walkthrough: converting a module
Using Provider as an example:
# 1. Preview what will change
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run
# 2. Apply the transformation
bun script/unwrap-namespace.ts src/provider/provider.ts
# 3. Rewrite import paths (script prints the exact command)
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
# 4. Verify
bun typecheck
bun run test
What changes on disk:
# BEFORE
provider/
provider.ts ← 1709 lines, `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← same file, namespace unwrapped to flat exports
What changes in consumer code:
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — shorter path, same Provider object
import { Provider } from "../provider"
All property access (Provider.Service, Provider.ModelNotFoundError, etc.)
stays identical.
Two cases the script handles
Case A: file is NOT index.ts (e.g. provider/provider.ts)
- Rewrites the file in place (unwrap + dedent)
- Creates
provider/index.tsas the barrel - Import paths change:
"../provider/provider"→"../provider"
Case B: file IS index.ts (e.g. bus/index.ts)
- Renames
index.ts→bus.ts(kebab-case of namespace name) - Creates new
index.tsas the barrel - No import rewrites needed —
"@/bus"already resolves tobus/index.ts
Do I need to split errors/schemas into subfiles?
No. Once you do the export * as conversion, the bundler can tree-shake
individual exports within the file. If cli/error.ts only accesses
Provider.ModelNotFoundError, the bundler traces that ModelNotFoundError
doesn't reference createAnthropic and drops the AI SDK imports.
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for code
organization — smaller files are easier to read and review. But it's not
required for tree-shaking. The export * as conversion alone is sufficient.
The one case where subfile splitting provides extra tree-shake value is if an
imported package has module-level side effects that the bundler can't prove are
unused. In practice this is rare — most npm packages are side-effect-free — and
adding "sideEffects": [] to package.json handles the common cases.
Scope
| Metric | Count |
|---|---|
Files with export namespace |
106 |
| Total namespace declarations | 118 (12 nested) |
Files with NamedError.create inside namespace |
15 |
| Total error classes to extract | ~30 |
Files using export * as today |
0 |
Phase 1 (the export * as conversion) is the main change. It's mechanical and
LLM-friendly but touches every import site, so it should be done module by
module with type-checking between each step. Each module is an independent PR.
Rules for new code
Going forward:
- No new
export namespace. Use a file with flat named exports andexport * asin the barrel. - Keep the service, layer, errors, schemas, and runtime wiring together in one
file if you want — that's fine now. The
export * asbarrel makes everything individually shakeable regardless of file structure. - If a file grows large enough that it's hard to navigate, split by concern (errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the bundler handles that.