Skip to content
Codeloom
TypeScript

TypeScript Declaration Files and .d.ts Explained

Learn how TypeScript declaration files work, when to write your own .d.ts files, and how to type untyped JavaScript libraries.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What declaration files are and how the compiler uses them
  • How to write .d.ts files for untyped JavaScript modules
  • The difference between ambient and module declarations
  • How to augment existing type definitions

Prerequisites

None — this post is self-contained.

TypeScript declaration files provide type information for JavaScript code that has no types of its own. They carry the .d.ts extension and contain only type signatures, no runtime logic. Every time you install @types/lodash or hover over a Node.js built-in in your editor, you are consuming declaration files. Understanding how they work lets you type legacy JavaScript, contribute to DefinitelyTyped, and extend third-party libraries with custom types.

How the Compiler Finds Declaration Files

When you import a module, TypeScript looks for types in a specific order. For a package named foo, it checks:

  1. A types or typings field in foo/package.json.
  2. An index.d.ts file inside the foo package.
  3. An @types/foo package installed in node_modules.

For local files, importing ./utils causes the compiler to look for ./utils.ts, ./utils.tsx, or ./utils.d.ts. If none exist, you get the infamous “could not find a declaration file” error.

Writing a Basic Declaration File

Suppose you have a JavaScript utility library at legacy-math.js:

// legacy-math.js
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
module.exports = { add, multiply };

Create legacy-math.d.ts next to it:

declare function add(a: number, b: number): number;
declare function multiply(a: number, b: number): number;

export { add, multiply };

Now TypeScript consumers get full type checking and autocompletion when they import from legacy-math.

Ambient Declarations

Ambient declarations describe things that exist at runtime but are not defined in TypeScript source. The declare keyword tells the compiler “trust me, this exists.”

// globals.d.ts
declare const API_BASE_URL: string;
declare const __DEV__: boolean;

interface Window {
  analytics: {
    track(event: string, data?: Record<string, unknown>): void;
  };
}

Place this file anywhere in your project (as long as it is included by tsconfig.json). There is no import or export at the top level, making it a script file that contributes to the global scope.

Module Declarations

When a third-party package ships without types and no @types package exists, you can declare the module yourself.

// types/untyped-lib.d.ts
declare module 'untyped-lib' {
  export function parse(input: string): Record<string, unknown>;
  export function stringify(obj: Record<string, unknown>): string;
}

This tells the compiler what untyped-lib exports. You can be as precise or as loose as you need. For a quick fix, a minimal declaration gets you past the error:

declare module 'untyped-lib';

This gives the module an any type. It silences errors but provides no safety. Use it as a temporary measure, not a permanent solution.

Wildcard Module Declarations

Some imports are not JavaScript at all. CSS modules, SVG files, and image assets need their own declarations.

// types/assets.d.ts
declare module '*.css' {
  const classes: Record<string, string>;
  export default classes;
}

declare module '*.svg' {
  const content: string;
  export default content;
}

declare module '*.png' {
  const src: string;
  export default src;
}

The wildcard * matches any filename with that extension. Now importing ./logo.svg returns a string without type errors.

Generating Declaration Files

When you author a TypeScript library, you want to ship declaration files alongside compiled JavaScript. Enable declaration in tsconfig.json:

{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./dist/types",
    "outDir": "./dist",
    "emitDeclarationOnly": false
  }
}

The compiler produces a .d.ts file for every .ts source file. If you use emitDeclarationOnly: true, it skips JavaScript output entirely, useful when another tool like esbuild handles transpilation.

Point the types field in package.json to the entry declaration file:

{
  "main": "./dist/index.js",
  "types": "./dist/types/index.d.ts"
}

Declaration Maps

Enable declarationMap to generate .d.ts.map files. These let editors jump from a declaration directly to the original TypeScript source, which dramatically improves the “go to definition” experience for library consumers.

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true
  }
}

Augmenting Existing Types

Sometimes a library’s types are mostly correct but missing a method you know exists. Module augmentation lets you extend them without forking the type package.

// types/express-augment.d.ts
import 'express';

declare module 'express' {
  interface Request {
    userId?: string;
    correlationId?: string;
  }
}

After this augmentation, req.userId is available on every Express Request object without type errors. The augmentation merges with the existing interface through declaration merging.

Global Augmentation

To add types to the global scope from within a module file, use declare global:

// auth-middleware.ts
export function authMiddleware() {
  // ...middleware logic
}

declare global {
  var currentUserId: string | undefined;
}

The declare global block lets a module file contribute to the global scope. Without it, the file’s module boundary would prevent the declaration from being visible globally.

Organizing Declaration Files

A common pattern is a types/ directory at the project root:

project/
  src/
  types/
    globals.d.ts
    untyped-lib.d.ts
    assets.d.ts
  tsconfig.json

Include the directory in your config:

{
  "compilerOptions": { ... },
  "include": ["src", "types"]
}

Keep each declaration file focused on one concern. Avoid dumping everything into a single global.d.ts file, as it becomes difficult to maintain.

Common Pitfalls

Adding an import or export to a file that was meant to be ambient (script-scoped) turns it into a module. Its global declarations stop working. If you need both imports and global declarations in one file, wrap the globals in declare global {}.

Another common mistake is duplicating declarations. If @types/node already declares process and you also declare it in a local .d.ts, you get conflicting type errors. Check what is already available before writing custom ambient types.

Wrap Up

Declaration files bridge the gap between untyped JavaScript and TypeScript’s type system. Use declare module for third-party packages without types, ambient declarations for global variables injected by build tools, and the declaration compiler option to ship types with your own libraries. Keep your .d.ts files organized, focused, and included in your tsconfig.json, and they will provide the same safety and editor experience as first-class TypeScript code.