TypeScript .d.ts - Hand-Maintained Files Break Monorepo
After updating a shared npm package, hand-maintained .
- .d.ts files describe JavaScript library shapes without containing runtime code
- TypeScript uses module resolution to find matching .d.ts files
- Hand-authored .d.ts files use declare module, namespace, and interface
- Production pitfall: mismatched version between .d.ts and actual implementation causes 'any' types
- Biggest mistake: adding .d.ts files without ensuring they match the JS module name exactly
Imagine you buy a brand-new kitchen appliance that came from Japan. The appliance itself works perfectly, but all the instructions are in Japanese. A declaration file is like a professional translator who writes you a perfect English manual — they don't change the appliance at all, they just tell you what every button does. TypeScript declaration files do exactly that for JavaScript libraries: they describe the shape of existing JavaScript code so TypeScript knows how to work with it, without touching the original code.
Every serious TypeScript project eventually hits the same wall: you install a popular npm package, write an import statement, and TypeScript throws a red underline at you saying it 'cannot find module' or has 'no type information'. That's not a bug in your code — it's TypeScript being honest. It doesn't know the shape of that JavaScript library, so it refuses to guess. Declaration files are the solution TypeScript ships with to bridge the 30-year gap between untyped JavaScript and the fully-typed world TypeScript promises.
The problem declaration files solve is deceptively simple but architecturally profound. TypeScript needs to perform static analysis at compile time, before a single line of JavaScript runs. When you consume a library written in plain JavaScript, there's nothing to analyze — no type annotations, no interfaces, just dynamic runtime behavior. Declaration files are a parallel universe of pure type information: they mirror every exported function, class, object, and constant from a JavaScript file, describing their shapes without containing any executable code whatsoever. The compiler reads the .d.ts file; the browser or Node.js runs the .js file. They're two different files doing two completely different jobs.
By the end of this article you'll understand how the TypeScript compiler resolves declaration files using module resolution strategies, how to author hand-crafted .d.ts files for legacy JavaScript that has no types, how to publish a typed library correctly so consumers get a great autocomplete experience, and exactly where the sharp edges are — the mistakes that waste entire afternoons in production codebases. We're going deep on internals, not skimming the surface.
What Are TypeScript Declaration Files?
A .d.ts file is a TypeScript declaration file — it describes the shape of existing JavaScript code without containing any executable logic. Think of it as a type-level mirror of a JavaScript module. TypeScript reads these files during compilation to understand the types of external libraries, and they're stripped entirely from the output JavaScript. They only exist at compile time.
- Bundled: If a npm package is written in TypeScript and compiled with the --declaration flag, .d.ts files are generated alongside the .js files. This is the gold standard.
- DefinitelyTyped: Community-maintained type packages under @types/ scope. For example, @types/lodash provides .d.ts files for Lodash.
- Hand-authored: When no existing types exist, you write them yourself. This is common for internal proprietary libraries or old jQuery plugins.
The type system inside a .d.ts file is exactly the same as in regular TypeScript, but with one constraint: you cannot put runtime code. No console.log, no assignments, no side effects. Only declare, interface, type, function, class, namespace, module — all the keywords that define structures, not implementations.
Declaration File Structure Decision Tree — Which Type Source to Use
Choosing the right source of type declarations for a library is a decision that directly affects maintenance burden and developer experience. This decision tree walks through the common scenarios and helps you pick the best strategy based on the library's origin, popularity, and stability.
Start by asking: Is the library written in TypeScript? If yes and it publishes its own .d.ts files (via the types field in package.json), then you're done — types are bundled. If it's TypeScript but doesn't publish types, check the package.json for a types field or look for a dist/ folder with .d.ts files. If missing, consider opening an issue or contributing a PR.
If the library is plain JavaScript, move to the next question: Does the library have an @types/ package on npm? Run npm info @types/library-name or search on https://github.com/DefinitelyTyped/DefinitelyTyped. If yes, install it as a devDependency. If no, ask: Is the library popular and stable? Popular libraries with many consumers often have community-maintained types in DefinitelyTyped even if not published yet — check the DefinitelyTyped repository for open PRs. For less popular or internal libraries, you have two options: write a hand-authored .d.ts file, or use a quick ambient declaration (declare module 'lib-name';) which gives everything any type.
A hand-authored .d.ts is appropriate when the library has a small, stable API surface and you need full type safety. For large, frequently changing libraries, hand-authoring becomes a maintenance sink — consider wrapping the library behind a typed abstraction layer instead, or advocating for the library author to add types.
This decision tree helps you avoid the common mistake of over-engineering when types aren't needed, or under-investing when type safety is critical.
declare module 'x'; — makes every export any. That silences errors but undermines type safety. Reserve it for truly temporary workarounds.@types/ Package Reference — How to Find and Contribute to DefinitelyTyped
DefinitelyTyped is the community-driven repository that hosts thousands of type definitions for JavaScript libraries. When you run npm install @types/lodash, you're downloading a package automatically published from the DefinitelyTyped GitHub repository. Understanding how to scan for available @types packages and how to contribute when a package is missing is essential for any production TypeScript project.
Finding available @types packages: - Use npm search @types/<package-name> to check if a types package exists. - Visit https://www.npmjs.com and search for @types/package-name. - Use the DefinitelyTyped repository: https://github.com/DefinitelyTyped/DefinitelyTyped — browse the types/ directory. - Use CLI: npm info @types/library-name versions to see available versions (match the library version).
When to install @types: - For any runtime dependency that is JavaScript and has a corresponding @types package, install it as a devDependency. - Keep the version of @types aligned with the library's version. Use tilde or exact version range to avoid breaking changes. - Never commit generated files from @types — they are managed by npm.
Contributing to DefinitelyTyped: If a library lacks an @types package, you can contribute one. The process: 1. Fork the DefinitelyTyped repo. 2. Create a new directory under types/<package-name>/. 3. Write an index.d.ts file with the library's type declarations. 4. Add a tsconfig.json and tester.ts file (see existing packages for templates). 5. Run npm test to verify. 6. Submit a pull request. The DT maintainers will review and, if accepted, publish the package automatically.
Common pitfalls with @types: - Version mismatches: If the library is on v2 but @types is on v1, you'll get type errors for APIs that changed. Always pin both versions. - Overlapping types: Some libraries (like React) have both bundled types (when using @types/react) and additional community types — scope your installation carefully. - Mocking @types in test environments: If you use @types/node, ensure your test runner's Node.js version matches the type definitions.
npm info @types/lodash versions and compare with the lodash version you're using. For lodash 4.17.21, you might need @types/lodash 4.17.14 — the major/minor should match.Module Resolution: How TypeScript Finds .d.ts Files
TypeScript's module resolution is the algorithm that maps import specifiers to files on disk. It's roughly: for import { something } from 'my-lib', TypeScript looks for a .ts, .tsx, or .d.ts file at the resolved path. The resolution strategy (classic vs node) determines exactly where it searches.
In practice, you'll almost always use the node resolution strategy (the default when module is 'commonjs' or 'node16'). Here's the order: 1. Check if the specifier is a relative path (starts with './' or '../'). If so, look for the file directly. 2. For non-relative module names (like 'lodash'), TypeScript searches through the node_modules directories up the directory tree. 3. Inside a package's node_modules, TypeScript looks at the package's types field in package.json (or typing as fallback). 4. If no types field, it looks for index.d.ts at the package root. 5. If still not found, it searches for @types/package-name in node_modules/@types/. 6. If nothing is found — you get the 'Cannot find module' error.
One critical detail: TypeScript also respects typeRoots and types in tsconfig.json. You can override the default search paths, especially in monorepo setups where you need to point to shared types.
typeRoots manually, TypeScript stops looking in node_modules/@types unless you explicitly include that path. This is a common source of CI vs local inconsistencies.tsc --traceResolution when module resolution behaves differently than expected.Authoring Hand-Crafted .d.ts Files
When you're stuck with a dependency that has no types and no @types/ package, you write the declaration file yourself. This is surprisingly common in corporate environments with legacy internal libraries.
The starting point is a declare module block. The module name must exactly match the import specifier. For example, if you import import { parse } from 'my-old-parser', your .d.ts must start with declare module 'my-old-parser' { ... }.
Inside the module, you can export interfaces, types, functions, classes, and variables. Use declare function for standalone functions. Use namespace to group related types. Don't forget to export the module's default export if it uses CommonJS module.exports.
A common pattern is to create a globals.d.ts file in your project root and declare all missing modules there. But be careful: if two declarations with the same module name exist, TypeScript merges them — which can lead to unexpected conflicts.
Pro tip: Use declare module 'some-module' without any block to immediately suppress the 'cannot find module' error. This is the quickest way to silence TypeScript, but it's also the most dangerous because it makes everything any.
- Every export in the .d.ts must have a corresponding runtime export in the JavaScript file.
- You don't need to declare internal variables or helper functions — only the public API surface.
- Ambient declarations (like
interface Window) are global and don't need a module wrapper. - Use
export defaultcarefully: CommonJS interop can behave differently in ES module consumers.
declare module 'module-name' to enclose all declarations.Extending Global Objects (Window/Request) — The 'declare global' Pattern
Sometimes you need to add custom properties to global objects like Window, Request, NodeJS.Process, or any built-in interface. TypeScript provides the declare global block to safely augment these global namespaces from within a module file. This is essential when you're writing polyfills, feature flags stored on window, or custom request properties in a Node.js server.
The pattern: Inside a module (any file with at least one import or export), use declare global { ... } to add members to global interfaces. For example, to add a custom appConfig property to Window:
```typescript // src/global.d.ts or any module .ts file export {}; // makes this a module
declare global { interface Window { appConfig: { apiUrl: string; theme: 'dark' | 'light' }; } } ```
Now TypeScript recognizes window.appConfig anywhere in your project without additional type assertions.
Why not use a non-module ambient declaration? If you write interface Window { ... } in a non-module .d.ts file (no imports/exports), that's a global augmentation — it's automatically in effect for all files. That can cause conflicts if two packages attempt to augment the same interface differently. By using declare global inside a module, the augmentation is only active in files that import the module (or if the module is included via tsconfig). This gives you control and reduces namespace pollution.
Common use cases: - Adding custom properties to Window for frontend frameworks. - Extending Request in Express or Next.js to include user or session. - Adding methods to Array, String, or other built-in prototypes (though this is generally discouraged). - Augmenting NodeJS.ProcessEnv to include custom environment variables.
Best practices: - Keep global augmentations in a single .d.ts file, imported indirectly via your project's entry point. - Use export {} to ensure the file is treated as a module if it contains only declare global. - Prefer module augmentation over global augmentation when possible: declare module 'express' { interface Request { user: User } } is scoped to that module. - Document each augmentation and why it's needed — future developers will thank you.
declare global for built-in browser/Node globals (Window, Request, Process). Use module augmentation (declare module 'express' { ... }) for third-party library types. The latter is safer because it's scoped to that specific module.declare global inside a module (with export {}) to safely extend global objects. Prefer module augmentation for third-party libraries. Keep global augmentations centralized and documented.Publishing Typed Libraries — The 'types' Field and Best Practices
If you're publishing a library, you need to decide how to provide types. If your library is written in TypeScript, the answer is simple: set declaration: true in tsconfig.json and add a types field to package.json pointing to the main .d.ts file. TypeScript will generate .d.ts alongside .js outputs.
- Publish separate @types packages (like DefinitelyTyped).
- Include hand-authored .d.ts files in the same package.
The types field in package.json tells TypeScript which file to load when someone imports your module. If omitted, TypeScript falls back to index.d.ts in the package root. If you have multiple entry points (like a main entry and a subpath export), you need the exports field with types condition.
Important: If you use exports in package.json, the types field is ignored for the main entry. TypeScript uses the exports map to resolve types for each subpath. This is a common source of confusion — a library may have types pointing to the right file but consumers still get 'any' types because the exports map lacks a types condition.
exports field, every module path must be explicitly listed. If a consumer tries to import a subpath that isn't listed, Node.js (and TypeScript) will throw — even if that file exists on disk.types condition in exports causes TypeScript to ignore bundled .d.ts files.types and exports with the types condition.Production Pitfalls and Debugging Techniques
Even with everything set up correctly, declaration files can cause hours of debugging. Here are the most painful scenarios and how to fix them.
Pitfall 1: Triple-slash directives in .d.ts Sometimes you see /// <reference types="..." /> at the top of a .d.ts file. These are legacy directives that tell the compiler about dependencies. In modern TypeScript projects, they're rarely needed and often cause problems when files are moved or renamed. Avoid writing them — use regular imports in your .ts files instead.
Pitfall 2: Global augmentation collisions If two packages both augment the global interface (e.g., declare global { interface Window { ... } }), the second one may overwrite the first. This is a common issue in monorepos where multiple packages extend DOM types. The fix is to use module augmentation inside a module scope, not global augmentation.
Pitfall 3: Version mismatch between library and its types If your app depends on awesome-lib@1.2.0 but @types/awesome-lib@1.0.0 is installed, you get type errors that don't match the runtime behavior. Always keep the @types version in sync with the runtime version. Use npm ls @types/awesome-lib to check.
Pitfall 4: Incorrect moduleResolution setting With modern Node.js (16+) and TypeScript, using moduleResolution: "node" can produce different results than moduleResolution: "node16" or "bundler". The "bundler" option is more lenient and often used in webpack projects. If you switch between these, declarations may not resolve correctly.
import type and export type. Refactor if possible.The Micro-Patch That Broke Type Checking Across the Monorepo
- Always regenerate .d.ts files from TypeScript source automatically — never maintain them by hand for libraries written in TypeScript.
- For JS libraries, use the 'types' field in package.json and test the .d.ts file by actually importing it in a strict TypeScript project.
- Add a CI check that compiles a consumer project against the declaration file to catch mismatches before release.
declare module 'package-name';Key takeaways
types condition in the exports maptypes field.tsc --traceResolution to debug why TypeScript can't find or misresolves a declaration file.declare module without a bodyany, defeating static analysis.Common mistakes to avoid
4 patternsSetting `typeRoots` without including original @types path
typeRoots entry in tsconfig.json. Build fails with 'Cannot find module' for popular libraries like jest or node../node_modules/@types in the typeRoots array alongside your custom paths. For example: "typeRoots": ["./io/thecodeforge/types", "./node_modules/@types"]Using `export =` but forgetting interop with ES modules
module.exports gets imported with import * as in an ES module project, and the imported object is typed as { default: ... } instead of the expected shape.export = in the declaration file. For libraries that support both CJS and ESM, use a conditional exports map with separate types for each format.Trusting that `declare module 'x' {}` will override a package's bundled types
import 'package-name' then declare module 'package-name' { ... } inside a module. Or use paths and baseUrl to redirect the package's type resolution.Omitting the `types` condition in the package.json `exports` field
any types even though you bundled .d.ts files. TypeScript falls back to an implicit any because the exports map doesn't include a types condition."types": "./dist/index.d.ts" as the first condition in every export entry. TypeScript checks the types condition before any runtime conditions.Interview Questions on This Topic
What is the difference between a `.ts` file and a `.d.ts` file?
.ts file contains both type definitions and executable code. It can be compiled to JavaScript. A .d.ts file contains only type information — no runtime code. It is used solely by the TypeScript compiler to understand the shapes of existing JavaScript libraries or to provide type information for code that will be compiled separately.Frequently Asked Questions
That's TypeScript. Mark it forged?
10 min read · try the examples if you haven't