Introduction
This site has a light/dark theme toggle. To avoid the dreaded flash of the wrong
theme on page load, an inline script in the document head sets the theme up
before anything renders, and it hangs a little helper object on window.theme
with functions like setTheme and getSystemTheme. The runtime code worked
great. Then I went to call window.theme.setTheme('dark') from a component, and
TypeScript stopped me cold:
Property 'theme' does not exist on type 'Window & typeof globalThis'.
TypeScript wasn’t wrong, exactly. It had never been told. The fix was eight
lines in a file called env.d.ts—no imports, no runtime code, and no output
shipped to the browser—just a note to the compiler that says “this exists, and
this is its shape.”
That note is an ambient declaration: a type-only description of something that’s
already present in your runtime environment. Ambient declarations live in
.d.ts files (“d” for declaration), and the defining trait is that they produce
no runtime output. Compile your project and a .d.ts file emits nothing. It
only informs the type checker.
The gist, if you want to stop early: ambient type files have three
common jobs—typing globals TypeScript can’t see, describing untyped JavaScript
packages, and teaching TypeScript what asset imports like .svg files are. They
get picked up through your tsconfig.json, not through imports, and there’s one
mechanical snag (the script-versus-module rule) that will trip you up at least
once. The rest of this post is the detail.
What “Ambient” Actually Means
A normal .ts file carries two things at once: type information and runtime
code. When you compile it, the types get erased and the JavaScript remains. A
.d.ts file is what’s left if you keep the types and delete all the code,
so there’s no code left to run.
A .d.ts file is the building directory in an office lobby. The
directory tells you Suite 402 is Accounting and Suite 405 is Legal. It doesn’t
put anyone in Suite 402. The people were already there—the directory just
describes the building so visitors can find their way around. Your JavaScript
runtime is the building. The type checker is the visitor reading the sign.
The keyword behind all of this is declare. It means “trust me, this
exists at runtime, and this is its shape.” You can declare pretty much anything:
// A global constant injected by some scriptdeclare const BUILD_VERSION: string;
// A global functiondeclare function trackPageview(url: string): void;
// A whole module that has no types of its owndeclare module 'ancient-jquery-plugin';Notice what’s missing: values. There’s no = '1.2.3' after BUILD_VERSION and
no function body after trackPageview. Declarations describe; they never
create.
If you’ve ever installed a package like @types/node or @types/lodash, you’ve
already used ambient files at scale. Those @types/* packages come from
DefinitelyTyped,
a community repository of .d.ts files for JavaScript libraries that don’t ship
their own. TypeScript automatically includes anything under
node_modules/@types by default, which is why installing one just works.
Libraries that do ship types are doing the same thing—bundling
.d.ts files next to their JavaScript.
So the mechanism isn’t exotic. It’s the same directory-in-the-lobby trick,
whether it’s describing all of Node.js or one sneaky object on window.
Use Case 1: Typing Globals
The scenario: some value exists at runtime that TypeScript can’t see from your
source code. Maybe a CDN script (a content delivery network—third-party
JavaScript loaded straight from someone else’s server) attached an analytics
object to window. Maybe your framework injects something.
Maybe—hypothetically—you wrote an inline theme script yourself and then acted
surprised when the compiler had no idea about it.
The fix from this site’s src/env.d.ts, lightly trimmed, looks like this:
interface Window { theme: { setTheme: (theme: 'auto' | 'dark' | 'light') => void; getTheme: () => 'auto' | 'dark' | 'light'; getSystemTheme: () => 'light' | 'dark'; getDefaultTheme: () => 'auto' | 'dark' | 'light'; };}That’s it. No import anywhere, and now any file in the project can call
window.theme.setTheme('dark') with full autocomplete and checking.
You’ll often see the same idea written with a wrapper:
export {};
declare global { interface Window { analytics?: { track: (event: string) => void }; }}Why do these two versions look different? That’s the script-versus-module rule,
and I’ll give the rule a proper section below, because in my experience it’s the
part of ambient files that confuses people most. Short version: the bare interface Window works
because my env.d.ts has no imports or exports; the declare global version is
what you need when the file does.
This pattern shows up in the wild a lot more than you’d think. For example, a
hobby project of mine uses PocketBase, which lets you extend the backend
with JavaScript hooks—and it generates an ambient types.d.ts full of
declarations like declare function cronAdd(...) so your editor knows about the
globals its runtime provides. That’s a tool shipping the directory for its
building, and it’s the same trick.
Now the important warning, and it’s the big one: declaring a global doesn’t
create it. If I deleted my inline theme script but left the declaration,
TypeScript would keep cheerfully approving window.theme.setTheme('dark')—right
up until the browser throws Cannot read properties of undefined at a user. Remember, the directory doesn’t put anyone in Suite 402. If the directory
says Accounting is up there but the office is empty, you’ll ride the elevator,
knock, and stand in a dark room holding your expense report. The compiler
trusted you. Make sure you deserved it.
Use Case 2: Quieting Untyped Modules
The scenario: you install some older JavaScript package—it works fine, it’s just
from an era before types—and TypeScript refuses the import:
Could not find a declaration file for module 'ancient-jquery-plugin'.
The quick escape hatch is a one-liner:
declare module 'ancient-jquery-plugin';That one-liner is a
shorthand ambient module,
and it tells TypeScript “this module exists; type everything from it as any.”
The error disappears. So does your type safety for that entire package.
The way I think about it: an untyped package is like the bulk bins at the grocery store—perfectly good food, no label. The shorthand declaration is slapping a blank sticker on the bin that says “contents: something.” Technically labeled. Legally, probably, a label. Nutritionally useless.
The better move is to fill in the actual nutrition facts—declare the shape of the parts you use:
declare module 'ancient-jquery-plugin' { export interface PluginOptions { speed?: number; easing?: 'linear' | 'swing'; }
export function init(selector: string, options?: PluginOptions): void;}You don’t have to type the entire library. Type the two functions you call and
leave the rest. You keep autocomplete, you keep checking, and six
months from now, when you pass speed: '500' instead of speed: 500, the
compiler will catch it instead of production.
I hold the same line here that I hold with as casts and @ts-ignore: when the
compiler is wrong about a shape, the fix is to correct the type definition, not
to shout over it. A blanket any module is fine as a five-minute unblocking
move. As a permanent resident, that any module is a blank label on your food.
Use Case 3: Typing Non-Code Imports (Assets)
The scenario you hit the first time you import an image or a stylesheet through
a bundler: you write import logo from './logo.svg' or
import styles from './card.module.css', and TypeScript objects—because as far as the language is concerned, you can’t import
a picture. Your bundler (Vite, webpack, whatever) knows what to do with
that import at build time. TypeScript just needs to be told what type comes out
the other side.
The tool for the job is a wildcard module declaration:
declare module '*.svg' { const src: string; export default src;}
declare module '*.module.css' { const classes: Record<string, string>; export default classes;}Now each .svg import is a string (the URL the bundler produces) and each
CSS module import is an object of class names. A single declaration covers them,
forever.
The part that makes this section feel familiar is that your framework almost
certainly wrote these declarations for you already. Vite scaffolds a src/vite-env.d.ts
containing a single line—/// <reference types="vite/client" />—which pulls in
Vite’s own ambient declarations
for *.svg, *.module.css, ?raw imports, and the rest of its asset
menagerie. That weird little file you’ve scrolled past a hundred times is a
.d.ts doing what we just did by hand.
Astro (which this site is built on) works similarly, with one wrinkle worth
knowing: since Astro 5, the
framework’s generated types live in .astro/types.d.ts, pulled in through your
tsconfig. A src/env.d.ts is no longer created for you—it exists purely for
your ambient additions, which is why I still keep one: it’s where
my window.theme declaration lives, alongside typed entries for my PUBLIC_*
environment variables. The framework types itself; the file is for the parts of
the building only I know about.
How TypeScript Finds These Files
File discovery is the part that feels like magic until someone explains it, so
let’s walk through it. You don’t import an ambient .d.ts file (Vite’s
triple-slash reference is the nearest thing, and that’s a compiler directive,
not an import). TypeScript discovers these files through tsconfig.json: the
include globs pull in any
.d.ts sitting in your source tree, files can list them explicitly, and
typeRoots (defaulting to
node_modules/@types) handles installed packages. My site’s tsconfig extends
astro/tsconfigs/strict, and its include patterns already cover anything under
src/—so env.d.ts participates in each type check without a single explicit
reference. Once a declaration file is in the compilation, its declarations
apply project-wide (with one caveat, coming next). There’s no magic, just
configuration you didn’t write yourself.
Now, the snag I mentioned in the introduction. TypeScript sorts each file into one of two
categories, and the rule is blunt: a file with any top-level import or
export is a module; a file with neither is a script. Declarations in a
script are global automatically. Declarations in a module are scoped to that
module—even in a .d.ts file.
The difference is ceiling lights versus desk lamps. A script file’s declarations
are ceiling lights: flip the switch and the floor is lit—each file in the
project sees them, no plugging in required. A module’s exports are desk lamps:
perfectly good light, but each desk that wants it has to plug the lamp in with
an import. The vicious part is that adding a single import statement to a
script file rewires the room: each ceiling light becomes a desk lamp.
Declarations that were global a moment ago now light up just their one file,
and you’ll get baffling “property does not exist” errors in code
you didn’t touch.
That rule is the entire explanation for the two Window examples earlier. My env.d.ts
has no imports or exports, so it’s a script, and a bare interface Window
merges into the global Window type directly. The moment a file becomes a
module, you need the
declare global {} block
to say “these particular declarations should be ceiling lights anyway”—and the
export {} you sometimes see is just an empty statement whose only job is
forcing module-hood so declare global is legal. That empty export looks like
cargo culting. It’s actually load-bearing.
As for where I put mine: my standing convention is a types.d.ts at the
project’s top level for shared ambient types, promoted to a types/ folder if
it grows, plus the framework-conventional env.d.ts for environment and window
augmentations. Anything reused across two or more files earns an ambient home;
types intrinsic to a single component stay in that component. The payoff is that
call sites don’t import those types at all—they just use them, ceiling-light
style.
When NOT to Reach for Ambient Types
Time for the caveats, because ambient declarations are a sharp tool and I’ve cut myself.
If you control the code, prefer real exported types. Ambient globals shine
when you’re describing something you can’t change—for example, a CDN script, a
legacy package, or a bundler’s asset handling. But for modules you wrote, an explicit
export type with an explicit import is easier to trace. Six months from now,
“where does this type come from?” should be answerable by reading the top of the
file, not by grepping the project for a declaration file you forgot existed.
A lingering declare module 'x'; is a smell. As covered above, the
shorthand form is a blank nutrition label. It’s a perfectly fine “fix later” marker—just
make sure later actually arrives. Each import from that module is any, and any spreads through code like a dye.
Globals are still globals. Typing window.theme doesn’t change the fact
that it’s implicit shared state that any file can reach without announcing the
dependency. All the usual caveats about hidden coupling apply; the type
declaration just makes the coupling well-documented. However, sometimes a global is the right
trade—an inline script that has to run before hydration can’t be imported like
a module, so a global (or a data attribute on <html>) is about the only
handoff it has. Just make it a decision, not a default.
Conclusion
Three jobs, then: ambient type files describe globals the compiler can’t see, put proper labels on untyped packages, and teach TypeScript what your bundler does with non-code imports. TypeScript finds them through tsconfig, not through imports, and the script-versus-module rule decides whether your declarations light the whole floor or one desk.
What finally made this click for me was realizing that a .d.ts file is a
conversation with the type checker, not with the runtime. None of that file
exists at runtime, and none of it runs. The file is a building directory—only ever as
accurate as the person who last updated it, and the runtime doesn’t read it at
all. (Even TypeScript’s
shiny new Go-based compiler—the native port that makes builds roughly 10x
faster—changes none of these mechanics. The directory format has outlived the
elevator.)
Ambient declarations are also one of those features that stay invisible until
the first time you need them—and then you start seeing them everywhere: the vite-env.d.ts you
never opened, the @types/* entries in your lockfile, the generated file your
framework maintains for you. The magic was configuration all along. It usually
is.