CodeFixesHub
    programming tutorial

    Introduction to Declaration Files (.d.ts): Typing Existing JS

    Learn to write .d.ts files to type existing JS libraries with practical examples, publish-ready patterns, and troubleshooting—start typing your JS today!

    article details

    Quick Overview

    TypeScript
    Category
    Sep 24
    Published
    19
    Min Read
    2K
    Words
    article summary

    Learn to write .d.ts files to type existing JS libraries with practical examples, publish-ready patterns, and troubleshooting—start typing your JS today!

    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 or Extract<T, U] to narrow unions—see our guides on Using NonNullable: Excluding null and undefined and Deep Dive: Using Extract<T, U> to Extract Types from Unions.

    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:

    Create a minimal local setup to test types:

    bash
    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:

    js
    // utils.js
    exports.add = function (a, b) { return a + b }
    exports.VERSION = '1.0'

    Create utils.d.ts next to it:

    ts
    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:

    ts
    // 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:

    ts
    export default function parse(input: string): AST;

    For UMD (works as global and module), use export as namespace MyLib and normal exports:

    ts
    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:

    ts
    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:

    ts
    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:

    ts
    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:

    ts
    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:

    ts
    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 NonNullable: Excluding null and undefined. When extracting union members for specific branches, the Extract 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:

    ts
    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:

    ts
    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:

    ts
    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:

    ts
    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:

    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 tsd or 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 types or typings pointing at your entry .d.ts file.
    • If type resolution fails for packages with both ESM and CommonJS builds, provide both exports and types fields, 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.ts and add types to 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

    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.

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:19:54 PM
    Next sync: 60s
    Loading CodeFixesHub...