Declaration Files (.d.ts): Writing Types for JS Libraries
AI generated
<T>
type
TypeScript · Declaration Files · .d.ts · DefinitelyTyped
Declaration Files (.d.ts)
Writing types for JS libraries

Pulling in an untyped JavaScript library costs you autocomplete and type safety across the whole project. Declaration files close that gap without touching the library's own code. This guide covers when DefinitelyTyped already has you covered, when you actually need your own types, and how to safely extend the global Window object.

12 min read .d.ts · DefinitelyTyped · Ambient Declarations TypeScript 5.x · npm · @types

1. What .d.ts files are and why TypeScript needs them

A declaration file, recognizable by its .d.ts extension, contains only type information and no executable code. The TypeScript compiler strips it entirely at build time; it does not exist at runtime. Its sole job is to tell the compiler and the editor the shape of a variable, function, or module, so that autocomplete, safe refactoring, and error checking work even for code that is not itself written in TypeScript.

For your own .ts files, the compiler automatically generates matching .d.ts files next to the compiled JavaScript when the declaration option is set in tsconfig.json. The problem starts as soon as you import a dependency that is not TypeScript itself and ships no types: without a declaration file, the compiler sees only any, loses all checking, and depending on your strict configuration may even report an error like TS7016 because no type definitions were found.

2. Checking DefinitelyTyped first: @types packages

Before writing a declaration file yourself, it is always worth checking DefinitelyTyped, the largest community repository for TypeScript types. For thousands of popular JavaScript packages without their own types, a matching @types package already exists there, maintained by the community and published on npm under the @types scope. A simple npm install --save-dev @types/packagename is usually enough for the compiler to recognize the package as fully typed, without writing a single line of code yourself.

TypeScript automatically discovers packages under node_modules/@types without needing to set typeRoots manually in tsconfig.json. Before installing, it is worth checking the target library's own package.json: if it already has a types or typings field, it ships its own types, and an additional @types package would be redundant or could even conflict.


# Check whether the target package already ships its own types
npm view lodash types

# Install community types from DefinitelyTyped
npm install --save-dev @types/lodash

# If the package itself ships types, no @types package is needed
npm view zod types

3. When you must write your own declarations

Writing your own declaration file only becomes necessary once two conditions hold at the same time: the imported package ships no types of its own, and no matching @types package exists on DefinitelyTyped. This commonly affects small, outdated, or internal company libraries that were never maintained with TypeScript users in mind. The compiler typically reports TS7016 (Could not find a declaration file) or TS2307 (Cannot find module) in this case, depending on whether any module can be found at all.

The second common trigger is extending global browser or Node APIs that exist at runtime but are not part of the standard library lib.dom.d.ts. A tracking script that sets up window.dataLayer, or a configuration injected as a global variable via a script tag, are invisible to the compiler until a declaration file describes their shape. Importing non-JS assets like .svg or .css through bundler loaders also needs its own ambient declaration, since TypeScript does not know those file extensions on its own.

4. Basic ambient declaration syntax

The declare keyword marks a declaration as ambient: it describes a value that already exists elsewhere, without generating any code itself. declare function and declare const describe individual global values, declare module opens a named module, and declare namespace groups related types under a shared identifier. Inside a .d.ts file, declare before top-level declarations is often technically optional, but it makes the intent readable and is therefore recommended by style guides.

The distinction between script context and module context matters here: a .d.ts file without any import or export is treated as a global script, and every name declared inside it lands in the global namespace. As soon as the file contains an import or export, it becomes a module, and global additions must be placed explicitly inside a declare global block. This distinction is one of the most common sources of errors in hand-written declaration files and is covered in more detail in section 8.


// global.d.ts or any ambient declaration file (script context, no import/export)

// Declare a function that exists at runtime but has no types
declare function gtag(command: string, ...args: unknown[]): void;

// Declare a constant injected by a script tag in index.html
declare const BUILD_VERSION: string;

// Declare an entire module with a minimal shape
declare module "legacy-lib" {
  export function init(options: { debug: boolean }): void;
}

