In simple language, a .d.ts file is type-info-only — no runtime code. It tells TS “this thing exists somewhere out there and here’s its shape”. We use it for three jobs: typing JavaScript libraries that don’t ship their own types, adding globals (like a window.myApp), and teaching TS about non-JS imports (like .svg or .css files).
What’s actually in a .d.ts?
Only type declarations. No expressions, no function bodies, no runtime code. The declare keyword is everywhere because it means “this exists, trust me, I’m just describing it”.
// jquery.d.ts (simplified)
declare function $(selector: string): JQuery;
declare interface JQuery {
hide(): JQuery;
show(): JQuery;
text(value: string): JQuery;
}
After this, TS lets us call $("...").hide() with full types, even though the actual jQuery code is plain JS loaded via a script tag.
Three flavors of .d.ts
There are three different patterns depending on what we’re typing.
1. Ambient declarations (global)
These describe globals that exist without being imported. Use these for browser/Node extensions, env shims, etc.
// globals.d.ts
declare const __APP_VERSION__: string; // injected by Vite/webpack DefinePlugin
declare global {
interface Window {
analytics: { track: (event: string) => void };
}
}
// usage anywhere in the project
console.log(__APP_VERSION__);
window.analytics.track("page_view");
The declare global block is the official way to extend globals from inside a module. Without it, augmenting Window won’t work.
2. Module declarations
Used to type a JS module that doesn’t have its own types, or to declare a “fake” module for non-JS assets.
// types/legacy-lib.d.ts
declare module "legacy-lib" {
export function doStuff(input: string): number;
export const VERSION: string;
}
// images.d.ts — for asset imports
declare module "*.svg" {
const src: string;
export default src;
}
declare module "*.module.css" {
const classes: Record<string, string>;
export default classes;
}
Now import logo from "./logo.svg" gives us a properly-typed string. Without this, TS would scream about an unknown module.
3. Module augmentation
Sometimes a library has types but we want to extend them. Use module augmentation.
// extend express's Request to include a custom property
import "express";
declare module "express" {
interface Request {
user?: { id: string; role: string };
}
}
// now in our handlers:
app.get("/me", (req, res) => {
res.json(req.user); // typed as { id: string; role: string } | undefined
});
The import at the top is important — it tells TS we’re augmenting an existing module, not redefining it.
Triple-slash directives
The funky /// comments at the top of some .d.ts files. They’re the old way of expressing dependencies between declaration files. Mostly legacy now, but two are worth knowing.
/// <reference types="node" />
/// <reference path="./other.d.ts" />
reference types pulls in a @types/* package. We rarely write these by hand anymore — modern setups use "types" in tsconfig or just rely on automatic @types/* discovery.
@types/* packages
When a library doesn’t ship its own types, the community publishes them on DefinitelyTyped, distributed via npm as @types/xxx.
npm install --save-dev @types/lodash @types/node
TS auto-discovers these from node_modules/@types. We don’t need to import them — just install and they work. To narrow which @types get auto-loaded, set "types": ["node", "jest"] in tsconfig.
Authoring our own — the d.ts for a library
If we publish a TS library, the compiler emits .d.ts files alongside the .js (when declaration: true is set). Consumers get types for free. The package.json needs a "types" (or "typings") field pointing at the entry .d.ts.
// package.json of a published lib
{
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
// tsconfig.json
{
"compilerOptions": {
"declaration": true,
"declarationMap": true, // source maps for declarations — go-to-def works
"emitDeclarationOnly": false
}
}
That’s the whole thing. Declaration files look weirder than they are — it’s really just types in a separate file, with declare sprinkled on top to say “no runtime here”.