8.7 Ambient Enums for Declaration Files
Right, so you’ve decided to venture into the wild west of TypeScript declaration files (.d.ts), where the rules get a little… bendy. Good for you. This is where you make peace with JavaScript code you didn’t write and teach TypeScript how to understand it. And when that JavaScript code has enums, you can’t just waltz in with a normal enum declaration. You need an ambient enum.
The declare keyword is your passport here. It tells TypeScript, “Hey, trust me on this. This thing exists at runtime, I’m just describing its shape for you.” You use it in .d.ts files to describe code that lives elsewhere.
The Basic Ambient Enum
Let’s say you’re writing a declaration file for a legendary, battle-hardened npm library from 2018 that has a numeric enum for status codes. It exists in the global scope. Your job is to describe it.
// my-legacy-lib.d.ts
declare enum Status {
Success,
Failure,
Pending,
}
This is an ambient numeric enum. It behaves exactly like a regular numeric enum, but because it’s declared, TypeScript knows not to emit any JavaScript for it. It assumes the JavaScript, something like var Status = { Success: 0, Failure: 1, Pending: 2 };, already exists in the global environment. You can use Status.Success in your TypeScript code, and it’ll compile to the exact same Status.Success at runtime, which it expects to find. Simple.
When You Need const Ambient Enums
Now, imagine the library author was performance-conscious and used a const enum to squeeze out every ounce of performance by inlining the values. Your ambient declaration needs to match that.
// my-optimized-lib.d.ts
declare const enum HttpCode {
OK = 200,
BadRequest = 400,
NotFound = 404,
}
This tells TypeScript, “This enum is const, so please inline its values everywhere.” When you write if (response.code === HttpCode.NotFound), it compiles directly to if (response.code === 404). No runtime object named HttpCode is ever looked for. This is fantastic… until it isn’t.
Here’s the colossal foot-gun they designed: You cannot use a declare const enum in a project with isolatedModules enabled (which is true for most modern build tools like Vite or create-react-app). Why? Because isolatedModules means each file is compiled independently, and without the ability to see the ambient enum’s definition in other files, the compiler has no way to know what value to inline. It just gives up and throws an error. It’s a classic case of a good idea running headfirst into modern tooling. The official advice is to avoid const enum in declaration files intended for publication. I tend to avoid const enum in general for this reason; the marginal performance gain is rarely worth the tooling headache.
The Safer Alternative: Preserved Const Enum
To navigate this mess, TypeScript offers the preserveConstEnums compiler flag. When enabled, the compiler will still inline the values for a const enum where it can, but it will also emit a runtime JavaScript object for the enum, just in case something needs to reference it by name. It’s a compromise. It gives you the inlining optimization in your own code while still providing a runtime artifact for external consumers or reflection-like patterns. It’s a sensible default, in my opinion.
Ambient String Enums
These work exactly as you’d expect. You just declare them.
// my-library.d.ts
declare enum LogLevel {
DEBUG = "DEBUG",
INFO = "INFO",
ERROR = "ERROR",
}
Since string enums don’t have the auto-incrementing magic of numeric enums, there’s no inlining optimization to be done, and thus no const variant. What you see is what you get. A runtime object must exist with these exact string values.
The Golden Rule: It Has to Exist
This is the most important thing to remember, and it’s where everyone gets bitten. When you write declare enum Status { ... }, you are making a promise to the TypeScript compiler. You are promising that when this code runs, there will be a variable Status accessible in that scope with those exact properties.
If you break that promise—if the library you’re describing exports its enum differently, say as MyLib.STATUS_CODES—then your TypeScript code will compile perfectly and then crash spectacularly at runtime with a ReferenceError. The ambient declaration is a lie, and the runtime is the judge.
This is why, for modern library authors, the best practice is to avoid ambient enums in your own published .d.ts files. Instead, use a regular export enum in your TypeScript source. This way, the compiler generates the runtime code and the type definitions simultaneously, guaranteeing they never fall out of sync. You only need ambient enums when you’re describing someone else’s code, which is why they’re the cornerstone of the DefinitelyTyped project (@types/ packages).
So use ambient enums when you have to, but always double-check your work against the actual, running JavaScript. It’s the only way to be sure.