A TypeScript package's index.ts contains `export { Config } from './config';` where Config is an interface. Built with `tsc` the package works, but built by a per-file transpiler such as esbuild or swc, consumers crash with an error saying './config' provides no export named 'Config'. Why does the same source behave differently, and how do you fix it?
answer
- two tools, two amounts of knowledge
- one file at a time cannot tell type from value
- the interface emitted nothing to re-export
- a keyword fixes the line
- a flag fixes the class of bug
basics
~20 stsc reads ./config, sees Config is an interface, and drops it from the re-export. A per-file transpiler never reads that file, so it emits a real re-export of a binding that the emitted config.js does not have. Fix it with export type, and enable isolatedModules so the compiler catches every such site.
solid answer
~50 sThe difference is how much each tool knows. `tsc` compiles the whole program, so it resolves `Config`, sees it is an interface, and elides it — the emitted `index.js` re-exports nothing. A per-file transpiler reads only `index.ts` and cannot tell a type from a function, so it must assume `Config` is a value and emit a genuine re-export. The emitted `config.js` has no such export, because the interface emitted nothing, and the module graph fails to link. In native ESM this surfaces at link time as a `SyntaxError` before any code runs; through a CommonJS build it more often surfaces as an `undefined` that breaks somewhere unrelated. The fix at the source is `export type { Config } from './config';`, or the inline form when the statement mixes types and values. The real fix is systemic: turn on `isolatedModules` so `tsc` reports every remaining site as a compile error, rather than waiting for the fast build to break in production.
go deeper
Know that an interface produces no JavaScript, so re-exporting it by name can leave a reference to something that does not exist at runtime, and that export type is the fix on the line.
Explain the asymmetry: tsc resolves the import and elides the type, a per-file transpiler cannot resolve anything and must emit a real re-export. Be able to write both the export type and the inline type-specifier fixes.
Diagnose it from the symptom. Recognise the missing-export SyntaxError versus the late undefined, tie the difference to ESM link-time versus CommonJS property access, and insist on isolatedModules so the class of bug fails at compile time rather than in the artifact.
Own the guarantee for a published package. Decide what the build gate must prove about the artifact rather than the source, and set the compiler configuration so no contributor can reintroduce a construct whose correctness depends on which tool built it.
## Why the two builds disagree The two tools are not doing the same job. `tsc` is a whole-program compiler. Before it emits a line, it has resolved every import, so when it reaches `export { Config } from "./config"` it *knows* `Config` is an interface — a construct that produces no JavaScript. Its emit logic then elides the specifier, and if that was the only one, the whole statement. esbuild, swc and Babel's TypeScript transform are per-file transpilers. They parse `index.ts`, strip type syntax, and emit — without ever opening `./config.ts`. Faced with the same line, the tool has exactly one piece of evidence: the identifier `Config`. That could be a class, a function, a constant, or an interface. Only the first three exist at runtime, and emitting nothing for a real value would be catastrophic, so the tool does the only safe thing available to it and emits a real re-export. Meanwhile the same tool compiled `config.ts`, whose interface declaration emitted nothing. So `index.js` re-exports a name that `config.js` never defines. ## How the failure surfaces The shape of the crash depends on the output format, which is why this bug is reported so many different ways: - **Native ESM.** Module linking is static and happens before evaluation, so the failure is immediate and loud: a `SyntaxError` about a requested module not providing an export named `Config`, thrown before any of your code runs. - **CommonJS output.** `require` returns an object and the missing property is simply `undefined`. Nothing throws at the boundary; you get a `TypeError` much later, at the first place something tries to use the value, often in a completely unrelated file. - **Bundlers.** Many report it at build time as an unresolved export, which is the pleasant case — you find out before shipping. What all three share is that the *type-checking* build is green. `tsc --noEmit` passes, CI passes, and the breakage lives only in the artifact your users run. ## The source fix Mark the re-export as type-only, so the decision is visible in the one file the transpiler reads: ```ts export type { Config } from "./config"; ``` When a barrel re-exports both, use the inline modifier rather than splitting the statement: ```ts export { type Config, loadConfig } from "./config"; ``` The `type` keyword is *syntax*. Any tool, with no cross-file knowledge at all, can delete the marked specifier and keep the rest — which is exactly the property that makes the output identical between `tsc` and the fast transpiler. ## The systemic fix Fixing the one line you found is not the job; the same mistake is almost certainly elsewhere, and nothing stops it recurring on the next pull request. Two compiler settings close it: - **`isolatedModules`** makes `tsc` report every construct that a per-file tool could not translate — this re-export among them. It changes no output; it just turns a latent runtime bug into a compile error that any contributor sees. - **`verbatimModuleSyntax`** goes further and changes `tsc`'s own emit to follow the single-file rule, so unmarked type imports and re-exports become errors as a consequence of how emit now works. Barrel files are where this concentrates, because an index file is a long list of `export ... from` lines and types accumulate there faster than anywhere else. Expect the initial pass to flag many of them, and expect every fix to be a keyword. ## Diagnosing it in the wild When someone reports that a package "works in dev but not in the built output", or that a build works with one bundler and not another, this is one of the first hypotheses to test. The tell is a *missing export* or an unexplained `undefined` for a name that your editor resolves perfectly. Check whether the name is type-only at its declaration, then check whether the re-export marks it. If the project does not have `isolatedModules` on, assume there are more. One related trap worth knowing: `export * from "./config"` does not have this problem, because the star form re-exports whatever the emitted module actually has. It is the *named* re-export, which asserts a specific binding exists, that breaks.
- Why does `export * from './config'` not hit this problem?Because the star form does not name a binding. It re-exports whatever the emitted module actually provides at runtime, so a type that emitted nothing simply is not among them. The named form is an assertion that a specific export exists, and that assertion is what fails when the name turns out to be type-only.
- The type-checking CI job is green. What would you add so this fails in CI instead of production?Enable `isolatedModules` in the tsconfig the CI job already uses. It changes no output and adds no build step, but it makes `tsc` report every re-export and reference that a per-file tool could not translate, so the same job that is currently green starts failing on exactly these lines.
- Why does the same bug show up as a SyntaxError in one build and an undefined value in another?Because of the output format. Native ESM links statically before evaluation, so a missing export is detected up front and throws immediately. CommonJS resolves properties on an object at access time, so the missing name is just `undefined` and nothing throws until something later tries to use it, usually far from the cause.
saying these in an interview costs you the question
- Blames the transpiler for a bug and files an issue upstream
- Fixes it by converting the interface into a class
- Assumes a green tsc build proves the emitted bundle is sound
- Adds a runtime shim exporting a dummy Config value
- Fixes the one line and does not enable isolatedModules