Mert Özen Further With Every Line
Frontend TypeScript Intermediate — #14

TypeScript Declaration Files: .d.ts, @types, declare and Global Augmentation

Mert Özen Nov 3, 2026 8 min 2 views
TypeScript Declaration Files: .d.ts, @types, declare and Global Augmentation

We look at what .d.ts files are, @types and DefinitelyTyped, writing your own declarations with declare module, ambient declarations, global augmentation with declare global, and generating .d.ts with declaration: true.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

When the page loads, the server puts a configuration object on window, and we want to tell TypeScript about it. To do that, we add a new property to the Window interface in a separate file:

// global.ts
interface AppConfig {
  apiUrl: string;
}

interface Window {
  appConfig: AppConfig;
}

export {};

// main.ts
console.log(window.appConfig.apiUrl);

In the beginner series we saw that interfaces combine through declaration merging. Do you think main.ts compiles? If it doesn't, on which line is the error, and why? A hint: the one-line export {} in global.ts isn't innocent.

What Is a .d.ts File?

A declaration file is a file with the .d.ts extension that carries type information only. It contains no function bodies, no variable values, not a single line that runs at runtime; it only says "there's something with this name and this type". It's the exact opposite of the syntax that leaves traces behind, which we saw in the previous post: these files have no JavaScript output at all.

If you try to write a body, TypeScript won't let you:

// math.d.ts
// Error: An implementation cannot be declared in ambient contexts.
export function double(n: number): number {
  return n * 2;
}

You use declaration files every day without noticing. How does TypeScript know the return type of document.querySelector, or the signature of Array.prototype.map? From files like lib.dom.d.ts and lib.es2025.d.ts, chosen by the lib setting in tsconfig. When you use "Go to Definition" on a browser API in your editor, the file that opens is one of these.

Giving Types to a JavaScript File

The simplest scenario: your project has a legacy file written in plain JavaScript, and you want to use it from TypeScript.

// format.js
export function formatPrice(amount, currency) {
  return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}

export const DEFAULT_CURRENCY = "USD";

You put a .d.ts with the same name next to it:

// format.d.ts
export declare function formatPrice(amount: number, currency: string): string;

export declare const DEFAULT_CURRENCY: string;

The declare keyword means "this thing is defined somewhere else; I'm only telling you its type". Now when you write import { formatPrice } from "./format.js", TypeScript doesn't read the JavaScript file; it finds format.d.ts next to it and uses the types there. Write formatPrice("12", "USD") and you get the familiar TS2345 error.

Don't forget one thing: TypeScript doesn't check that the types in a .d.ts really match the JavaScript code. A .d.ts is a promise, and keeping that promise is the responsibility of whoever wrote the file.

@types and DefinitelyTyped

Some libraries on npm ship their own types inside the package; the types arrive the moment you install it. Others are plain JavaScript and contain no types. When you import such a package, TypeScript warns you like this (legacy-colors is a made-up package name here):

error TS7016: Could not find a declaration file for module 'legacy-colors'.

The message goes on to say that the package's JavaScript file "implicitly has an 'any' type" and suggests two fixes: npm i --save-dev @types/legacy-colors, or your own .d.ts file. The first leads to DefinitelyTyped: a large GitHub repository collecting declaration files written by the community for thousands of libraries that don't ship their own types. Every package there is published to npm with the @types/ prefix. Once you install it, TypeScript resolves the import to the .d.ts under node_modules/@types/legacy-colors; you don't need any extra setting.

Global declarations are different. Packages like @types/node declare globals that exist everywhere, such as process. Since TS 6.0 the default of the types setting in tsconfig is an empty array, which means the globals of packages under @types are no longer loaded automatically:

// Error: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
console.log(process.env.HOME);

The message already tells you what to do: install the package and add it to tsconfig with "types": ["node"]. That way you explicitly choose which global types enter the project.

declare module: Writing Your Own Declaration

If DefinitelyTyped has no types for the package, or you only use a few of its functions, you write the declaration yourself. Create a folder, say types, in your project and add it to the include list in tsconfig:

// types/legacy-colors.d.ts
declare module "legacy-colors" {
  export const colorNames: readonly string[];
  export function isColorName(value: string): boolean;
}

declare module "legacy-colors" describes what's inside the module imported by the name in quotes. Now import { isColorName } from "legacy-colors" passes type checking and isColorName(42) reports an error. If you're in a hurry there's also a short form without a body: declare module "legacy-colors";. It silences TS7016 but makes everything coming from the module any; think of it as a temporary fix.

The same syntax also accepts a wildcard. In projects that use a bundler it's common to import non-JavaScript files like CSS, and TypeScript doesn't recognize them on its own:

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

Without this declaration, the line import "./styles.css" reports "Cannot find module or type declarations for side-effect import of './styles.css'." (TS2882), because of noUncheckedSideEffectImports, which has been on by default since TS 6.0. With the declaration in place you can write import styles from "./button.css" and use styles.primary as a string.

Ambient Declarations

