skip to content

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?

level: seniorimportance: should knowfreq 38%

answer

  1. two tools, two amounts of knowledge
  2. one file at a time cannot tell type from value
  3. the interface emitted nothing to re-export
  4. a keyword fixes the line
  5. a flag fixes the class of bug

basics

~20 s

tsc 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 s

The 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context