---
title: "How to Create Type Definitions in TypeScript (.d.ts)"
date: "2020-04-21"
slug: "how-to-create-type-definitions"
author: "Dany Paredes"
canonical: "https://danywalls.com/how-to-create-type-definitions"
description: "Learn how to write custom .d.ts declaration files in TypeScript for untyped npm packages, module augmentation, and global variables."
---


We have all experienced this moment in TypeScript. You install a useful npm package, import it into your code, and the TypeScript compiler immediately stops you with an error:

```bash
Could not find a declaration file for module 'old-library'.
'node_modules/old-library/index.js' implicitly has an 'any' type.
Try `npm i --save-dev @types/old-library` if it exists.
```

You try to run `npm i -D @types/old-library`, but npm tells you the package does not exist on DefinitelyTyped.

Many developers respond by adding `// @ts-ignore` or casting the import to `any`. That removes the compiler warning, but it destroys type safety and autocompletion for your entire team.

The clean solution is to write your own **type definition file** (`.d.ts`). In this post, I will show you how to declare types for untyped npm packages, configure `tsconfig.json`, and augment global objects like `window` and `process.env`.

---

## 1. What Are Declaration Files (`.d.ts`)?

TypeScript separates executable logic from type descriptions. Standard `.ts` files compile to `.js` files. In contrast, `.d.ts` files contain **only type metadata**:

- They never generate JavaScript output.
- They describe function signatures, class shapes, and object properties.
- They use the `declare` keyword to inform TypeScript that the real implementation exists elsewhere at runtime.

Before writing complete signatures, let us look at the fastest way to unblock your build.

---

## 2. Quick Fix: Shorthand Ambient Declarations

If you need your project to build immediately, you can create a shorthand ambient module declaration.

Create a folder in your project named `src/types/` and add a file named `untyped-modules.d.ts`:

```typescript
// src/types/untyped-modules.d.ts
declare module 'old-library';
```

This single line tells TypeScript: *"Trust me, 'old-library' exists at runtime. Treat anything imported from it as `any`."*

```typescript
import { calculateTotal } from 'old-library';

// calculateTotal is typed as any, but the build succeeds
const result = calculateTotal(10, 20);
```

This shorthand fixes the compiler error instantly. However, you do not get autocompletion or parameter safety. To get full type safety, we need to write specific type definitions.

---

## 3. Writing Complete Module Declarations

Let us imagine we have an untyped HTTP utility package named `simple-fetcher`.

The library exposes a client object with `get`, `post`, and custom request options. We want full autocompletion in our editor.

Create a file named `src/types/simple-fetcher.d.ts`:

```typescript
// src/types/simple-fetcher.d.ts

declare module 'simple-fetcher' {
  export interface RequestOptions {
    timeout?: number;
    headers?: Record<string, string>;
  }

  export interface ApiResponse<T> {
    data: T;
    status: number;
    ok: boolean;
  }

  export function get<T = unknown>(
    url: string,
    options?: RequestOptions
  ): Promise<ApiResponse<T>>;

  export function post<T = unknown>(
    url: string,
    body: unknown,
    options?: RequestOptions
  ): Promise<ApiResponse<T>>;
}
```

Every exported item matches the runtime JavaScript behavior of the package. Now when we consume the library, our editor provides autocomplete and strict parameter checking:

```typescript
import { get, RequestOptions } from 'simple-fetcher';

interface User {
  id: string;
  name: string;
}

const config: RequestOptions = { timeout: 5000 };

// TypeScript knows response.data is User!
const response = await get<User>('https://api.example.com/user/1', config);
console.log(response.data.name);
```

If you pass an invalid option (like `timeout: "fast"`), the compiler catches the error immediately before your code reaches production.

Now that our type file is written, we must make sure the TypeScript compiler actually loads it.

---

## 4. Configuring tsconfig.json to Include Local Types

By default, TypeScript looks for type definitions inside `node_modules/@types`.

