Declaration Files and Ambient Types
Writing .d.ts by hand, declaring modules and globals, augmenting types you do not own, and keeping ambient declarations from leaking everywhere.
Writing .d.ts by hand, declaring modules and globals, augmenting types you do not own, and keeping ambient declarations from leaking everywhere.
A colleague adds a .d.ts file to describe a global your build
injects. Two days later every file in the project has stopped
being a module — imports resolve differently, const at the top
level collides across files, and nothing in the error messages
mentions the file they added.
Declaration files describe code that has no types of its own, and they are governed by rules that differ from ordinary source in ways that catch people. By the end of this lesson you will write them correctly, know the one line that decides whether your declarations are global or local, and be able to extend types you do not own.
A declaration file has types and no implementations:
// types/legacy-uploader.d.ts
export type UploaderOptions = {
endpoint: string;
timeoutMs?: number;
};
export declare function create(options: UploaderOptions): Uploader;
export declare class Uploader {
upload(file: Buffer, name: string): Promise<{ id: string }>;
}
export declare const VERSION: string;declare says "this exists somewhere, here is its type". No
function bodies, no field initialisers, no code emitted —
.d.ts files produce nothing.
The compiler generates these for you with "declaration": true,
which is what a library ships. You write them by hand for three
things: a package with no types, a global your environment
provides, and a non-code import your bundler handles.
Bad — a declaration file with no imports or exports.
// types/globals.d.ts
type Photo = { id: string; name: string };
declare const BUILD_VERSION: string;Good — a deliberate choice between the two forms.
// types/globals.d.ts
export {}; // makes this file a MODULE
declare global {
const BUILD_VERSION: string;
}Everything in it is global.
Photo is now in the global scope of every file in the
project, available without an import and colliding with any
other Photo.
Nothing leaks unless you say so.
Names are scoped to the file, and declare global is the
explicit, greppable way to contribute one globally.
That is occasionally what you want and usually an accident. It
also explains the opening scenario in reverse: adding an
import to a previously-global declaration file turns it into a
module, and every global it used to provide disappears at once.
The rule: export {} makes a file a module; declare global
is how a module contributes something global. Being explicit
about both is what stops a declaration file affecting things it
should not.
For a package with no types:
declare module "name" describes an import path. Describe only
what you use — an incomplete declaration is enormously better
than none, which was the practice course's point.
For files a bundler turns into values:
Without those, import logo from "./logo.svg" is an error even
though the build handles it perfectly.
And a whole-module escape hatch, for something you have not described yet:
That is honest and contained — it marks the untyped boundary
rather than scattering any through your code — but it is a
hole. Prefer a partial declaration.
Adding to an existing module needs an import to make the file a
module, then a matching declare module:
Now request.user exists everywhere, which is the standard way
to type what your middleware attaches. It works because
interfaces merge — the behaviour the foundations course flagged
as a hazard, here being the whole feature.
Two constraints. Only interfaces merge, so a package
exposing a type cannot be augmented this way. And the module
specifier must match exactly what you import, including any
subpath.
For globals your environment provides:
The ProcessEnv one is common and worth a caution: it makes
process.env.DATABASE_URL a string when nothing guarantees
the variable is set. It is a claim, not a check — so validate at
startup as the practice course described, and treat this as
documentation rather than safety.
For a library, the compiler generates them:
declarationMap links each declaration back to your source, so
a consumer's go-to-definition lands in real code rather than a
.d.ts.
Point consumers at them through the exports map, with types
first in each block — the rule from the modules lesson, and the
most common reason a package appears untyped despite shipping
types.
Two failure modes worth knowing.
A leaked private type. If a public function returns something from an unexported type, the generated declaration references a name consumers cannot reach:
Export the type, or change the public signature. It is telling you that your public API is larger than you thought.
Portability. "declaration": true requires types that can
be written down, which occasionally means annotating a return
type the compiler could otherwise infer. That is a mild cost and
usually improves the API.
Since nothing verifies a declaration, test it:
And two checks worth running on anything you publish:
Those catch the exports ordering problem, declarations that do
not resolve under one module system, and the dual-package
mistakes from the modules lesson — all of which are invisible
locally and obvious to a consumer.
You can now describe code that has no types, extend types you do not own, and ship declarations that consumers can actually resolve. The distinction to hold on to is script versus module — one line decides whether a declaration file is local or affects every file in the project.
Next is Authoring a Typed Library, which builds on this. A published type surface is a contract: changing it is a breaking change, and designing one you can live with is a different skill from making it work.
Before you move on, look at any .d.ts in your project and
check whether it has a top-level import or export. If it does
not, everything in it is global — and that may be a surprise
worth acting on.
error TS4053: Return type of public method has or is using
private name 'InternalPhoto'.WHAT A .d.ts IS
types with no implementations; emits nothing
declare function / class / const / namespace
generated with "declaration": true; hand-written for
untyped packages, environment globals, and asset imports
THE LINE THAT DECIDES SCOPE
no top-level import/export -> a SCRIPT: everything is GLOBAL
any import or export -> a MODULE: everything is local
export {}; makes it a module deliberately
declare global { ... } how a module adds a global
adding an import to a global .d.ts removes every global at once
DESCRIBING A MODULE
declare module "legacy-uploader" { export function ... }
describe only what you USE
declare module "*.svg" { const c: string; export default c }
declare module "untyped-thing"; everything is any - a hole
AUGMENTING
import "express";
declare module "express" { interface Request { user?: ... } }
works because INTERFACES merge - a `type` cannot be augmented
the specifier must match your import exactly
declare global { interface Window { ... } }
namespace NodeJS { interface ProcessEnv { ... } }
ProcessEnv typing is a CLAIM - still validate at startup
THE RISK
nothing checks a .d.ts against the real code
a wrong type compiles and fails at runtime
treat every hand-written declaration like a type assertion
SHIPPING
declaration + declarationMap + declarationDir
"types" FIRST in each exports block
TS4053 "private name" means your public API leaks an
unexported type
CHECKING
expectTypeOf in a .test-d.ts, compiled in CI
npx @arethetypeswrong/cli --pack
npx publint// types/legacy-uploader.d.ts
declare module "legacy-uploader" {
export function create(options: { endpoint: string }): Uploader;
export interface Uploader {
upload(file: Buffer): Promise<void>;
}
export default create;
}declare module "*.svg" {
const content: string;
export default content;
}
declare module "*.css" {
const classes: Record<string, string>;
export default classes;
}declare module "untyped-thing"; // everything from it is any// types/express.d.ts
import "express";
declare module "express" {
interface Request {
user?: { id: string; email: string };
}
}export {};
declare global {
interface Window {
analytics?: { track(event: string): void };
}
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
LOG_LEVEL?: "debug" | "info" | "warn";
}
}
}{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"declarationDir": "dist"
}
}// tests/types.test-d.ts
import { expectTypeOf } from "vitest";
import { create } from "legacy-uploader";
expectTypeOf(create).parameter(0).toMatchTypeOf<{ endpoint: string }>();
expectTypeOf(create({ endpoint: "x" }).upload).returns.resolves.toBeVoid();npx @arethetypeswrong/cli --pack
npx publint