Writing a Simple Declaration File for a JS Module
Introduction
Many JavaScript libraries and internal modules lack TypeScript type definitions even when they work perfectly at runtime. Without declaration files, TypeScript users lose autocompletion, type checking, and safety benefits. Writing a simple declaration file (.d.ts) bridges that gap: it describes the runtime shapes to the type system so consumers can use your module safely and with editor support.
This tutorial teaches intermediate developers how to write clear, maintainable .d.ts files for CommonJS and ES modules. You'll learn how to model functions, default and named exports, objects, overloads, generics, ambient modules, and how to handle dynamic keys and utility types. The guide includes practical examples, step-by-step instructions, code snippets you can reuse, and troubleshooting tips for common pitfalls.
By the end of this article you'll be able to: author a working .d.ts for an existing JS module, apply mapped and index-signature techniques for dynamic shapes, use TypeScript utility types to simplify declarations, and integrate your definitions with downstream TypeScript projects. Along the way, we'll link to deeper TypeScript resources to help you expand your understanding of mapped types, index signatures, type narrowing, and guards.
Background & Context
TypeScript relies on declarations to know the types of values coming from JavaScript. When a library lacks type information, TypeScript treats imports as any, disabling early error detection and developer tooling. Declaration files (.d.ts) provide a description-only module surface that mirrors what the module exports at runtime but contains no implementation. They can live inside the same package (ship with your library) or in @types packages maintained separately.
Creating declaration files is particularly important for internal modules or legacy codebases where converting to TypeScript is not an immediate option. A well-written .d.ts improves DX, enables safe refactors, and provides an incremental path to stronger typing. When writing declarations you'll commonly use interfaces, type aliases, function signatures, and ambient module declarations. More advanced scenarios may use mapped types and index signatures to model flexible runtime shapes.
Key Takeaways
- How to author a basic .d.ts for default and named exports
- How to type functions, objects, overloads, and generics in declarations
- When to use ambient module declarations vs. shipped types
- How to model dynamic keys with index signatures and mapped types
- How to reuse built-in utility types for cleaner definitions
- Common pitfalls and troubleshooting techniques
Prerequisites & Setup
This guide assumes you know TypeScript basic syntax (types, interfaces, type aliases, generics) and have a development environment with Node.js and a code editor that supports TypeScript. You do not need to convert your JavaScript code to TypeScript to author .d.ts files. Recommended setup:
- Node.js and npm/yarn installed
- TypeScript installed globally or in the project (
npm install --save-dev typescript) - A sample JS module to declare (we'll use examples below)
- Optional: TypeScript-aware editor like VS Code for live feedback
Create a small project folder and add a JS file to declare, or point to an existing module in node_modules. Initialize tsconfig.json for testing by running npx tsc --init and set allowJs: true and checkJs: false if working with mixed files.
Main Tutorial Sections
1) What is a .d.ts file and where to place it
A .d.ts file is a TypeScript declaration file that contains only declarations: type aliases, interfaces, function signatures, namespaces, and module declarations. For a package you ship, place the .d.ts next to the compiled JS file and point the package.json types field to it, for example:
{
"main": "dist/index.js",
"types": "dist/index.d.ts"
}For local or ad-hoc declarations you can create a declarations.d.ts in your project src or types folder and ensure tsconfig.json includes it via the include array. If you're declaring for an external package without types, use an ambient module declaration in a .d.ts file:
declare module 'untyped-lib' {
export function doThing(x: any): any;
}This informs TypeScript about the shape without needing to change the library.
2) Basic module: default export function
Suppose a JS module exports a default function:
// greet.js
module.exports = function greet(name) {
return 'Hello ' + name;
}Write a .d.ts to describe it:
declare function greet(name: string): string; export = greet;
For ES-like default exports compiled from CommonJS, you can use export default if runtime matches ESM:
declare function greet(name: string): string; export default greet;
Choose export = for CommonJS module.exports = and export default for ESM. Mis-matching these is a common cause of import errors.
3) Named exports and objects
If the module exports multiple named functions or an object with properties, model that surface precisely. Example JS:
// lib.js exports.add = (a, b) => a + b; exports.version = '1.2.3';
Corresponding .d.ts:
export function add(a: number, b: number): number; export const version: string;
If the module exports a single object:
module.exports = {
add: (a, b) => a + b,
version: '1.2.3'
};You can declare an interface and export it:
interface Lib {
add(a: number, b: number): number;
version: string;
}
declare const lib: Lib;
export = lib;For complex transforms on exported types you may later use mapped types; see our guide on Introduction to Mapped Types for patterns to derive new shapes from existing ones.
4) Typing functions: overloads and generics
Functions often have overloads or generic behavior at runtime. Represent overloads in .d.ts by listing signatures above the implementation signature. Example: a pick function that can accept different argument patterns:
export function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>; export function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K>;
Use generics to describe behavior like transforming arrays or preserving types. If your function returns a narrower type based on the input, you may need to describe conditional types or constraints. When working with utility types like Pick or Omit, it's useful to be familiar with their semantics; see our guide on Using Omit<T, K>: Excluding Properties from a Type and Using Pick<T, K>: Selecting a Subset of Properties to reuse standard patterns.
5) CommonJS vs ES module interop
Different build setups produce different runtime shapes, so your .d.ts must match how code is consumed. CommonJS modules commonly use module.exports = while ESM uses export default or named exports. If your package uses Babel or ts-node, it might add a default wrapper, so consumers importing with import lib from 'pkg' may expect default.
Declaration patterns:
- For CommonJS: use
export =andimport foo = require('foo')in TypeScript consumer code. - For ESM: use
export defaultandimport foo from 'foo'. - If you support both, consider publishing both ESM/CJS builds with corresponding
.d.tsfiles or provide a hybrid declaration that usesexport =withexport as namespacefor global usage.
Mistakes here cause runtime-to-type mismatches and import errors. When in doubt, inspect the compiled JS to see whether module.exports or exports.foo is used.
6) Ambient module declarations for untyped packages
If you can't ship types with the package, add an ambient declaration in your consuming project, e.g. types/untyped-lib.d.ts:
declare module 'untyped-lib' {
export function doSomething(options: any): Promise<any>;
}This approach is quick but should be used sparingly because it centralizes types in the consumer, not the library. For larger libraries prefer contributing a types file or publishing to DefinitelyTyped. Ambient declarations are ideal for small glue layers where only a few signatures are required.
7) Dynamic keys: index signatures and mapped types
At runtime many modules expose objects with dynamic keys. To type them, use index signatures or mapped types. For simple dynamic keys use an index signature:
export interface Dict<T = any> {
[key: string]: T;
}For more control over key types and transformations, study Basic Mapped Type Syntax ([K in KeyType]) and our Key Remapping with as in Mapped Types — A Practical Guide to produce derived object types. For example, to derive a readonly version of a runtime object type you can use mapped types to iterate over keys and transform the value types.
Also consider index signatures when keys are numeric or other primitive types; incorrect key typing can cause unsafe usage and runtime type errors.
8) Modeling optional and nullable values
JavaScript frequently uses null or undefined to indicate optional values. In declaration files, explicitly annotate optional properties with ? or create union types with null | undefined. If you want to remove nullability from types within declarations, use utility types like NonNullable<T> to express the stripped type clearly. For more on excluding null and undefined see Using NonNullable
Example:
export interface Config {
path?: string | null;
retries: number | null;
}
export type SafeConfig = {
[K in keyof Config]: NonNullable<Config[K]>;
};This pattern documents the runtime possibility of nulls while giving downstream code a clear way to obtain a non-nullable view.
9) Reusing and combining union utilities: Extract and Exclude
If your module exposes unions or discriminated unions, describing how consumers narrow them is valuable. Use Exclude<T, U> and Extract<T, U> to derive subsets of union types in your declarations. For deeper usage patterns see Deep Dive: Using Extract<T, U> to Extract Types from Unions and Using Exclude<T, U>: Excluding Types from a Union.
Example: suppose a plugin system accepts a union of event names, and your exported helper filters out internal events:
export type Events = 'start' | 'stop' | 'internal:debug'; export type PublicEvents = Exclude<Events, 'internal:debug'>;
Documenting these transforms ensures consumers get accurate type feedback when constructing event handlers.
10) Runtime checks, narrowing, and type guards
When a JS library exposes values whose type depends on runtime checks, provide type guard declarations to help TypeScript narrow in consumer code. If the module ships a helper isFoo, declare it as a type predicate:
export function isFoo(x: any): x is Foo;
This allows TypeScript to narrow the type when the guard returns true. For guidance on writing your own guards and control flow, see Custom Type Guards: Defining Your Own Type Checking Logic and Control Flow Analysis for Type Narrowing in TypeScript. Type predicates are a small addition to .d.ts files but add huge value to consumers by enabling precise narrowing and safer code.
Advanced Techniques
Once you understand basic declarations, advanced techniques help keep your types maintainable and expressive. Use mapped types to derive new shapes from base interfaces, e.g., create deep readonly variants or pick/omit transformations automatically. For cases where keys are remapped at runtime, explore the key remapping as clause in mapped types to model renames and prefixing, as explained in our Key Remapping with as in Mapped Types — A Practical Guide.
Conditional types can model return types that vary by input. The infer keyword inside conditional types helps infer type variables from nested types. For union manipulation and extracting members, Extract and Exclude are indispensable. When declaring complex API surfaces, compose smaller types with utility types (Pick, Omit, Readonly, NonNullable) to keep definitions DRY and readable. Reusing established patterns reduces maintenance and improves clarity for consumers.
Performance note: declaration file complexity does not affect runtime performance, but extremely complex types can slow down TypeScript's language service; prefer pragmatic simplicity over exhaustive type gymnastics in frequently used public surfaces.
Best Practices & Common Pitfalls
Dos:
- Match your declaration to the runtime shape precisely. Test with a TypeScript consumer project.
- Use
typesin package.json to point to bundled .d.ts files. - Prefer specific types over
anyto help consumers early. - Use utility types to keep declarations concise and consistent.
Don'ts:
- Don’t mix
export =andexport defaultin a single declaration incorrectly; pick the one matching runtime. - Avoid overly complex conditional types on hot paths—editor responsiveness suffers.
- Don’t declare internal-only helpers as exported types; keep public API surface minimal.
Troubleshooting tips:
- If imports produce
any, ensure your declaration file is included or referenced by package.json. For local ambient declarations, ensure tsconfig.json includes the declaration folder. - For import style mismatches, inspect the compiled module for
module.exportsvsexports.foodifferences. - If TypeScript errors about incompatible exports, try switching between
export =andexport defaultin a test consumer to confirm consumption style.
Real-World Applications
Declaration files are useful in many scenarios: adding types to internal legacy modules, shipping types with a library so consumers get editor support, or quickly declaring types for third-party libraries that lack them. For instance, a design system shipping JavaScript components benefits greatly from .d.ts files so consumers can type-check props and understand component APIs. In server-side applications, declaration files for internal utility modules provide safety during refactors. In plugin ecosystems, precise union and discriminated union declarations make plugin contracts explicit and safer.
In CI pipelines, consider adding a step that compiles TypeScript consumers against your published .d.ts to catch mismatches before release.
Conclusion & Next Steps
Writing a simple .d.ts for a JS module is a high-value, low-friction way to improve developer experience and reliability without fully converting your codebase to TypeScript. Start by matching the runtime shape, add specific types for primary exports, and gradually adopt advanced features like mapped types and type guards. Test in a TypeScript consumer and iterate.
Next steps: practice by declaring a few real modules in your project, explore mapped types and index signatures for dynamic shapes, and read the linked deep-dive articles to expand your toolkit.
Enhanced FAQ
Q: What file name should I use for declarations?
A: Use index.d.ts colocated with your compiled entry (e.g., dist/index.d.ts) and ensure package.json's types field points to it. For project-level ambient declarations, a types or @types folder with an index.d.ts is common. Keep file names matching the module's export path for clarity.
Q: How do I declare a CommonJS module that exports an object and functions?
A: If runtime uses module.exports =, prefer export =. Define interfaces for the object and export them via declare const lib: Lib; export = lib;. For mixed named exports via exports.foo, declare each named export as export function foo(...) or export const foo: ....
Q: Can I use advanced TypeScript features like conditional types and mapped types in .d.ts files? A: Yes. .d.ts files can contain the same type-level features as a TypeScript source file. However, be mindful: complex types can slow the TypeScript language service for consumers. Use them when they increase clarity or avoid duplication. For practical patterns and syntax, check Basic Mapped Type Syntax ([K in KeyType]) and our overview of mapped types Introduction to Mapped Types.
Q: How do I type objects with dynamic property names?
A: Use index signatures for simple dynamic keys, e.g. [key: string]: T. For transformations or constrained keys, use mapped types and key remapping as patterns; see our Key Remapping with as in Mapped Types — A Practical Guide and the article on Index Signatures in TypeScript: Typing Objects with Dynamic Property Names.
Q: What about null and undefined in declarations?
A: Be explicit. Use ? for optional properties and unions, e.g. value?: string | null. If you want types without null or undefined, use NonNullable<T> to express the non-nullable variant. See Using NonNullable
Q: How do type guards work in declarations?
A: Export functions with type predicates like function isX(v: any): v is X;. Consumers can use these to narrow values inside conditional branches. For more on designing guards and controlling flow analysis, see Custom Type Guards: Defining Your Own Type Checking Logic and Control Flow Analysis for Type Narrowing in TypeScript.
Q: When should I use any in declarations?
A: Reserve any for truly unknown runtime shapes or when type correctness is not feasible. Prefer precise types to help consumers. When you need to opt out temporarily, document the reason and add TODOs for future improvement.
Q: How can I test my .d.ts file?
A: Create a small TypeScript test project that imports your module and uses its API. Run tsc --noEmit to surface typing errors. Writing unit tests with tsd (a lightweight type assertion library) is also a great practice to keep declarations correct during refactors.
Q: How do I handle deprecated or unstable API parts in declarations?
A: Annotate deprecated members with JSDoc @deprecated and consider keeping them typed but documenting status. Consumers' editors will highlight deprecated members if you include @deprecated tags.
Q: Can I reference other type declaration files from a .d.ts?
A: Yes. Use triple-slash reference directives like /// <reference path="./other.d.ts" /> or import types by module name if types are exported. Prefer ES-style imports in declaration files when referring to named types from other packages.
Q: How do I declare overloaded call signature for an exported object? A: If an exported value is callable and has properties, use an interface with call signature:
interface Callable {
(x: number): string;
prop: boolean;
}
declare const fn: Callable;
export = fn;This allows both calling and property access typings to be described.
Q: How do Extract and Exclude help in declarations?
A: They let you derive subtypes from unions without duplicating the union logic. For example, Extract<T, U> picks members of T assignable to U, while Exclude<T, U> removes them. These utilities are handy for plugin systems and discriminated unions. Explore Deep Dive: Using Extract<T, U> to Extract Types from Unions and Using Exclude<T, U>: Excluding Types from a Union for examples.