Some values don't come from any module; they're already present in the environment. A version number that the build tool injects into the code at compile time is a good example. These are called ambient declarations and are written with declare:

// types/build.d.ts
declare const __APP_VERSION__: string;

This file has no import or export. Remember the distinction from the modules post: a file like this is a script, and its declarations land in the global scope. That's why every file in the project can use __APP_VERSION__ without importing it. Add an export to the same file and the declaration gets locked inside that file, invisible from anywhere else.

The same approach works for old scripts that expose a library through a single global object. This is the place we promised in the previous post: with declare namespace you can group types under one name, and since it leaves nothing behind at runtime, it's fully erasable.

Global Augmentation

The answer to the opening question lies in this distinction too. The export {} at the end of global.ts makes the file a module. An interface Window written inside a module doesn't merge with the global Window; it becomes a new interface that belongs to that module and that nobody uses. main.ts, meanwhile, looks at the global Window:

main.ts(1,20): error TS2339: Property 'appConfig' does not exist on type 'Window & typeof globalThis'.

The way to add to the global scope from inside a module is declare global:

// global.ts
interface AppConfig {
  apiUrl: string;
  debug: boolean;
}

declare global {
  interface Window {
    appConfig: AppConfig;
  }
}

export {};

This is called global augmentation. A declare global block can only be written inside a module; that's why the export {} line stays there. Now window.appConfig.apiUrl shows up as a string in every file. AppConfig itself stays in the module; we didn't fill the global scope with names nobody needs.

Generating .d.ts Files with declaration: true

So far we've written .d.ts files by hand. If your code is already TypeScript, tsc does it for you. When you publish a library, you give your users the types along with the JavaScript:

// src/cart.ts
export interface CartItem {
  sku: string;
  price: number;
  quantity: number;
}

const TAX_RATE = 0.2;

export function cartTotal(items: readonly CartItem[]) {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return Math.round(subtotal * (1 + TAX_RATE) * 100) / 100;
}

export class Cart {
  #items: CartItem[] = [];

  add(item: CartItem): this {
    this.#items.push(item);
    return this;
  }

  get total() {
    return cartTotal(this.#items);
  }
}
{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true
  },
  "include": ["src"]
}

After npx tsc, this cart.d.ts sits next to cart.js in the dist folder:

export interface CartItem {
  sku: string;
  price: number;
  quantity: number;
}
export declare function cartTotal(items: readonly CartItem[]): number;
export declare class Cart {
  #private;
  add(item: CartItem): this;
  get total(): number;
}

A few details stand out. We never wrote the return type of cartTotal; tsc found number through inference and wrote it into the file. TAX_RATE, which isn't exported, is missing entirely, because it isn't visible from outside. #items shows up only as #private;; the fact that the class has a private field remains, but its type and name don't leak. If you only want the .d.ts, for example because another tool produces the JavaScript, you can add emitDeclarationOnly.

The rootDir line in tsconfig isn't decoration. Since TS 6.0 the default rootDir is the folder containing tsconfig. With the sources under src and no such line, tsc 7.0 reports error TS5011 and asks you to set rootDir explicitly.

When you publish the package, you point to the types in package.json. In the exports field, the types condition should come first:

{
  "name": "cart-utils",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/cart.d.ts",
      "default": "./dist/cart.js"
    }
  }
}

Common Pitfalls

Not noticing that the file is a module when you make a global addition. A single import line makes a file a module, and without declare global your addition quietly stays local.

Making the short declare module "x"; form permanent. The error goes away, but everything from that library becomes any; type checking is effectively switched off at that point.

Blindly trusting that a .d.ts is correct. Types written by hand or coming from @types may lag behind the installed version of the library. When you update a package, update the matching @types package too.

Writing a .d.ts by hand and keeping it next to TypeScript code. If the source is already TypeScript, use declaration: true; a hand-written copy will drift away from the code one day.

A Small Experiment

★☆☆ Put format.js and format.d.ts from this post in a folder and write a main.ts that imports them. See that the call formatPrice("12", "USD") reports an error and formatPrice(12, "USD") passes.

★★☆ Predict, then try: if you add export {}; to the end of types/build.d.ts, does the line console.log(__APP_VERSION__) compile? Why?

★★☆ Fix global.ts from the Predict First section without using declare global: stop the file from being a module. What might the downside of this approach be?

★★★ Add export type Currency = "USD" | "EUR"; to cart.ts, along with a function that takes this type as a parameter. Before compiling with declaration: true, write down on paper how it will look in the .d.ts, then compare it with the output.

With this post the intermediate series is complete: from generics to utility types, from type guards to modules and declaration files, we've covered nearly all of everyday TypeScript. In the advanced series we move from writing types to producing them. We'll make decisions between types with conditional types, capture pieces from inside a type with infer, and derive new types from existing ones with mapped types and template literal types; we'll write utility types like ReturnType, which we used in the intermediate series, ourselves, and push the limits of type-level programming. At the end of the road we'll also get into decorators and the details of TypeScript 7's compiler written in Go.