You install a package, write a line of code that uses it, and TypeScript instantly knows the shape of everything it returns. It autocompletes the method names. It warns you when you pass a number where a string belongs. That feels like magic, especially when the package itself was written in plain JavaScript with no types at all. So where did all that type information come from?
The answer is a quiet little file type with a strange name: the declaration file, ending in .d.ts. It is one of the most important pieces of the TypeScript world, and yet most developers go years without ever writing one by hand. In this article we will unpack what these files are, the idea of “ambient” declarations, the declare keyword, and the large community project that supplies types for thousands of JavaScript libraries. By the end, the magic will feel a lot less mysterious.
The problem declaration files solve
TypeScript and JavaScript have a peculiar relationship. JavaScript is the language that actually runs in browsers and on servers. TypeScript is a layer on top that adds type information, and that information is checked while you write code and then erased before the code runs. The browser never sees a type.
This creates a gap. A huge amount of code in the world is written in plain JavaScript. Older libraries, internal scripts, packages that were published before TypeScript became popular: none of them carry type information. When you try to use that code from a TypeScript project, TypeScript has no idea what any of it is. It does not know that a function expects two arguments, or that an object has a name property. As far as the type checker is concerned, the code is a black box.
A declaration file is how you describe that black box from the outside. It is a file that contains only type information and no runnable logic. Its whole job is to tell TypeScript, “here is the shape of this JavaScript code” so the type checker can do its work.
Types only, no behavior
A .d.ts file never contains the actual implementation of anything. It holds type signatures, interface shapes, and function declarations. Think of it as a label on a sealed box: it tells you what is inside without being the contents itself.
What “ambient” means
When you read about declaration files, you will keep running into the word ambient. It sounds technical, but the idea is simple.
Normally in TypeScript, a type exists because you wrote some code that creates it. You define a function, and TypeScript infers its type from the function body. The type and the real value live together.
An ambient declaration is different. It describes a value or type that exists somewhere else, in code TypeScript cannot see or check. You are not creating anything; you are announcing that something already exists in the surrounding environment. The word “ambient” captures that nicely, like describing the temperature of a room you are standing in but did not build.
A classic example is a global variable that a script tag loaded onto the page. The variable is genuinely there at runtime, but there is no TypeScript code that produces it. So you write an ambient declaration to tell the compiler about it.
The declare keyword
The tool you use to write ambient declarations is the declare keyword. It is a promise to TypeScript: “trust me, this thing exists at runtime, even though you cannot see the code that makes it.”
Imagine a third-party analytics script adds a global function called trackEvent to the page. In your TypeScript code you want to call it without errors. You can declare it:
// analytics.d.ts
declare function trackEvent(name: string, value: number): void;
Now anywhere in your project you can call trackEvent("signup", 1) and TypeScript will check the arguments against that signature, even though the real function lives in some external script. Notice there is no function body. The declare keyword means you are only describing the shape, not providing the code.
You can declare more than functions. Variables, classes, and entire modules can all be declared:
// globals.d.ts
declare const APP_VERSION: string;
declare namespace AppConfig {
const apiUrl: string;
const timeout: number;
}
A promise you must keep
declare tells TypeScript to assume something exists, but it does not make it exist. If you declare a function that is not actually present at runtime, your code will compile cleanly and then crash when it runs. The declaration is a description, not a guarantee. Make sure what you declare truly is loaded.
Describing a whole module
Many JavaScript libraries are not global variables; they are modules you import. To describe one of those, you use a slightly larger pattern called a module declaration.
Suppose there is a plain-JavaScript package named simple-logger with no types. You import it like this:
import { log } from "simple-logger";
Without types, TypeScript complains that it cannot find the module. You can write a declaration file that describes its public surface:
// simple-logger.d.ts
declare module "simple-logger" {
export function log(message: string): void;
export function warn(message: string): void;
export const version: string;
}
Now TypeScript treats simple-logger as if it were fully typed. You get autocomplete on log and warn, and a red squiggle if you call them wrong. The actual JavaScript inside the package never changed; you simply described it from the outside.
This is the core mental model worth holding onto:
Plain JavaScript library Declaration file (.d.ts)
┌──────────────────────┐ ┌──────────────────────┐
│ real code that runs │ ◄────► │ types that describe │
│ (no type info) │ │ that code (no runtime)│
└──────────────────────┘ └──────────────────────┘
│ │
└──────────► TypeScript ◄──────────┘
sees the runtime behavior
AND understands the types
DefinitelyTyped and @types
Here is the part that explains the everyday magic. If declaration files describe untyped libraries, who writes the declarations for the thousands of popular JavaScript packages out there? You could write your own for each one, but that would be an enormous, repetitive job.
The community solved this with a single, gigantic, open project called DefinitelyTyped. It is a public repository where volunteers contribute high-quality declaration files for popular JavaScript libraries that do not ship their own types. When someone writes the declarations for a library, everyone benefits.
These declarations are published to the npm registry under a special scope named @types. The naming follows a simple pattern. If a library is called lodash, its community-maintained types live in a package called @types/lodash. If it is called express, the types are in @types/express.
npm install lodash
npm install --save-dev @types/lodash
The first line installs the real library, the code that runs. The second line installs the matching declaration files, the types that describe it. TypeScript automatically looks inside @types packages and applies them. You import lodash normally and suddenly everything is typed, even though lodash itself was written in plain JavaScript.
Check for bundled types first
Many modern libraries now ship their own .d.ts files inside the package, so you do not need a separate @types install at all. A good habit: try importing the library first. If TypeScript already understands it, the types are bundled. Only reach for @types/... when the editor complains that it cannot find a declaration.
Why you consume far more than you write
Put the pieces together and a comfortable truth emerges: as a working developer, you will read and rely on declaration files constantly, but you will write them rarely.
Every time you install a typed library, you are consuming declaration files. Every autocomplete suggestion, every type error on a library function, every hover tooltip that shows you a function signature, all of it flows from .d.ts files that someone else already wrote, either the library author or a DefinitelyTyped contributor.
The situations where you actually write one are narrow:
| Situation | Do you write a .d.ts? |
|---|---|
| Using a typed library | No, types come with it |
| Using a library covered by @types | No, just install @types |
| A global from a script tag | Sometimes, a small declaration |
| An old internal JS library nobody typed | Occasionally, if you must |
| Importing non-code assets (images, etc.) | Sometimes, a tiny module declaration |
For most projects, the only hand-written declarations are a few lines telling TypeScript about a global or an unusual import. The heavy lifting is already done for you.
Modern TypeScript writes them for you
When you build your own TypeScript library and compile it, the compiler can automatically generate .d.ts files from your source. Your code already has types, so TypeScript just extracts them into declaration files for your users. This is how a well-published TypeScript package ships its types: nobody writes the declarations by hand, the compiler does it.
A quick reality check on accuracy
One honest caveat is worth stating. A declaration file is only as correct as the person who wrote it. Because it is a separate description of code that lives elsewhere, the two can drift apart. A library might add a new option that the declaration does not mention yet, or a community type might describe an older version.
When that happens, TypeScript still trusts the declaration, because that is all it has to go on. The code may pass the type check and still misbehave. This is rare with popular libraries, which have many eyes on their types, but it is the reason declarations are a description and not a promise. Treat surprising type errors against a library as a clue to check whether the types match the version you installed.
Summary
Declaration files, the ones ending in .d.ts, exist to give TypeScript a vocabulary for code it cannot see: plain JavaScript that carries no type information of its own. They contain types and nothing runnable.
The key ideas come together neatly. An ambient declaration describes something that already exists in the surrounding environment rather than creating it. The declare keyword is how you write those descriptions, whether for a global function, a variable, or an entire module. And the DefinitelyTyped project, published as @types packages on npm, supplies ready-made declarations for thousands of untyped libraries, which is why installing @types/lodash instantly makes lodash feel fully typed.
The practical takeaway is freeing: you do not need to master writing declaration files to use TypeScript well. You will consume them every day through the libraries you install, and you will only occasionally write a handful of lines to describe a global or an odd import. Knowing what .d.ts files are and where the types come from is enough to make the whole system feel transparent instead of magical.