Introduction to Declaration Files (.d.ts): Typing Existing JS
Introduction
When you're using or shipping JavaScript libraries in a TypeScript codebase, declaration files (.d.ts) are the bridge that provides types without converting the original JavaScript to TypeScript. For intermediate developers, writing robust .d.ts files unlocks better editor IntelliSense, safer code, and easier adoption of legacy or third-party JS. This article teaches how to create, structure, and publish declaration files for different module systems, how to type complex APIs (including overloads, generics, and ambient modules), how to integrate with package.json and tsconfig, and how to troubleshoot common interoperability problems.
By the end of this tutorial you will know how to:
- write top-level declaration files for CommonJS, ESM, and UMD packages
- declare ambient modules and global augmentations
- author precise function, class, and namespace typings
- map runtime shape to expressive TypeScript types (including utility types and conditional types)
- publish types alongside JS and configure consumers correctly
You'll see many practical examples and step-by-step guidance so you can create maintainable .d.ts files and improve developer experience for your libraries or apps.
Background & Context
TypeScript's compiler consumes .d.ts files to obtain types for JavaScript code. A declaration file contains type-only declarations—no compiled JS—so authors of JS libraries can provide types without a full migration. Declaration files become part of your package distribution (via "types" or "typings" in package.json) or can be contributed to DefinitelyTyped.
Understanding how to write declaration files well requires knowledge of module formats (CommonJS vs ESM vs UMD), how TypeScript resolves modules, ambient declarations (declare module, declare global), and the expressive type system features that let you model JS behavior precisely (generics, overloads, mapped types, conditional types). For advanced typing, you might use utility types such as NonNullable
Key Takeaways
- Declaration files (.d.ts) express types for JS without generating JS code.
- You can declare modules, namespaces, classes, functions, and global augmentations.
- Use precise types (generics, overloads, union/tuple types) to reflect runtime behavior.
- Configure package.json and tsconfig so TypeScript consumers pick up your types.
- Advanced type-level techniques (conditional and mapped types) help when modeling flexible APIs.
Prerequisites & Setup
Before you begin, you should have:
- Node.js and npm/yarn installed.
- A JavaScript project to type (or a library you’re publishing) and an editor that supports TypeScript language services (VS Code recommended).
- Basic TypeScript knowledge: types, interfaces, modules, and generics. If you need to refresh conditional types or mapped types, see Introduction to Conditional Types: Types Based on Conditions and Introduction to Mapped Types: Creating New Types from Old Ones.
Create a minimal local setup to test types:
mkdir my-js-with-types cd my-js-with-types npm init -y npm install typescript --save-dev npx tsc --init
Set "allowJs": true if you want to compile JS with TS, and use "declaration": false since .d.ts files are written by hand. For library authors who emit types from TS sources, set "declaration": true in tsconfig.
Main Tutorial Sections
1. Basic .d.ts Structure and declare Forms
A declaration file contains only declarations. The most common patterns are declare module 'name', declare global, and top-level export declarations. Example: imagine a simple CommonJS module utils.js:
// utils.js
exports.add = function (a, b) { return a + b }
exports.VERSION = '1.0'Create utils.d.ts next to it:
declare module 'utils' {
export function add(a: number, b: number): number;
export const VERSION: string;
}If your project uses file-based imports rather than a module name, you can write an ambient file without declare module and simply export declarations from a root d.ts to match the file layout.
2. Export Styles: CommonJS, ESM, and UMD
Different runtime module systems require different declaration patterns. For CommonJS with module.exports =, you can use export = syntax:
// For module that does: module.exports = function fn() {}
declare function myDefault(...args: any[]): any;
export = myDefault;For ES module style export default, use export default:
export default function parse(input: string): AST;
For UMD (works as global and module), use export as namespace MyLib and normal exports:
export as namespace MyLib; export function fn(...args: any[]): any;
Publishing packages should set the package.json types field to point to your bundled .d.ts entry file.
3. Typing Functions, Overloads, and Call Signatures
Model runtime signatures precisely. Use overloads for functions that behave differently by argument shape:
export function fetch(url: string): Promise<string>;
export function fetch(url: string, opts: { json: true }): Promise<any>;
export function fetch(url: string, opts?: any): Promise<any> {
// no runtime body in .d.ts; here for illustration only
}For callable objects (functions with properties), declare an interface with call signature:
declare function middleware(req: Request, res: Response): void;
declare namespace middleware {
let version: string;
}
export = middleware;4. Classes, Interfaces, and Ambient Namespaces
To mirror class-based libraries, model constructors, static members, and prototypes:
export class Client {
constructor(config?: ClientConfig);
request(path: string): Promise<Response>;
static defaults: { timeout: number };
}
export interface ClientConfig {
baseUrl?: string;
}Use declare namespace when a library exposes a function with attached properties or needs a legacy namespace. Namespaces can also be combined with export = for older patterns.
5. Index Signatures and Dynamic Objects
If an API returns objects with dynamic keys, use index signatures to model them safely. Example: a settings object keyed by string:
export interface Settings {
[key: string]: string | number | boolean;
}If you need finer control (e.g., only certain keys allowed or known keys plus dynamic ones), combine known properties with index signatures. For more advanced patterns around typing dynamic property names, consult Index Signatures in TypeScript: Typing Objects with Dynamic Property Names.
6. Using Utility Types and Modeling Narrowing
In declaration files you can and should leverage TypeScript utility types to keep declarations concise and correct. Use Pick, Omit, Readonly, and others when modeling transformed objects:
export type PublicOptions = Omit<InternalOptions, 'secretKey'>; export type ImmutableConfig = Readonly<Config>;
If your JS runtime removes nulls, prefer NonNullable<T> to reflect that: see Using NonNullableExtract and Exclude utilities are handy—read our deep dive on Extract<T, U> and Using Exclude<T, U>: Excluding Types from a Union.
7. Advanced Types in Declarations: Conditional & Mapped Types
Sometimes you need to model APIs that transform shapes based on input. Conditional and mapped types in .d.ts files let you express this at the type level (no runtime cost). For example, a wrapper that makes properties optional or readonly conditionally:
type MaybeArray<T> = T | T[];
type APIResponse<T> = T extends { data: infer D } ? D : T;If you rely on key remapping or need to transform object keys, mapped types with as are powerful—see our guide on Key Remapping with as in Mapped Types — A Practical Guide and the Basic Mapped Type Syntax ([K in KeyType]) article for deeper background. Also, mastering conditional types helps when writing these generic declarations—refer to Mastering Conditional Types in TypeScript (T extends U ? X : Y).
8. Declaring Third-Party Modules and Ambient Declarations
If you consume a JS-only module and want to add types locally, create a types/ folder and inside types/some-lib/index.d.ts write:
declare module 'some-lib' {
export function transform(x: any): any;
}Then point typeRoots in tsconfig or include the path via @types conventions. For quick local patches, ambient modules are appropriate. If you want your types to be used only for development, publishing to DefinitelyTyped is an option.
9. Augmenting Global Interfaces and declare global
Libraries that add to global objects (like polyfills or runtime helpers) should augment the global scope safely:
declare global {
interface Window {
myLib: MyLibAPI;
}
}
export interface MyLibAPI {
doThing(): void;
}Wrap such augmentations in a module if you're also exporting other symbols. Consumers will pick up augmentations when your .d.ts file is included.
10. Testing Declaration Files and Editor Experience
To validate declarations, create a small TypeScript project that imports your module and exercises the public API. Use tsc --noEmit or enable skipLibCheck accordingly. Add tests with tsd (npm i -D tsd) to write type-level assertions. Example tsd test:
import { add } from './dist/utils';
import { expectType } from 'tsd';
expectType<number>(add(1, 2));This ensures your declarations don't regress and your consumers get accurate type feedback.
Advanced Techniques
Expert-level approaches can help model tricky runtime behaviors:
- Use conditional types and
inferwithin .d.ts to extract types from complex generics. See Using infer in Conditional Types: Inferring Type Variables for patterns. - Leverage mapped types with
asto remap keys when writing wrapper types—this is useful when your JS API renames or proxies keys; see Key Remapping withasin Mapped Types — A Practical Guide. - Write explicit type predicate declarations for custom type guard functions in JS so TS consumers can benefit from narrowing. See our guide on Custom Type Guards: Defining Your Own Type Checking Logic to learn how to declare
function isFoo(x: any): x is Foo;in a .d.ts. - When modeling APIs that return narrowed unions based on runtime checks, leverage conditional types alongside union utilities like
ExtractandExcludefor accuracy.
Best Practices & Common Pitfalls
Dos:
- Keep declarations minimal and focused on the public API surface.
- Prefer precise types over just
any—types improve DX and catch bugs. - Use
tsdor a sample TypeScript consumer project to validate types regularly. - Document assumptions in comments so future maintainers understand runtime constraints.
Don'ts:
- Don't include runtime code in .d.ts files—only declarations belong here.
- Don't over-export internal helpers; keep the public API small.
- Avoid writing fragile types that tightly couple to implementation details that may change frequently.
Troubleshooting tips:
- If consumers can't find types, ensure package.json has
typesortypingspointing at your entry .d.ts file. - If type resolution fails for packages with both ESM and CommonJS builds, provide both
exportsandtypesfields, and author dual declarations or guide consumers to the right import style. - If third-party modules are untyped, consider contributing to DefinitelyTyped or shipping minimal ambient modules in your repo.
Real-World Applications
- Wrapping legacy JS libraries for internal use: create a small set of .d.ts files that describe the API surface and let your team consume them with full type safety.
- Publishing JS packages with first-class TypeScript support: include a bundled
index.d.tsand addtypesto package.json so downstream developers get types automatically. - Migrating incrementally: keep runtime in JS but add .d.ts files for critical modules, gradually improving types over time.
Conclusion & Next Steps
Declaration files are a practical way to make JavaScript safer for TypeScript consumers without a full rewrite. Start by modeling the public API surface, validate with tests, and progressively refine types using advanced TypeScript features. Next, learn more about advanced type-level tools and mapped/conditional types to produce expressive declarations.
For further reading, revisit guides on conditional types, mapped types, and utility types linked throughout this article.
Enhanced FAQ
Q1: What file name should I use for declaration files?
A1: Use descriptive names that mirror the runtime layout. If your package entry is index.js, provide index.d.ts next to it and set "types": "index.d.ts" in package.json. For ambient patches or multiple modules, place files under a types/ folder and configure typeRoots in tsconfig if necessary.
Q2: Should I write .d.ts files or convert the project to TypeScript? A2: It depends on goals. If you only need to give types to consumers or to limit risk, .d.ts files are fast and non-invasive. For long-term maintainability and runtime type safety, migrating to TypeScript may be beneficial. You can combine strategies: incrementally migrate core modules while shipping .d.ts for the rest.
Q3: How do I model functions with multiple behaviors? (Example: curry, variadic args)
A3: Use function overloads and tuple/rest types. For variadic behavior returning different types based on arguments, overloads are easiest to read. For more dynamic patterns, generic tuple types and conditional inference with infer can express transformations (see Using infer in Conditional Types: Inferring Type Variables).
Q4: How do I declare types for a module that mutates the global scope?
A4: Use declare global {} blocks in your .d.ts and ensure the file is included by consumers (via types or typeRoots). Wrap global augmentation in a module if your package also exports symbols.
Q5: How to author types that depend on different runtime builds (CJS vs ESM)?
A5: Provide declarations that reflect both consumer styles. You can ship a single .d.ts that uses export = for CommonJS or provide separate entry points with different typings matched to the exports field in package.json. Test both import styles in a sample consumer project.
Q6: When should I use ambient modules (declare module 'x') versus regular exports?
A6: Use ambient modules when typing third-party packages or when the declared module name differs from the file layout. For your own package files, prefer file-scoped export declarations that match the file structure.
Q7: How do I represent runtime narrowing (type guards) in .d.ts?
A7: Declare the function with a type predicate, e.g. export function isFoo(x: any): x is Foo;. This tells TypeScript that after calling isFoo(x) in a conditional, x is treated as Foo inside the true branch. See Custom Type Guards: Defining Your Own Type Checking Logic for more patterns.
Q8: Can I use advanced mapped and conditional types in .d.ts files?
A8: Absolutely. Declaration files can contain any TypeScript type-level constructs. For instance, you can use mapped types to transform keys and conditional types to create context-sensitive return types. For patterns and examples, check Key Remapping with as in Mapped Types — A Practical Guide and Mastering Conditional Types in TypeScript (T extends U ? X : Y).
Q9: How should I test declarations before publishing?
A9: Use tsc --noEmit against a small test project that imports your package, and add tsd tests to assert type expectations. Also, ensure your package installs and the types entry resolves properly. Automated CI that runs tsc and tsd helps catch regressions.
Q10: What are common pitfalls when consumers still see any despite providing .d.ts?
A10: Common causes: wrong types path in package.json, skipLibCheck hiding errors, mismatched import names, or TypeScript resolving a different package path (monorepos can complicate resolution). Validate resolution with tsc --traceResolution and ensure your .d.ts matches the published layout.
Further Reading and Related Topics
- If you need to transform types in your declarations, our guides on mapped types and conditional types are good next steps: Introduction to Mapped Types: Creating New Types from Old Ones and Introduction to Conditional Types: Types Based on Conditions.
- Learn how to extract and exclude union members when modeling complex APIs with Deep Dive: Using Extract<T, U> to Extract Types from Unions and Using Exclude<T, U>: Excluding Types from a Union.
- For patterns that pick or omit properties when modeling variations of an object, review Using Pick<T, K>: Selecting a Subset of Properties and Using Omit<T, K>: Excluding Properties from a Type.
By following these patterns, testing thoroughly, and leveraging advanced type features as needed, you'll be able to provide first-class typing for JavaScript libraries and dramatically improve the developer experience for TypeScript consumers.