// Group related ambient types under a namespace
declare namespace MyApp {
  interface Config {
    apiUrl: string;
    timeout: number;
  }
}

5. Declaring types for an untyped npm package

For a specific untyped package, declare module "packagename" { ... } is the central tool, also known as module augmentation. Inside the curly braces, you rebuild the package's public API exactly as it is actually exported: named exports as export function or export interface, a default export as export default. The declaration does not need to be complete; it is enough to type the parts of the API you actually use, and you can fill in the rest later once you need it.

Such files usually live in a dedicated folder like types/ or src/types/ and must be covered by the include array in tsconfig.json so the compiler picks them up. Important: the file itself is never imported anywhere, it takes effect purely by being present in the compilation context. With several packages missing types, it is a good idea to use one file per package, for example types/legacy-lib.d.ts, instead of mixing every declaration into a single global file.


// types/legacy-lib.d.ts
// Minimal type coverage for an untyped npm package

declare module "legacy-lib" {
  export interface LegacyLibOptions {
    debug?: boolean;
    retries?: number;
  }

  export function initialize(options: LegacyLibOptions): Promise<void>;

  export default class LegacyClient {
    constructor(apiKey: string);
    request(path: string): Promise<unknown>;
  }
}

6. Extending the global Window object

Scripts loaded via a script tag in HTML, such as a tag manager or a server-injected configuration, attach their values directly to window. The standard library lib.dom.d.ts naturally has no knowledge of these project-specific properties, which is why window.dataLayer triggers error TS2339 without an extension. The fix is declaration merging: TypeScript allows the same Window interface to be declared in multiple places, and all declarations get merged into one.

For declare global to work inside a file, that file must be treated as a module, meaning it needs at least one import or export, or, failing that, an empty export {} at the end. Inside declare global { interface Window { ... } }, you only note the additional properties, not the entire existing Window interface. Once saved, the compiler recognizes window.dataLayer and window.myAppConfig as fully typed across the whole project, including autocomplete in the editor.


// global.d.ts
// Extend the built in Window interface with app specific globals
export {}; // turns this file into a module so declare global works

declare global {
  interface Window {
    dataLayer: Record<string, unknown>[];
    myAppConfig: {
      apiBaseUrl: string;
      featureFlags: Record<string, boolean>;
    };
  }
}

// Usage anywhere in the project, fully typed
window.dataLayer.push({ event: "checkout_started" });
console.log(window.myAppConfig.apiBaseUrl);

7. Shipping your own .d.ts with a published package

If you publish your own npm package, you should give consumers the same type safety you expect from libraries yourself. The compiler flag declaration: true in tsconfig.json automatically generates a matching .d.ts file next to every compiled .js file at build time, with no hand-written declarations required. For a package with a single entry point, a central dist/index.d.ts that re-exports all public types is enough.

For npm consumers to find this file, the types field (formerly typings) in package.json must point to the correct path, usually matching the folder of the main field. If this field is missing, TypeScript automatically looks for a .d.ts file next to the entry point referenced in main, which is less robust than an explicit declaration, especially with more complex folder structures containing several subpackages.


{
  "name": "@mironsoft/build-utils",
  "version": "1.4.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc --declaration"
  },
  "files": [
    "dist"
  ]
}

8. Common pitfalls with ambient declarations

The most common mistake is accidentally mixing global and module context: a file that forgets export {} but still uses declare global gets silently ignored by the compiler, or produces cryptic errors in a completely different part of the project. A second classic mistake is declare module "*" or an overly broad wildcard pattern for asset imports: it does suppress the compiler error, but returns any for every import regardless of type, meaning there is no real type checking left at all.

Version drift between an installed library version and its @types package is another hard-to-diagnose source of errors: DefinitelyTyped packages are versioned independently of the library itself and can contain outdated or incorrect signatures after a major library update. Editing types directly in node_modules/@types never solves the problem permanently, because every npm install overwrites the change. The correct fix is always your own declaration file with module augmentation, checked into the project's version control.

9. Declaration file approaches compared

For nearly every typing problem there is a quick, risky shortcut and a slightly more effortful but sustainable approach. The table below shows the five most common decisions around declaration files and why the more effortful path pays off in the long run.

