A glossy 3D office-lobby building directory sign split between a red-lit side with an entry pointing at an empty floor and a cyan-lit side with entries matching occupied offices, beneath the post title "Using Ambient Type Files in TypeScript".

Using Ambient Type Files in TypeScript

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 script
declare const BUILD_VERSION: string;
// A global function
declare function trackPageview(url: string): void;
// A whole module that has no types of its own
declare 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:

global.d.ts
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.