The Compiler Only Knows What You Tell It
Install an untyped package and you get one of TypeScript's more honest error messages: "Could not find a declaration file for module 'confetti-lite'. '/node_modules/confetti-lite/index.js' implicitly has an 'any' type." The compiler isn't confused. It genuinely has nothing to go on โ JavaScript at runtime carries no types, and unless someone wrote a .d.ts describing the shape, TypeScript treats the whole module as any. Declaration files are that someone. Most of the time it's DefinitelyTyped. Sometimes it has to be you.
- You've pinned a package version specifically because the newer one broke your hand-written
.d.ts - You augmented Express's
Requesttype in three different files because you couldn't remember where the first one went skipLibCheckhas beentruein your tsconfig since the project started and nobody remembers why- An
@typespackage told you a function took two arguments; the library's changelog says it's taken three since last spring
A .d.ts file is a claim about runtime behaviour that nothing checks against the actual runtime. Understanding how TypeScript resolves and trusts them is what keeps that claim honest.
Writing One From Scratch
For a small untyped library, an ambient module declaration is often faster than digging through GitHub issues for a community types package that doesn't exist yet. Drop this next to your source, make sure your tsconfig's include actually reaches it, and the import resolves like any typed package would.
// src/types/confetti-lite.d.ts
declare module 'confetti-lite' {
export interface ConfettiOptions {
particleCount?: number;
spread?: number;
origin?: { x: number; y: number };
colors?: string[];
}
export default function confetti(options?: ConfettiOptions): Promise<void>;
}import confetti from 'confetti-lite';
await confetti({ particleCount: 200, spread: 70 }); // fully typed, zero 'any'A file like this outside your includeglobs is invisible to TypeScript โ no error, no warning, it just silently doesn't apply. If a teammate's editor shows red squiggles that your terminal tscrun doesn't, check that first.
declare global and Scope Leakage
declare globalaugments the actual global scope โ every file in the program sees it, including ones that never import the file where you wrote it. That's the point when you're adding a method to Array.prototype or typing window.analytics. It stops being the point the moment two people on the team both decide to globally augment Windowwith slightly different shapes for the same property โ now you've got a merge conflict TypeScript won't catch until the types actually contradict each other.
// src/types/window.d.ts
export {}; // <-- without this, TS treats the file as a global SCRIPT, not a module,
// and 'declare global' inside a script is redundant (everything in a
// script is already global) โ the export turns the file into a module
// so the augmentation actually means something
declare global {
interface Window {
analytics: {
track(event: string, props?: Record<string, unknown>): void;
};
}
}Module Augmentation: Extending Express's Request
This is the pattern every auth middleware eventually needs: attach a decoded user to the request object and have req.user type-check downstream without an as any. The part people get wrong is which module to augment โ Express re-exports its Request interface from express-serve-static-core, so augmenting 'express' directly usually does nothing.
// src/types/express.d.ts
import 'express';
declare module 'express-serve-static-core' {
interface Request {
user?: { id: string; role: 'admin' | 'member' };
}
}app.use((req, res, next) => {
req.user = decodeToken(req.headers.authorization);
next();
});
app.get('/me', (req, res) => {
res.json(req.user); // typed as { id: string; role: 'admin' | 'member' } | undefined
});
flowchart TD
I["import x from 'pkg'"] --> Q{"Does pkg ship its own types?<br/>(package.json 'types' or 'exports'.types)"}
Q -- yes --> A["Use the bundled .d.ts"]
Q -- no --> Q2{"@types/pkg installed?"}
Q2 -- yes --> B["Use DefinitelyTyped<br/>(versioned separately from pkg itself)"]
Q2 -- no --> Q3{"Ambient 'declare module' in your own .d.ts?"}
Q3 -- yes --> C["Use your hand-written types"]
Q3 -- no --> D["any โ or a hard error under noImplicitAny"]
style A fill:#16a34a,color:#fff,stroke:#15803d
style B fill:#0078D4,color:#fff,stroke:#005a9e
style C fill:#0078D4,color:#fff,stroke:#005a9e
style D fill:#dc2626,color:#fff,stroke:#b91c1c
Generating Declarations Automatically
If you're authoring a library rather than consuming one, don't hand-write the public .d.tsโ generate it from your actual source so it can't drift.
// tsconfig.json
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"declarationMap": true,
"outDir": "dist/types"
}
}npx tsc --declaration --emitDeclarationOnlydeclarationMapis worth the extra build artifact: it lets an editor's "go to definition" jump straight through the generated .d.ts to your original .ts source, instead of dead-ending on a type signature with no body.
@types Packages vs a Library Shipping Its Own
DefinitelyTyped exists because most of the npm ecosystem predates good TypeScript support, and it's a genuinely impressive volunteer effort โ thousands of packages typed by people who don't work on the packages themselves. That last part is also the catch. An @typespackage is published, versioned, and maintained separately from the library it describes, so there's no mechanism forcing them to stay in sync. A library that ships its own .d.ts โ via the types field in package.json, or a types condition inside exports โ closes that gap, because the same release that changes the API also changes the types. When you have a choice between a package with bundled types and one that leans on a community @types definition, bundled wins, all else equal.
Pitfalls
Hand-written declaration files drift. Someone refactors a function to return string | null instead of always string, doesn't touch the adjacent .d.tsbecause nothing forces them to, and now the type system is lying to every caller with total confidence. There's no build step that checks a .d.ts against the JavaScript it describes unless you generate it โ which is the strongest argument for generation over hand-authoring anywhere you can manage it.
skipLibCheck: true is in nearly every tsconfig template for a real reason โ without it, two packages that both bundle slightly incompatible versions of @types/nodecan produce errors you have no way to fix yourself. But the flag doesn't discriminate. It skips type-checking your own hand-rolled ambient declarations too, including the ones with a real mistake in them. If something in a .d.ts you wrote is subtly wrong, skipLibCheck is exactly why the compiler never mentioned it.
Key Takeaways
- A
.d.tsis a promise, not a check โ nothing validates it against the JavaScript it describes unless you generate it from source. declare globalneedsexport {}to stay scoped to a module augmentation instead of becoming a global script; skipping it is the most common reason an augmentation "doesn't work."- Augment
express-serve-static-core, notexpress, when extendingRequestโ this one trips up almost everyone the first time. - Prefer libraries that bundle their own types over relying on
@types/*; bundled types can't drift out of sync with a release the way a separately versioned DefinitelyTyped package can.

