skip to content

Why does TypeScript's `isolatedModules` flag reject references to an ambient `declare const enum`, and what does a per-file transpiler do with an ordinary `const enum`?

level: middleimportance: should knowfreq 30%

answer

  1. the value has to come from somewhere
  2. inlining needs the declaring file
  3. ambient means nothing is emitted anywhere
  4. no object to fall back on
  5. the zero-cost promise quietly disappears

basics

~20 s

A const enum is normally compiled away by inlining its member values, which requires reading the declaring file. An ambient const enum emits no runtime object at all, so a tool that reads one file has nothing to inline and nothing to reference — hence the error.

solid answer

~50 s

A `const enum` is designed to leave no runtime object: `tsc` replaces each `Colors.Red` with the literal member value at the use site. That substitution needs the declaration, which usually lives in another file. An **ambient** one, `declare const enum`, is worse — it promises the values exist somewhere but emits nothing anywhere, so there is no object to fall back on. A tool compiling one file in isolation can do neither the inlining nor the fallback, so `isolatedModules` rejects references to ambient const enums outright. For an ordinary `const enum`, tools generally give up on the cross-file inlining and emit it as a regular enum object instead, which means the construct silently stops delivering the zero-cost property that motivated it. The practical advice that falls out is to prefer a plain `enum` or, better, a union of string literals with an `as const` object when you want the values, and to keep ambient const enums out of any codebase that a per-file tool will touch.

go deeper

for a junior

Know that a const enum is meant to disappear by having its values substituted at the use site, and that this is why it behaves differently from a normal enum when other tools compile your code.

for a middle

Explain inlining as a cross-file operation and why the ambient form has neither an object nor a readable declaration for a single-file tool, making both possible outputs wrong. Name the as const object plus derived type as the portable alternative.

for a senior

Recognise the silent regression: the same source emits an enum object under one tool and inlined literals under another, so the construct's only benefit evaporates without any error. Push for removing ambient const enums from shared declaration files before a pipeline change.

for a principal

Own the rule that no construct's emitted form may depend on another file. Decide whether const enums and namespaces are permitted at all in a codebase whose build tooling you expect to change, and encode that decision in the compiler configuration rather than a style guide.

## What makes const enum special An ordinary `enum` emits a real JavaScript object, so `Colors.Red` at runtime is a property access on a thing that exists. A `const enum` opts out of that: the compiler does not emit the object, and instead **inlines** each member reference as its value. ```ts const enum Colors { Red = 0, Green = 1 } const c = Colors.Red; // emitted as: const c = 0 /* Colors.Red */; ``` The attraction is that the abstraction costs nothing at runtime. The catch is that inlining is a *cross-file* operation: to replace `Colors.Red` in your file, the compiler must have read the file where `Colors` is declared and know that `Red` is `0`. ## Why the ambient form is unfixable per file `declare const enum` goes one step further. The `declare` keyword means "this exists, do not emit a definition for it": ```ts // somewhere.d.ts declare const enum Level { Debug = 0, Info = 1 } ``` No file anywhere emits an object for `Level`. `tsc` still works, because it read the declaration and can inline `Level.Info` as `1`. But a per-file transpiler is in an impossible position: it cannot inline, because it never read the declaration; and it cannot emit a property access, because there is no runtime object to access. Either output is wrong. That is exactly the situation `isolatedModules` exists to prevent, so it reports references to ambient const enums as an error. There is no clever workaround at the use site — the construct itself is incompatible with single-file compilation. ## What happens to an ordinary const enum A non-ambient `const enum` is more forgiving, because its declaration is at least *somewhere* in your source. Per-file tools generally handle it by emitting it as an ordinary enum object rather than inlining across files, since a tool that sees the declaration and the use site in the same file may inline locally but has no basis to do so anywhere else. The consequence matters more than the mechanism: the emitted code contains the enum object you were trying to avoid, and member accesses become real property lookups. Nothing crashes, but the one reason to write `const enum` instead of `enum` has quietly gone away, and you are left with the construct's downsides — a runtime value with reverse-mapping behaviour for numeric members — and none of its benefit. It also means the *same source* produces materially different output depending on which tool built it, which is precisely the divergence `isolatedModules` is meant to eliminate. (`tsc` itself has a `preserveConstEnums` option, which makes it emit the object too while still inlining uses — useful when something outside the compilation needs the object to exist.) ## Namespaces and the same reasoning The other construct that sits badly with single-file compilation is `namespace`. A namespace containing only type declarations emits nothing, while one containing values emits an object and an IIFE — and which case you are in depends on the contents, sometimes spread across merged declarations in several files. For code that a per-file tool will process, the durable advice is to express modules as ES modules and reserve `namespace` for declaration files, where it is a description rather than something to emit. ## What to write instead When you want a small closed set of values in a codebase compiled by fast tools: ```ts export const Level = { Debug: 0, Info: 1 } as const; export type Level = (typeof Level)[keyof typeof Level]; ``` This is plain JavaScript with a derived type — no cross-file magic, identical output from every tool, and the values are available at runtime when you need them. A plain string-literal union is even lighter when you never need a runtime object at all. The general rule to carry away: any TypeScript construct whose *emitted* form depends on information in another file is a liability in a modern build, and `isolatedModules` is the flag that tells you where those constructs are.

  • What does tsc's preserveConstEnums option change?
    It makes `tsc` emit the enum object for a `const enum` in addition to inlining member references. That is useful when something outside the compilation — a debugger, or code not compiled by `tsc` — needs the object to exist at runtime. It does not make ambient const enums workable under per-file compilation.
  • If you need a closed set of named values in a per-file-transpiled codebase, what do you write instead?
    An `as const` object plus a derived type: `export const Level = { Debug: 0, Info: 1 } as const;` with `export type Level = (typeof Level)[keyof typeof Level];`. It is plain JavaScript, so every tool emits the same thing, and you still get literal-typed members and a union type. When no runtime values are needed, a string-literal union alone is lighter still.
  • Why is `namespace` also a poor fit for single-file compilation?
    Because whether it emits anything depends on its contents — a namespace of pure types emits nothing, one containing values emits an object and an IIFE — and those contents can be merged across several files. Modern codebases use ES modules for code and keep `namespace` for declaration files, where it describes shape rather than producing emit.

saying these in an interview costs you the question

  • Thinks const enum is just a faster enum with no other consequences
  • Believes ambient const enums emit a hidden runtime object
  • Assumes every transpiler inlines const enum members across files
  • Says isolatedModules bans all enums
  • Claims namespaces and modules are interchangeable in emitted code

context