A TypeScript declaration file is a .d.ts file that describes the shape of JavaScript code without containing any implementation. It tells the compiler which functions, classes and variables a module exports and what their types are. Every time you import a library in TypeScript, the editor's autocomplete and the compiler's checks come from a declaration file, either shipped with the library, installed from an @types package, or written by you.
This guide explains what goes into a .d.ts file, how TypeScript finds one, how to install @types packages (including for scoped packages), and how to write your own with declare module and declare global when a library has no types.
What a TypeScript declaration file contains
A declaration file has only type information. Compare a small JavaScript module with its declaration:
// src/lib/format.js
export function formatPrice(amount, currency = "THB") {
return new Intl.NumberFormat("th-TH", { style: "currency", currency }).format(amount);
}
export const VERSION = "1.4.0";// src/lib/format.d.ts
export declare function formatPrice(amount: number, currency?: string): string;
export declare const VERSION: string;When TypeScript code imports ./lib/format.js, the compiler reads format.d.ts for types and leaves the runtime code alone. The rules are simple:
- No function bodies, no initial values, no logic. Only signatures.
declaresays "this exists at runtime, trust me". Inside a.d.tsfile every top-level declaration is ambient, sodeclareis implied for exported ones, but writing it keeps the intent clear.- Interfaces and type aliases can be written as usual. They have no runtime form anyway.
Because the compiler trusts declaration files completely, a wrong .d.ts is worse than none. If it says a function returns string and it actually returns undefined sometimes, TypeScript won't warn you.
Where TypeScript looks for types
When you write import { debounce } from "lodash-es", TypeScript resolves types in roughly this order:
- The package's own
package.json: atypescondition insideexports, or the top-leveltypes(or oldertypings) field. - A
.d.tsfile next to the package's JavaScript entry point, such asindex.d.ts. - A matching package in
node_modules/@types, such as@types/lodash-es.
If none of those exist and noImplicitAny is on (it is under strict), you get error TS7016:
error TS7016: Could not find a declaration file for module 'legacy-slugify'.
'/app/node_modules/legacy-slugify/index.js' implicitly has an 'any' type.Many modern libraries, such as axios, zod and date-fns, bundle their own types, so step 1 covers them and you don't need anything extra. For libraries that don't, the community maintains types in the DefinitelyTyped repository, published to npm under the @types scope.
Installing @types packages
Type packages are only needed at compile time, so install them as dev dependencies:
npm install -D @types/lodash
# or
yarn add -D @types/lodash
# or
pnpm add -D @types/lodashA few that almost every Node project needs are @types/node for Node's built-in modules, plus @types/express or @types/jest if you use those libraries.
Before installing, check whether the library already ships types. The npm website shows a "TS" badge next to packages with built-in types and a "DT" badge when types exist on DefinitelyTyped. Installing an @types package for a library that already has types causes duplicate or conflicting declarations.
Keep versions aligned. @types packages follow the major and minor version of the library they describe, so @types/[email protected] describes [email protected]. A mismatch can give you types for APIs that don't exist in your installed version.
Scoped package names: @types/scope__name
npm scopes already use a slash (@babel/core), and a package name can only contain one. DefinitelyTyped handles this by dropping the @ and replacing the slash with two underscores:
| Library | Types package |
|---|---|
lodash | @types/lodash |
@babel/core | @types/babel__core |
@babel/traverse | @types/babel__traverse |
@myorg/pam | @types/myorg__pam |
npm install -D @types/babel__coreControlling global types with types and typeRoots
By default TypeScript includes every package in node_modules/@types as global declarations. That can cause clashes, for example when both @types/jest and @types/mocha define a global describe. The types option restricts it:
{
"compilerOptions": {
"types": ["node", "vitest/globals"]
}
}This only affects packages loaded as globals. An explicit import express from "express" still finds @types/express. typeRoots changes where TypeScript looks for those global packages, and you rarely need it.
Generating declaration files from your own code
If you publish a TypeScript library, let the compiler write the .d.ts files for you:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist"
}
}Then point consumers at them in package.json:
{
"name": "@myorg/pam",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}declarationMap lets "Go to Definition" jump to your .ts source instead of the .d.ts. If another tool like esbuild or SWC builds the JavaScript, add emitDeclarationOnly so tsc only produces types. TypeScript 5.5 added isolatedDeclarations, which requires explicit return types on exports so that other tools can generate declarations quickly, file by file.
Typing an untyped library with declare module
When a library has no types and no @types package, write the declarations yourself. Create a folder such as types/ and make sure tsconfig.json includes it:
{
"include": ["src", "types"]
}The quick fix: a shorthand declaration
To silence TS7016 and move on, declare the module with no body:
// types/legacy-slugify.d.ts
declare module "legacy-slugify";Every import from that module is now any. That unblocks the build, but you lose all checking, so treat it as a temporary step.
A proper declaration
Better is to describe only the parts you use. Suppose legacy-slugify is a CommonJS package that does module.exports = function slugify(input, options) {...}:
// types/legacy-slugify.d.ts
declare module "legacy-slugify" {
interface SlugifyOptions {
separator?: string;
lowercase?: boolean;
}
function slugify(input: string, options?: SlugifyOptions): string;
export = slugify;
}export = models module.exports = .... With esModuleInterop enabled you can then write import slugify from "legacy-slugify".
For an ES module with named exports, export each member instead:
declare module "lodash" {
export function last<T>(array: readonly T[]): T | undefined;
}Note the T | undefined. The last element of an empty array is undefined, and the declaration should say so. In real code you would install @types/lodash rather than writing this, but it shows the pattern.
One rule catches people out: a .d.ts file that contains a top-level import or export becomes a module, and inside a module declare module "x" is treated as an augmentation of an existing module rather than a new declaration. For typing an untyped package, keep the file free of top-level imports. If you need a type from elsewhere, use an inline import("...") type.
Non-code imports
Bundlers let you import files like images or CSS modules. TypeScript needs a wildcard declaration for them:
// types/assets.d.ts
declare module "*.svg" {
const src: string;
export default src;
}
declare module "*.module.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}Tools like Vite already ship these in vite/client, so check before writing your own.
Module augmentation and global augmentation
Sometimes the types exist but you need to add to them. TypeScript merges interface declarations with the same name, which is one of the main differences covered in TypeScript interfaces vs type aliases.
Adding a property to Express Request
Authentication middleware often attaches a user to the request. @types/express declares Request inside a global Express namespace, so you can extend it:
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
user?: { id: string; role: "admin" | "member" };
}
}
}
export {};Now req.user is typed in every handler. export {} turns the file into a module, which declare global requires.
Typing globals: window and process.env
The same pattern types values injected at runtime or build time:
// src/types/globals.d.ts
declare global {
interface Window {
__APP_CONFIG__: { apiUrl: string; release: string };
}
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
}
}
}
export {};ProcessEnv requires @types/node. Keep in mind that this is only a promise to the compiler. If DATABASE_URL is missing at runtime, nothing will stop it, so validate environment variables when the application starts.
Troubleshooting checklist
When a library's types won't resolve, work through these steps in order:
- Check whether the package ships types (
typesorexportsin itspackage.json). - If not, install
@types/<name>, or@types/<scope>__<name>for scoped packages. - If there is no
@typespackage, add adeclare modulefile with the parts you use. - Make sure your
.d.tsfiles are covered byincludeintsconfig.json, and that a restrictivetypeslist isn't hiding a global package. - Check that the
@typesversion matches the library's version. - Use
skipLibCheck: trueto skip errors inside third-party declaration files, but don't use it to hide mistakes in your own.
FAQ
What is the difference between .ts and .d.ts files?
A .ts file contains code and types and compiles to JavaScript. A .d.ts file contains only types, produces no output, and describes JavaScript that already exists somewhere else.
Should @types packages be dependencies or devDependencies?
For an application, use devDependencies, because types are only needed at build time. For a published library whose own .d.ts files reference an @types package, put that package in dependencies so consumers get it too.
How do I fix "Could not find a declaration file for module"?
Install the matching @types package if one exists. Otherwise create a .d.ts file with declare module "name", either as a shorthand that types everything as any or with real signatures for the functions you use.
How do I install types for a scoped package like @babel/core?
Drop the @ and replace the slash with two underscores: npm install -D @types/babel__core.
Can I contribute types for a library?
Yes. DefinitelyTyped accepts pull requests. If the library is actively maintained, it is often better to offer the types to the library itself, so they ship and stay in sync with each release.
Summary
Declaration files are the contract between TypeScript and JavaScript. Prefer libraries that ship their own types, fall back to @types packages with matching versions, and write a focused declare module file only for what's left. Use declare global and interface merging to extend existing types instead of casting to any. If you're setting up a new project, the TypeScript project setup guide covers the tsconfig.json basics. For help structuring a larger TypeScript codebase, Vectorkub builds and maintains web applications and can support your team.
