TypeScript Declaration Files: Writing .d.ts for Libraries
Declaration files (.d.ts) describe the shape of JavaScript modules so TypeScript can provide type checking and autocompletion for untyped code. Every npm package that ships TypeScript types — either bundled or via @types/ — has a declaration file at its core.
This guide covers writing declaration files, ambient declarations, module augmentation, triple-slash directives, and publishing type definitions.
Declaration File Basics
A declaration file contains only type information — no implementation. It uses the declare keyword to describe the shape of existing code:
// types.d.ts
declare function calculateTax(amount: number, rate: number): number;
declare class Calculator {
constructor(initialValue: number);
add(x: number): number;
getResult(): number;
---Ambient Declarations
Ambient declarations describe code that exists at runtime but is not written in TypeScript. Use declare module to describe npm packages:
// globals.d.ts
declare module "my-custom-lib" {
export function doSomething(config: Record<string, unknown>): void;
export const VERSION: string;
---Triple-Slash Directives
Triple-slash directives reference other type declarations. They are used primarily in older codebases and global declaration files:
/// <reference types="node" />
/// <reference path="./custom-types.d.ts" />
The types directive references a package in @types/. The path directive references a local file.
Module Augmentation
Module augmentation extends the types of existing modules without modifying their source. This is useful for adding custom properties to library types — for example, adding req.user to Express requests:
// express-augmentation.d.ts
import "express";
declare module "express" {
interface Request {
user?: {
id: string;
name: string;
role: "admin" | "user";
};
}
---Global Augmentation
Global augmentation adds declarations to the global scope. Use this to extend built-in types or add global variables:
// global-augmentation.d.ts
declare global {
interface String {
toTitleCase(): string;
}
const APP_CONFIG: {
apiUrl: string;
environment: "development" | "production";
};
---Declaration Merging
TypeScript merges multiple declarations with the same name. Interface merging is particularly useful — you can declare an interface in multiple files and TypeScript combines them:
// file1.d.ts
interface Window {
myApp: { version: string };
---
// file2.d.ts
interface Window {
analytics: { track(event: string): void };
---
// Both properties are available on Window
Writing Declarations for Libraries
Using @types from DefinitelyTyped
Most popular libraries have community-maintained types published to @types/. Install them with npm:
npm install --save-dev @types/express @types/lodash @types/reactIf a library bundles its own types (indicated by the types field in package.json), you do not need the @types/ package.
Writing Custom Declarations
When types are not available on DefinitelyTyped, write minimal declarations covering only the APIs you use. Full-structure declarations are ideal but time-consuming to maintain:
// minimal-declarations.d.ts
declare module "legacy-library" {
export function init(config: { apiKey: string }): void;
export function track(event: string, properties?: Record<string, unknown>): void;
---Publishing Types
When publishing an npm package, include declaration files by setting declaration: true in tsconfig.json. TypeScript generates .d.ts files alongside compiled JavaScript. Point consumers to your types via the types field in package.json:
{
"name": "my-library",
"main": "dist/index.js",
"types": "dist/index.d.ts"
---Advanced Declaration Patterns
Declaring Namespaces
For legacy code that uses namespaces instead of modules:
declare namespace MyApp {
namespace Models {
interface User {
id: number;
name: string;
}
}
namespace Utils {
function formatDate(date: Date): string;
}
---Declaring Enums
Describe existing JavaScript enum-like objects:
declare const Colors: {
Red: "red";
Green: "green";
Blue: "blue";
---;
type Colors = (typeof Colors)[keyof typeof Colors];Declaring Classes
Use declare class to describe JavaScript classes:
declare class EventEmitter {
static defaultMaxListeners: number;
on(event: string, listener: (...args: any[]) => void): this;
emit(event: string, ...args: any[]): boolean;
removeListener(event: string, listener: (...args: any[]) => void): this;
---Best Practices
- Prefer
interfaceovertypefor object shapes that might be extended - Use generic constraints to provide accurate types for parameterized APIs
- Mark optional properties with
?and readonly properties withreadonly - Use
unknowninstead ofanyfor truly flexible types — it forces type checking before use - Include JSDoc comments in declaration files — they appear in editor tooltips
- Test your declarations by consuming them in a separate TypeScript file
Summary
Declaration files bridge TypeScript’s type system with existing JavaScript code and libraries. Use ambient declarations for describing global code, module augmentation for extending existing types, and triple-slash directives for referencing other declarations. When writing a library, enable declaration: true to generate types automatically.
FAQ
What is the difference between a .d.ts file and a regular .ts file? A .d.ts file contains only type declarations — no implementation. It describes the shape of existing code for TypeScript’s type checker. A .ts file contains both types and implementation and is compiled to JavaScript.
How do I fix “Cannot find module” errors for a library? First check if the library bundles types. If not, install @types/library-name. If types are not available on DefinitelyTyped, create a local declaration file with declare module "library-name" { ... }.
Can I use TypeScript to generate declaration files? Yes. Set declaration: true in tsconfig.json. The compiler generates .d.ts files for all your .ts files automatically. For libraries, also set declarationMap: true for better IDE navigation.
How do I handle version mismatches between a library and its types? Keep the @types/ version in sync with the library version. The DefinitelyTyped project maintains versions alongside library releases. Use your package manager’s version pinning to lock both.
Declaration File Structure
Declaration files (.d.ts) describe the shape of JavaScript modules to TypeScript. A well-structured declaration file includes:
Module Declarations
// types/mylib.d.ts
declare module "mylib" {
export function doSomething(options: Options): Result;
export interface Options {
timeout: number;
retries?: number;
}
export interface Result {
success: boolean;
data: unknown;
}
---Augmenting Existing Modules
When a library is missing types, augment it using module augmentation:
// types/augmentations.d.ts
import "express";
declare module "express" {
interface Request {
user?: { id: string; role: "admin" | "user" };
}
---Module augmentation is also useful for adding types to ambient modules, polyfilling missing exports, or extending library interfaces with custom properties. Place augmentations in a separate file and include it via the files or include field in tsconfig.
Global Declarations
For scripts loaded via <script> tags (not modules), use global declarations:
// types/globals.d.ts
declare const API_KEY: string;
declare function trackEvent(name: string, properties?: Record<string, unknown>): void;Global declarations should be used sparingly — prefer module-based declarations that support explicit imports and avoid polluting the global namespace.
Tripe-Slash Directives
Triple-slash directives are TypeScript compiler directives embedded in comments:
/// <reference path="./types.d.ts" />
/// <reference types="node" />
/// <reference lib="es2021" />
These are primarily needed for declaration files and legacy code. Modern TypeScript projects should use tsconfig.json files, include, and types fields instead — they provide better tooling support and are more maintainable.
Generating Declaration Files
When creating a TypeScript library, configure tsconfig to emit declaration files automatically:
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist/types",
"emitDeclarationOnly": false
}
---For mixed JavaScript/TypeScript codebases, enable allowJs and checkJs to gradually add type checking to JavaScript files. Use // @ts-check at the top of individual JS files for incremental adoption. The TypeScript compiler infers types from JSDoc comments and JavaScript patterns, producing declaration files that provide editor completions for JavaScript consumers.
Publishing Types to npm
When publishing a package, include types by setting the types field in package.json:
{
"name": "@myorg/my-lib",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"]
---For dual CJS/ESM packages, add exports field with type information:
{
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
}
---This ensures consumers get proper type checking regardless of their module system.
Practical Type Patterns for Large Codebases
When building large TypeScript applications, several patterns keep type complexity manageable:
Discriminated unions for API responses — Model every API endpoint as a union of success, error, and loading states:
type AsyncState<T, E = Error> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: E };This pattern eliminates impossible states — you cannot accidentally access data when status is "loading". The compiler enforces exhaustive handling of all states, preventing common bugs.
Zod for runtime validation — TypeScript types are compile-time only. Use Zod to validate external data at runtime and infer the type:
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
---);
type User = z.infer<typeof UserSchema>;
// Validate API response at runtime
const user = UserSchema.parse(apiResponse);
// user is fully typed as User
This dual approach provides runtime safety for data entering your system (API responses, file parsing, user input) and compile-time safety everywhere else.
Avoiding Type Complexity Traps
Complex types (nested conditionals, deep mapped types, recursive type aliases) can make code harder to understand. Apply these guidelines:
- Keep conditional types shallow — avoid nesting beyond 2-3 levels
- Name intermediate types instead of inlining complex expressions
- Prefer interfaces over type aliases for object shapes (interfaces have better error messages and are faster to check)
- Use
typefor unions, intersections, and mapped types - Extract reusable type utilities into a shared types module
Related: TypeScript Module System | TypeScript Config