../

Ambient Declarations

I recently landed a pull request (#18024) in Bun that reorganized and rewrote significant portions of Bun's TypeScript definitions. Working on this PR made me realize how little documentation there is on ambient declarations, so I wanted to write about it.

What are ambient declarations?

I'll start by answering this question with a couple questions...

1. How does TypeScript know the types of my node_modules, which are mostly all .js files?
2. How does TypeScript know the types of APIs that exist in my runtime?

The short answer: It can't!

The short but slightly longer answer is that it CAN with some extra files - ambient declarations! These are files that exist somewhere in your project (usually in node_modules) that contain type information and tell TypeScript what things exist at runtime. They use the file extension .d.ts, with the `.d` denoting "declaration".

By things I mean anything you import and use. That could be functions, classes, variables, modules themselves, APIs from your runtime, etc.

They're called "ambient" declarations because in the TypeScript universe ambient simply means "without implementation"

If you've ever imported a package and magically got autocomplete and type checking, you've benefited from ambient declarations.

A simple ambient declaration file could look like this:

add.d.ts

/**
 * Performs addition using AI and LLMs
 *
 * @param a - The first number
 * @param b - The second number
 *
 * @returns The sum of a and b (probably)
 */
export declare function add(a: number, b: number): Promise<number>;

If you can already read TypeScript this ambient declaration will be very easy to understand. You can clearly see a JSDoc comment, the types of the arguments, the return type, an export keyword, etc. It almost looks like real TypeScript, except the really important part to note here is the keyword declare is used. This keyword tells TypeScript to not expect any runtime code to exist here, it's purely a type declaration only.

Using the declare keyword in source code

It's completely legitimate and legal to use the declare keyword inside of regular .ts files. There are many use cases for this, a common one being declaring types of globals.

How Does TypeScript Find Types?

Module resolution is an incredibly complex topic, but it boils down to TypeScript looking for relevant types in a few places.

  • Bundled types: Some packages include their own .d.ts files.
  • DefinitelyTyped: If not, TypeScript looks in @types/ packages in node_modules.
  • Your own project: You can add .d.ts files anywhere in your project to describe types for JS code, global variables, or even new modules.
  • Source: If the module resolution algorithm resolves to an actual TypeScript file, then the types can be read from the original source code anyway. Some packages on NPM also publish their TypeScript source and allow modern tooling to consume it directly. Ambient declarations are NOT used in either of these scenarios.

Ambient vs. Regular Declarations

Regular declarations are for code you write and control.

add.ts

export function add(a: number, b: number): number {
	return a + b;
}

Ambient declarations are for code that exists elsewhere.

ai-add.d.ts

export declare function add(a: number, b: number): number;

The declare keyword tells TypeScript: "This exists at runtime, but you won't find it here."

Module vs. Script Declarations

  • Module declarations: Any .d.ts file with a top-level import or export. Types are added to the module.
  • Script (global) declarations: No top-level import/export. Types are added to the global scope.
File TypeExample SyntaxScope
Moduleexport declare function foo(): void;Module only (must be imported)
Script (global)declare function setTimeout(...): number;
declare function foo(): void;
Global (available everywhere)

Rule of thumb: An ambient declaration file is global unless it has a top-level import/export.

Global pollution

Script files can pollute the global namespace and can very easily clash with other declarations. Prefer the module pattern unless you really need to patch globalThis.

Why does this distinction exist? TypeScript is old in JavaScript's history - it predates the modern module system (ESM) and needed to support the "everything is global" style of early JS. That's why it still supports both module and script (global) declaration files.

How does TypeScript treat these differently?

  • Module: Everything you declare is private to that module and must be explicitly imported by the consumer - just like regular TypeScript/ESM code.
  • Script (global): Everything is injected directly into the global scope of every file in your program. This is how the DOM lib ships types like window, document, and functions like setTimeout.

When would you use each?

  • Module: For packages, libraries, and almost all modern code.
  • Script: For patching browser globals, legacy code, or when you really need to add something to the global scope.

Augmenting the global scope from a module

You can still augment the global scope from inside a module-style declaration file by using the global { ... } escape hatch, but that should be reserved for unavoidable edge-cases.

Declaring Global Types

Suppose you want to add a global variable that your runtime creates, or perhaps a library you're using doesn't have types for:

globals.d.ts

declare function myAwesomeFunction(x: string): number;

Because this declaration file is NOT a module, this will be accessible everywhere in your program.

What if you wanted to add something to the window object? TypeScript declares the window variable exists by assigning it to an interface called Window, which is also declared globally. You can perform Declaration Merging to extend that interface, and tell TypeScript about new properties that exist.

globals.d.ts

interface Window {
	myAwesomeFunction: (x: string) => number;
}

Declaring modules by name

You can declare a module by its name. As long as the ambient declaration file gets referenced or included in your build somehow, then TypeScript will make the module available.

my-legacy-lib.d.ts

declare module 'my-legacy-lib' {
	export function doSomething(): void;
}

This syntax also allows for declaring modules with wildcard matching. We do this in @types/bun, since Bun allows for importing .toml and .html files.

bun.d.ts

declare module '*.toml' {
	const content: unknown;
	export default content;
}
declare module '*.html' {
	const content: string;
	export default content;
}

Writing Your Own .d.ts Files

Suppose you're using a JS library with no types. Here's how to add them:

  1. Create a new .d.ts file (you could put this in a types/ folder)
  2. Write a module declaration:

    types/my-lib.d.ts

    declare module 'my-lib' {
    	export function coolFeature(x: string): number;
    }
  3. Make sure your tsconfig.json includes the types folder (usually automatic).

Compiler contract

Since ambient modules don't contain runtime code, they should be treated like "promises" or "contracts" that you are making with the compiler. They're like documentation that TypeScript can understand. Just like documentation for humans, it can get out of sync with the actual runtime code. A lot of the work I'm doing at Bun is ensuring our type definitions are up to date with Bun's runtime APIs.

Conflicts

While doing research for the pull request mentioned at the beginning, I found a few cases where the compiler was not able to resolve the types of some of Bun's APIs because we had declared that certain symbols existed, where they might have already been declared by lib.dom.d.ts (the builtin types that TypeScript provides by default) or things like @types/node (the types for Node.js). .

Avoiding these conflicts is unfortunately not always possible. Bun implements a really solid best-effort approach to this, but sometimes you just have to get creative. For example, you might see code like this to "force" TypeScript to use one type over another:

my-globals.d.ts

declare var Worker: globalThis extends { onmessage: any; Worker: infer T }
	? T
	: never;

Bun's types take this a step further by using a clever trick that let's us use the built-in types if they exist, with a graceful fallback when it doesn't

bun.d.ts

declare module "bun" {
	namespace __internal {
		// `onabort` is defined in lib.dom.d.ts, so we can check to see if lib dom is loaded by checking if `onabort` is defined
		type LibDomIsLoaded = typeof globalThis extends { onabort: any } ? true : false;

		/**
		 * Helper type for avoiding conflicts in types.
		 *
		 * Uses the lib.dom.d.ts definition if it exists, otherwise defines it locally.
		 *
		 * This is to avoid type conflicts between lib.dom.d.ts and @types/bun.
		 *
		 * Unfortunately some symbols cannot be defined when both Bun types and lib.dom.d.ts types are loaded,
		 * and since we can't redeclare the symbol in a way that satisfies both, we need to fallback
		 * to the type that lib.dom.d.ts provides.
		 */
		type UseLibDomIfAvailable<GlobalThisKeyName extends PropertyKey, Otherwise> =
			LibDomIsLoaded extends true
				? typeof globalThis extends { [K in GlobalThisKeyName]: infer T } // if it is loaded, infer it from `globalThis` and use that value
					? T
					: Otherwise // Not defined in lib dom (or anywhere else), so no conflict. We can safely use our own definition
				: Otherwise; // Lib dom not loaded anyway, so no conflict. We can safely use our own definition
	}
}

globals.d.ts

declare var Worker: Bun.__internal.UseLibDomIfAvailable<'Worker', {
	new(filename: string, options?: Bun.WorkerOptions): Worker;
}>;

This declares that the Worker runtime value exists, and will use the version from TypeScript's builtin lib files if they're loaded already in the program, and if not it will use the version passed as the second argument.

This trick means we can write types that can exist in many different environments without worrying about impossible-to-fix conflicts breaking the build.


Declaring entire modules as global namespaces

In Bun, everything importable from the 'bun' module is also available on the global namespace Bun

app.ts

import { file } from 'bun';
await file('test.txt').text();

// Or, exactly the same thing:

await Bun.file('test.txt').text();

In fact, you can do an equality to check to see that importing the module gives you the same reference to the global namespace.

bun repl


Welcome to Bun v1.2.13

Type ".help" for more information.


> require("bun") === Bun

true

Declaring this in TypeScript uses some strange syntax. You can find the declaration here, but it looks like this:

bun.ns.d.ts

import * as BunModule from "bun";

declare global {
	export import Bun = BunModule;
}

Let's break it down

  1. We have an import statement, so this file becomes a module.

  2. We import everything from the bun module and alias to a namespace called BunModule

  3. We use the `declare global` block to escape back into global scope, and then use the funky syntax export import to re-export the namespace to the global scope

This export import syntax a way of saying "re-export this namespace" - except when declaring on the global scope (inside a declare global { } block or inside a script/global file) the export keyword kind of turns into a namespace declaration for the global scope.

Here is the "rest" of @types/bun that piece this all together

bun.d.ts

declare module "bun" {
	/**
	 * Creates a new BunFile instance
	 * @param path - The path to the file
	 * @returns A new BunFile instance
	 */
	function file(path: string): BunFile;

	interface BunFile {
		/* ... */
	}
}

index.d.ts

// You "import" types by using triple-slash references,
// which tell TypeScript to add these declarations to the build.

/// <reference path="./bun.d.ts" />
/// <reference path="./bun.ns.d.ts" />

package.json

{
	"name": "@types/bun",
	"version": "1.2.13",
	"types": "./index.d.ts",
	// ...
}

In previous versions of Bun's types, the Bun global was defined as a variable that imported the bun module.

globals.d.ts

declare var Bun: typeof import("bun");

But since this is a runtime value, we have lost all of the types that are exported from the bun module. For example we can't use Bun.BunFile in our code.

In the pull request mentioned at the beginning, I changed previous declaration to use the export import syntax which fixed this issue. It means you can now use the Bun namespace exactly like you'd expect the bun module to behave.


Ambient declaration gotchas

  • "Cannot find module" or "type not found" errors: Make sure your .d.ts file is included in the project (check tsconfig.json's include/exclude).
  • Conflicts: If two libraries declare the same global, you'll get errors. Prefer module declarations, and avoid globals unless necessary.

Resources


Special thanks to the following people for reading revisions and helping with this post:

Conrad Crawford, Cody Miller