Writing TypeScript Declaration Files for JavaScript Projects
Writing TypeScript declaration files is one of the most practical ways to bring type safety to a JavaScript codebase. A declaration file (.d.ts) describes the shape of your code: types, function signatures, module interfaces — without changing anything at runtime. When you work with an untyped JavaScript library, or when you gradually adopt TypeScript in an existing project, knowing how to write declaration files yourself gives you control over what your editor and compiler see. They bridge the gap between dynamic JavaScript and the type system.
What is a declaration file?
A declaration file contains only type information. It tells TypeScript what types exist in your JavaScript code without adding any runtime behavior. The file ends with .d.ts and gets automatically picked up by TypeScript.
Here’s the simplest declaration file:
// greeter.js
function greet(name) {
return "Hello, " + name + "!";
}
The declaration file describes the same function using only type syntax. It tells TypeScript that greet takes a string and returns a string, without repeating the implementation. During compilation, TypeScript reads both files and uses the .d.ts to verify callers are passing the right arguments, while the JavaScript still handles the actual runtime behavior.
// greeter.d.ts
declare function greet(name: string): string;
The declare keyword tells TypeScript that something exists but was defined elsewhere. You can declare functions, variables, classes, and modules.
Creating declaration files for a JavaScript project
When you have a JavaScript project and want TypeScript to understand it, you have three main approaches.
Approach 1: automatic declaration generation
If you’re converting a JavaScript project to TypeScript incrementally, set declare in your tsconfig.json:
{
"compilerOptions": {
"allowJs": true,
"declaration": true,
"outDir": "./dist"
}
}
With these options, TypeScript automatically generates .d.ts files for your JavaScript code during compilation. This approach works well when you already have TypeScript in your build pipeline and want the compiler to produce types from your source files. The generated declarations mirror whatever TypeScript can infer from your code.
Approach 2: ambient declaration files
Create a types folder and add declaration files that TypeScript will automatically find. Ambient declarations describe libraries that you didn’t write yourself. They tell TypeScript about external modules without touching their source code:
// types/my-library.d.ts
declare module "my-library" {
export function calculate(a: number, b: number): number;
export const VERSION: string;
}
TypeScript looks for .d.ts files in your typeRoots or automatically in a types directory. This is the go-to pattern when you are consuming a third-party JavaScript package that ships without types. Instead of waiting for the library author to add types, you create an ambient module that TypeScript picks up automatically.
Approach 3: publishing declaration files
If you’re publishing a library to npm, include the declaration files in your package. The types field in package.json points consumers to where your type definitions live:
{
"name": "my-library",
"types": "dist/index.d.ts"
}
When you build your TypeScript project with declaration: true, TypeScript outputs the .d.ts files to your out directory.
Extending existing types
Sometimes you need to add types to something that already has them. TypeScript lets you extend built-in types and third-party library types.
Module Augmentation
You can add to existing module exports:
// Extend the Express Request type
import 'express';
declare module 'express' {
export interface Request {
user?: {
id: string;
role: string;
};
}
}
Now every Request object in your Express app has an optional user property. The augmentation merges into the existing Express types, so TypeScript treats the new field as if it were part of the original library. You get autocompletion for req.user everywhere, and the compiler catches misspellings or wrong types on that field. This is particularly useful in middleware chains where one handler attaches data that downstream handlers consume:
import { Request, Response, NextFunction } from 'express';
function authenticate(req: Request, res: Response, next: NextFunction) {
if (req.user) {
console.log(`User ${req.user.id} authenticated`);
}
next();
}
Module augmentation changes only the types, not the runtime. The import 'express' statement tells TypeScript to augment the existing module rather than creating a new one. This same technique works for any third-party library. You can add properties to existing interfaces without forking the library’s type definitions.
Global Augmentation
Module augmentation targets exported interfaces from a specific package. Global augmentation extends types that exist in the global scope, like built-in prototypes or browser APIs. This is less common but useful when you add methods to String.prototype, Array.prototype, or the window object:
declare global {
interface String {
capitalize(): string;
}
}
String.prototype.capitalize = function() {
return this.charAt(0).toUpperCase() + this.slice(1);
};
This adds a capitalize() method to all strings in your codebase. Because the augmentation uses declare global, TypeScript recognizes it everywhere without needing an explicit import. The actual implementation is still plain JavaScript assigned to String.prototype; the declaration file only describes the type. Be cautious with global augmentations: they affect every file in your project, so a name collision or an inaccurate type can cause widespread confusion.
Practical Patterns
Declaration files for functions
When declaring function types, be specific about parameters and return types. A well-written function declaration tells callers exactly what they can pass in and what shape of value they will get back. Overloaded signatures let you express different argument patterns that all resolve to compatible implementations:
// Good: specific types
declare function fetchData(url: string, options?: RequestInit): Promise<any>;
// Good: overloads for different calling patterns
declare function transform(input: string): string;
declare function transform(input: number): number;
declare function transform(input: string | number): string | number;
Overloaded signatures let TypeScript narrow the return type based on the argument type. When you call transform("hello"), the compiler knows the result is a string. When you call transform(42), it infers number. This is especially valuable for utility functions that handle multiple input types with consistent behavior.
Declaration files for classes
Class declarations mirror the runtime class shape. The .d.ts version lists only the public API: constructor parameters, instance properties, and method signatures — without any implementation:
// Point.js (runtime code)
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distanceTo(other) {
return Math.sqrt(
Math.pow(this.x - other.x, 2) +
Math.pow(this.y - other.y, 2)
);
}
}
The corresponding declaration strips out the method bodies and constructor logic, leaving only the type information. Notice that distanceTo keeps its parameter and return type but loses the Math.sqrt implementation. TypeScript doesn’t need to know how the method computes the distance. It only needs to know the contract:
// Point.d.ts
declare class Point {
constructor(x: number, y: number);
x: number;
y: number;
distanceTo(other: Point): number;
}
A class declaration also tells TypeScript that Point can be used with new. Without the declare class keyword, TypeScript would treat Point as a plain value and flag any new Point() calls as errors.
Declaration files for objects
For object literals and namespaces, you have two main patterns. A declare const describes an object with a fixed shape, useful for configuration objects. A declare namespace groups related functions and values under a single name, similar to how modules work but without requiring import statements:
declare const config: {
apiUrl: string;
timeout: number;
retries: number;
};
declare namespace MyUtils {
function parse(input: string): object;
const VERSION: string;
}
Choose declare const for single objects with a known shape. Choose declare namespace when you have several related utilities that belong together. Namespaces can be nested, so you can model deep object hierarchies without creating separate module files.
Declaration files for npm packages
When creating types for an npm package you’re publishing, include the declaration file in your published package. The types field in your package.json tells consumers where to find the entry point for your type definitions:
{
"name": "my-utils",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"]
}
The files field ensures only the dist directory is published, keeping your package small. The main field points to the compiled JavaScript entry point, while types gives TypeScript the declaration entry point, so consumers get both in a single install.
Build your package with TypeScript to generate both the JavaScript and declaration files. The declarationDir option keeps your .d.ts files separate from your compiled .js files, which makes the output directory cleaner:
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist"
}
}
When consumers run npm install my-utils, TypeScript automatically finds and uses the declaration file. The consumer gets autocompletion and type checking for your package without any extra configuration. The types field in your package.json handles the wiring automatically.
Common Pitfalls
Forgetting to Export
In module declaration files, everything inside a declare module block must be explicitly exported. TypeScript treats unexported members as private to the module, invisible to consumers:
// Wrong - TypeScript won't see these
declare module "my-module" {
function foo(): void; // Won't work
}
// Correct
declare module "my-module" {
export function foo(): void;
}
This is a common mistake when converting ambient declarations from DefinitelyTyped or older codebases. If TypeScript can’t find an export you believe should exist, check whether the declaration uses export on each member. The fix is mechanical but easy to overlook during a first pass.
Mismatch Between JS and .d.ts
Your declaration file must match your JavaScript exactly. A mismatch in parameter optionality is one of the easiest bugs to introduce: the JavaScript may work correctly, but the declaration will produce false type errors at every call site:
// runtime.js
function greet(name = "World") {
return `Hello, ${name}!`;
}
The default parameter name = "World" means callers can omit the argument entirely. A declaration that requires name: string would force every call site to pass a value, and TypeScript would flag greet() as an error even though the JavaScript runs fine. The declaration must mirror this optionality:
// runtime.d.ts - WRONG
declare function greet(name: string): string;
// runtime.d.ts - CORRECT
declare function greet(name?: string): string;
The parameter is optional in the JS code, so it must be optional in the declaration. A mismatch here is one of the most common sources of false type errors when adding declarations to an existing JavaScript project. Always check whether parameters have default values, and whether rest parameters or destructuring are used. Each of these affects the declaration shape.
Triple-Slash Directives
Older code often uses triple-slash directives to reference declaration files. These were the original way to include type references before tsconfig.json gained widespread support:
/// <reference path="./types.d.ts" />
Modern projects prefer tsconfig.json paths or typeRoots instead.
Keeping declarations aligned
The safest declaration files are the ones that stay close to the JavaScript they describe. When you change a runtime function, update the declaration at the same time and check whether the parameter list, return type, and optional fields still match. A declaration file can look correct while quietly drifting away from the real code, so small reviews are worth the effort.
It also helps to keep declarations simple. If the public API is obvious from the source code, mirror that shape rather than inventing a more abstract version. Consumers want to know what they can pass in and what they will get back. Clear declarations reduce guesswork and make editor tooling much more reliable.
Modeling module shapes
Declaration files are often about describing whole modules, not just isolated functions. That includes named exports, default exports, classes, namespaces, and any nested objects that callers can access. When you map the module shape carefully, TypeScript can guide consumers without forcing them to read the implementation.
This is especially useful for JavaScript packages that expose a stable surface but are not written in TypeScript. A well-written .d.ts file becomes a contract. It tells users which parts are safe to rely on and which parts are internal details that may change later. That boundary is valuable for both library authors and app teams.
When to publish types
If your package is meant for other projects, shipping declarations is usually worth the effort. It gives consumers autocompletion, better diagnostics, and a clearer sense of the API. For internal code, declaration files still help when you are gradually moving a JavaScript codebase toward stricter typing. You can annotate the parts that matter first and fill in the rest over time.
The main goal is accuracy. A type file that is too broad can hide mistakes, and a file that is too narrow can frustrate users with false errors. Aim for a middle ground where the declarations describe the real behavior without overpromising details that the runtime does not guarantee.
Versioning Types
Type definitions should change with the API they describe. When the runtime adds a field, changes a return value, or removes a function, the declaration file should reflect that change in the same release. That keeps the contract honest and helps consumers upgrade with less guessing.
It is also helpful to treat type changes as part of the public surface, not as an afterthought. A small edit in a declaration file can have a large effect on downstream code. Clear release notes and careful reviews make those changes easier to trust.
Keeping the surface stable
Stable declarations are often the result of careful restraint. If the runtime exposes one obvious way to do something, the type file should probably show that same shape rather than trying to predict every possible variation. A declaration that mirrors the real API is easier for consumers to learn and less likely to drift from the code.
That stability also helps tools help you. Editors can offer better completions when the declarations are clear, and test failures are easier to connect back to the right type change. The more consistent the surface, the less time you spend asking whether a type error is real or just a bad declaration.
See also
- JavaScript Modules — Understand module systems that declaration files describe
- TypeScript Types and Interfaces — Learn how TypeScript types and interfaces relate to declarations
- JavaScript Symbols — Learn about JavaScript primitive types that declaration files can type
- JavaScript Proxies — Understand the Proxy API that declaration files can declare