Task Risky approach Recommended approach Benefit
Unknown library type Using any everywhere Minimal .d.ts with real signatures Autocomplete and refactoring safety are preserved
Import without types // @ts-ignore on the import declare module "package-name"; as a stub Suppresses the error only at the module boundary, not project-wide
Broken third-party types Editing node_modules/@types directly Own .d.ts with module augmentation Change survives npm install and deployments
Accessing window.myProp (window as any).myProp interface Window { myProp } via declare global Type-safe access across the whole project, not just one spot
Before writing custom types Writing a .d.ts straight away Check DefinitelyTyped/@types first No duplicate or diverging community types

The common thread across every risky approach in the table: they postpone the problem instead of solving it. any and @ts-ignore suppress the error message but not the underlying uncertainty, which resurfaces as a runtime error as soon as the actual API changes. A minimal but correct .d.ts file costs a few minutes and pays off on every future refactor.

Mironsoft

TypeScript tooling, build pipelines, and type-safe headless integrations

Ready for TypeScript type safety across your project?

We analyze your build pipeline, fill in missing declaration files for internal and external packages, and make sure headless integrations and tooling scripts run fully type safe, from DefinitelyTyped audits to your own .d.ts libraries.

Type audit

Checking DefinitelyTyped coverage and identifying missing or outdated @types packages

Custom declaration files

Writing module and global augmentation for internal libraries and legacy packages

Build tooling

Cleanly setting up tsconfig, declaration output, and the types field for your own npm packages

10. Summary

Declaration files close the gap between JavaScript code that has no types and a TypeScript project that depends on full type safety. The right first step is almost never writing your own .d.ts file, but checking whether DefinitelyTyped already provides a matching @types package. Only once that check comes back negative does a custom ambient declaration pay off, whether as module augmentation for a single package or as global augmentation for a browser API like window.

The syntax itself is manageable: declare module, declare global, and a clean distinction between script and module context cover most practical cases. Anyone publishing their own packages should set declaration: true from the start and maintain the types field in package.json, so consumers get the same type safety you would expect from any good library yourself.

Declaration Files (.d.ts) - The Essentials at a Glance

DefinitelyTyped first

Check npm install --save-dev @types/packagename before writing your own declaration file.

Module augmentation

declare module "packagename" { ... } for individual untyped packages in a dedicated types/ file.

Global augmentation

declare global { interface Window { ... } } for custom properties on window, with export {} to mark the file as a module.

Publishing packages

declaration: true in the build and a correct types field in package.json for every consumer.

11. FAQ: Declaration Files (.d.ts) in TypeScript

1What is a declaration file (.d.ts)?
Contains only type information with no executable code. Describes the shape of values that already exist elsewhere, such as in a JavaScript library, to the compiler.
2When do I need my own .d.ts file?
Only when a package ships no types and no matching @types package exists on DefinitelyTyped, or for a global browser API outside the standard library.
3How do I find out if a package already has types?
npm view packagename types shows the types field of the package.json. If missing, search for @types/packagename on npm or on DefinitelyTyped.
4@types packages vs. bundled types?
Bundled types come from the library author and stay in sync with the version. @types packages are maintained independently and can briefly become outdated.
5What does declare global and export {} mean?
declare global extends global types inside a module. An empty export {} turns an otherwise import-free file into a module and enables declare global.
6any or @ts-ignore instead of real types?
Both only suppress the error message, not the type problem. A minimal, correct declaration file is usually achievable with little effort and is safer.
7How do I declare window.dataLayer?
With export {} and a declare global { interface Window { dataLayer: ... } } block. TypeScript merges this with Window via declaration merging.
8Where should custom .d.ts files live?
A common location is a types/ folder, covered by the include array in tsconfig.json. With multiple packages, a dedicated file per package is best.
9Two .d.ts files declaring the same type?
Interfaces are merged via declaration merging as long as signatures are compatible. Conflicting signatures or duplicate type aliases trigger a compiler error.
10Shipping your own types with an npm package?
Set declaration: true in tsconfig.json and point the types field in package.json to the generated main declaration file.