To make TypeScript recognize your custom `.d.ts` files, update your [tsconfig.json](file:///Users/danyparedes/Documents/projects/danywalls.com/tsconfig.json):

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "typeRoots": [
      "./node_modules/@types",
      "./src/types"
    ]
  },
  "include": [
    "src/**/*",
    "src/types/**/*.d.ts"
  ]
}
```

There are two important settings here:
- **`typeRoots`**: Tells TypeScript which directories contain ambient type definitions. Always keep `./node_modules/@types` so your installed npm types keep working.
- **`include`**: Ensures the compiler includes your `.d.ts` files during compilation.

With `tsconfig.json` configured, your custom definitions apply across the entire project.

Next, what happens when a package already has types, but you need to add custom methods? That requires **module augmentation**.

---

## 5. Module Augmentation: Extending Existing Libraries

Sometimes an existing library does have types, but your application adds a plugin or middleware that attaches new properties to its objects.

For example, imagine you use Express or Fastify, and your auth middleware attaches a `currentUser` to the request object.

You can augment the library's types inside a `.d.ts` file:

```typescript
// src/types/express-augmented.d.ts
import 'express';

declare module 'express-serve-static-core' {
  interface Request {
    currentUser?: {
      id: string;
      email: string;
      role: 'admin' | 'member';
    };
  }
}
```

Notice the `import 'express'` statement at the top. This import turns the file into an external module. This tells TypeScript to merge your new `currentUser` property with the existing `Request` interface rather than replacing it.

In your route handlers, the new property is now recognized:

```typescript
import { Request, Response } from 'express';

export function profileHandler(req: Request, res: Response) {
  if (!req.currentUser) {
    return res.status(401).send('Unauthorized');
  }

  // TypeScript knows currentUser has id, email, and role!
  res.json({ userId: req.currentUser.id, role: req.currentUser.role });
}
```

This same pattern works for extending global JavaScript objects like `window` or `process.env`.

---

## 6. Extending Global Objects (`window` and `process.env`)

In browser and Node.js environments, we frequently need to access environment variables or third-party scripts attached to the global scope.

### Adding Custom Properties to the Browser `window`

If you embed an analytics script or payment SDK (like Google Tag Manager or Stripe), it attaches to `window`:

```typescript
// src/types/global.d.ts
export {};

declare global {
  interface Window {
    dataLayer: Array<Record<string, unknown>>;
    analyticsScriptLoaded?: boolean;
  }
}
```

The `export {};` at the top ensures TypeScript treats the file as a module. Inside `declare global`, any interface you declare merges with the standard browser DOM types.

Now you can write `window.dataLayer.push(...)` without any TypeScript complaints.

### Typing Node.js `process.env`

Instead of seeing `string | undefined` everywhere you access environment variables, you can type `process.env`:

```typescript
// src/types/environment.d.ts
export {};

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
      DATABASE_URL: string;
      PORT?: string;
      API_SECRET_KEY: string;
    }
  }
}
```

Now whenever you read `process.env.DATABASE_URL`, TypeScript knows it exists and autocompletes the valid keys.

---

## 7. Comparison: When to Use Each Type Declaration Technique

Here is a quick reference table to help you pick the right technique:

| Approach | File Location | When to Use | Resulting Type Safety |
| :--- | :--- | :--- | :--- |
| **Shorthand Ambient** | `src/types/*.d.ts` | Quick fix for unblocking CI/CD builds | Low (`any`) |
| **Complete Module** | `src/types/*.d.ts` | Untyped npm packages without DefinitelyTyped | High (strict types) |
| **Module Augmentation** | `src/types/*.d.ts` | Adding plugins/middleware to typed libraries | High (interface merging) |
| **Global Augmentation** | `src/types/*.d.ts` | Typing `window`, `process.env`, or global scripts | High (global scope) |

---

## Conclusion & Next Steps

Writing custom `.d.ts` declaration files is one of the most practical skills you can have in TypeScript:

1. **Avoid `any` and `@ts-ignore`**: A short declaration file takes five minutes to write and protects your whole codebase.
2. **Start simple**: Use shorthand ambient declarations to unblock the build, then add explicit functions and interfaces as needed.
3. **Use augmentation**: Extend existing third-party interfaces and global objects using `declare module` and `declare global`.

If you want to continue strengthening your TypeScript architecture, take a look at these related guides:

- [Why Avoid Using 'any' in TypeScript](https://danywalls.com/why-avoid-using-any-in-typescript)
- [How to Check Types in TypeScript](https://danywalls.com/how-to-check-types-in-typescript)
- [3 Ways of Type Transformation in TypeScript](https://danywalls.com/3-ways-of-type-transformation-in-typescript)
- [Combining Types and Interfaces with & or | in TypeScript](https://danywalls.com/combining-types-and-interfaces-with-or-in-typescript)

