A TypeScript project has a declaration file containing `declare const BUILD_ID: string;`. Every use of `BUILD_ID` type-checks, yet the deployed app throws "BUILD_ID is not defined" at runtime. What does the `declare` keyword actually guarantee, and how would you stop this class of failure?
answer
- ambient means described, not defined
- the compiler cannot verify the claim
- emits nothing, checks nothing
- a dangling promise about the build
- validate once at a real boundary
basics
~20 sNothing. An ambient declaration is an unverified promise to the type checker; it emits no JavaScript and never checks that the thing exists. Guarantees have to come from the build or from a runtime-validated module, not from the declaration.
solid answer
~40 s`declare` introduces an **ambient** declaration: it tells the checker "assume something with this name and this type exists" and emits nothing. There is no verification step, so if the build's define, the script tag, or the polyfill that was supposed to supply `BUILD_ID` is missing or misspelled, the compiler stays silent and the failure surfaces in production. The fix is to stop relying on the promise. Prefer a real module — export the value from a generated `build-info.ts` so the import fails at compile time when it is missing — or, if it genuinely must be a global, read it once through a validated accessor that throws a clear error at startup. Keep ambient declarations narrow and few, and treat each one as an untested integration point with the build.
code
typescript · 12 linesdeclare global {
// honest type: the build may not have injected it
var BUILD_ID: string | undefined;
}
export function buildId(): string {
const value = globalThis.BUILD_ID;
if (typeof value !== 'string') {
throw new Error('BUILD_ID was not injected by the build');
}
return value;
}go deeper
Know that declare describes something that exists elsewhere and produces no JavaScript, so a declaration alone never makes a value appear at runtime.
Explain that the compiler cannot verify an ambient claim and that every ambient form — const, function, class, bodyless module — emits nothing, with the bodyless module typing all its imports as any.
Diagnose from the emitted bundle whether the build substitution ever ran, and redesign the failure away: a generated module the compiler resolves, or a union-typed global funnelled through one validating accessor that fails loudly at startup.
Set policy on ambient surface area — where declarations live, who reviews them, and what the build asserts at startup — recognising that each declare is checking the team has opted out of at an integration seam.
## What declare means `declare` marks a declaration as **ambient**: a description of something that exists elsewhere, provided by a script tag, a build-time substitution, a host environment, or a JavaScript file the compiler never sees. Ambient declarations have no implementation and produce no output. `declare const BUILD_ID: string` compiles to nothing at all, exactly like an `interface`. The compiler's contract is one-directional. You promise that a `BUILD_ID` of type `string` will be reachable at runtime; the compiler believes you and type-checks every use against that promise. It never verifies the claim, because it cannot — the thing you are describing is outside the program by definition. That makes every ambient declaration an untested integration point. The most common failures are all the same shape: - The bundler's compile-time replacement was never configured, or the name in the config differs by one character from the name in the declaration. - The `<script>` that defines the global is missing in one environment (the test harness, a preview build) but present in another. - The value exists but is not a `string` — a number, or `undefined` under some conditions — and everything downstream silently misbehaves rather than failing loudly. ## The same trap in other ambient forms ```ts declare function trackEvent(name: string): void; declare class LegacyWidget { constructor(el: HTMLElement); render(): void; } declare module 'legacy-charts'; ``` Each emits nothing. `declare class` is especially misleading: `new LegacyWidget(el)` type-checks and compiles to a plain `new` on a name that may not exist. The shorthand ambient module in the third line is the bluntest of all — a `declare module` with **no body** types every import from that specifier as `any`, so `import { anything } from 'legacy-charts'` compiles no matter what you name and every downstream type check is disabled. It is a useful unblocking tool during a migration and a very poor permanent state. ## Diagnosing the failure When a symbol type-checks but is undefined at runtime, work from the emitted output backwards: 1. Search the built bundle for the identifier. If the build was supposed to substitute a literal, the identifier should not be there at all; if it is, the substitution never happened. 2. Check whether the declaration is the *only* place that name appears in the repo. An ambient name with no build config and no script that defines it is a dangling promise. 3. Confirm the environment. Globals injected by one host are frequently absent in another, so the same code passes in the browser bundle and fails in a server render or a unit test. ## Designing the failure out The durable fix is to replace the promise with something the compiler can check. **Prefer a module over a global.** If the build can generate a small module, do that instead: ```ts // generated at build time: src/build-info.ts export const BUILD_ID = 'a1b2c3'; ``` Now a missing file is a module-resolution error at compile time, and the value has one obvious owner. Nothing is ambient. **If it must be a global, validate it once at the boundary.** Declare it as possibly missing and force the check: ```ts declare global { var BUILD_ID: string | undefined; } export function buildId(): string { const value = globalThis.BUILD_ID; if (typeof value !== 'string') { throw new Error('BUILD_ID was not injected by the build'); } return value; } ``` The type now tells the truth, the checker forces every caller through one accessor, and the failure becomes a loud startup error with a message that names the cause instead of a `ReferenceError` deep in a request path. **Keep ambient surface small.** Every `declare` is a line of type-checking you have opted out of. Concentrate them in one declarations file, review them like a public API, and make the build assert what it injects — a single startup check that all expected globals are present is worth more than any amount of type annotation, because the annotation is the one thing that provably cannot fail. ## The general principle This is the erasure rule showing its teeth. TypeScript's type layer is removed at emit, so no annotation can create, verify, or defend a runtime value. Assertions, non-null `!`, type predicates and ambient declarations all share the property that the compiler trusts you; ambient declarations are simply the version where the trusted claim is about a value that may not exist at all.
- What does a declaration file containing only `declare module 'legacy-charts';` with no body do?It is a shorthand ambient module: the specifier resolves, and every import from it is typed `any`. `import { anything } from 'legacy-charts'` then compiles regardless of what the package really exports, and all downstream checking on those values is off. It is a reasonable temporary unblock during a migration, but as a permanent state it silently disables type safety across every call site that touches the package.
- How would you type the global so the compiler forces callers to handle its absence?Declare it as `string | undefined` rather than `string`, and expose it through a single accessor that checks the value and throws a descriptive error. The union makes the checker reject every unguarded use, so the missing case cannot be ignored, and the accessor turns a vague runtime ReferenceError into one loud, well-named startup failure with an obvious owner.
- Why does a wrong ambient signature — say declaring a function's parameter as string when the real one takes a number — produce no compiler complaint?Because there is no implementation for the compiler to compare against. An ambient declaration is the only description of that value in the program, so it is true by construction. The mismatch surfaces only at runtime, which is why ambient signatures for hand-written JavaScript should be kept minimal and, where possible, replaced by generated declarations or by checked JavaScript with JSDoc types.
saying these in an interview costs you the question
- Believes declare checks that the value exists
- Thinks declare emits a runtime stub or shim
- Uses a bodyless declare module as a permanent fix
- Declares an injected global as always defined
- Trusts an ambient signature it never compared to real code