CodeFixesHub
    programming tutorial

    Typing Third-Party Libraries Without @types (Manual Declaration Files)

    Manually create declaration files for untyped libraries to avoid runtime errors and improve DX. Step-by-step guide with examples — start typing now!

    article details

    Quick Overview

    TypeScript
    Category
    Aug 25
    Published
    19
    Min Read
    2K
    Words
    article summary

    Manually create declaration files for untyped libraries to avoid runtime errors and improve DX. Step-by-step guide with examples — start typing now!

    Typing Third-Party Libraries Without @types (Manual Declaration Files)

    Introduction

    When you add a popular npm package to a TypeScript project and discover there is no @types package available, you face two choices: accept untyped consumption (any everywhere) or write types yourself. For intermediate developers, manual declaration files (.d.ts) are an essential skill: they let you document APIs, get meaningful editor completions and type-checking, and prevent subtle runtime mistakes caused by incorrect assumptions about a library's API.

    This tutorial explains how to write robust, maintainable declaration files for third-party libraries that lack published types. You'll learn patterns for CommonJS and ESM packages, how to type default and named exports, module augmentation, typing global objects exposed by scripts, packaging types for consumers, and integrating declaration files into build and CI pipelines. We'll cover both quick pragmatic declarations and more rigorous, gradual approaches so you can choose the right level of fidelity for your project.

    Throughout the guide you'll see practical examples and ready-to-copy snippets. You'll also get pointers to related TypeScript configuration topics and build concerns so declarations integrate cleanly with your toolchain. If you want a broader refresher on how compiler options are grouped and how that affects typing and builds, consult our guide on understanding tsconfig.json compiler options to align your tsconfig with the steps below.

    Background & Context

    TypeScript consumes type information from two primary sources: .ts/.tsx sources that contain type annotations and .d.ts declaration files. For third-party packages, the types are usually provided by the package itself (a "types" or "typings" entry in package.json) or by DefinitelyTyped via @types packages. When neither exists, TypeScript falls back to the any type, which defeats the benefits of static checking.

    Manual declaration files allow you to capture the surface area of a library without rewriting the library in TypeScript. They can live in your project (for consumption only by you), in a repo of shared type overrides, or be published alongside your own wrapper packages. This approach is especially useful for internal tools, small utility libraries, or legacy JS that you must interact with while migrating.

    Key concerns are correctly matching runtime module semantics (CommonJS vs ESM), avoiding breaking changes as upstream releases evolve, and integrating declarations into the build pipeline so consumers (and CI) use them. If your project consumes JavaScript sources you might also consider TypeScript's allowJs/checkJs options — read our guide on allowing JavaScript files in a TypeScript project for details.

    Key Takeaways

    • Manual .d.ts files let you type packages that lack @types and regain editor and compiler safety.
    • Distinguish CommonJS and ESM when writing declarations; the wrong assumption leads to incorrect import types.
    • Use ambient module declarations for quick fixes and proper index.d.ts for long-term packaging.
    • Integrate declarations into package.json with the "types" field and include them in your npm package.
    • Adopt gradual typing patterns when full typing is expensive: start with minimal types and expand.
    • Automate type generation and verification in CI to prevent regressions.

    Prerequisites & Setup

    Before writing declaration files, have a working TypeScript project and editor with TypeScript support (VS Code recommended). You should be familiar with TypeScript basics (interfaces, types, modules, import/export) and how to configure tsconfig.json; our understanding tsconfig.json compiler options guide is a useful companion.

    Install TypeScript as a dev dependency if you haven't already:

    bash
    npm install -D typescript

    If the package you are typing is a JS dependency and you want TypeScript to analyze JS files, enable allowJs and optionally checkJs — see allowing JavaScript files in a TypeScript project for safe migration patterns.

    Choose a place to put your manual declarations: a local folder like src/types or a top-level types directory. Update tsconfig.json's "typeRoots" or include the folder via "include" so the compiler sees your .d.ts files.

    Main Tutorial Sections

    1. Start Small: Use Ambient "declare module" for Quick Fixes

    When you simply need to stop TypeScript errors and get the basic shape, an ambient declaration is the fastest way.

    Create a file like src/types/untyped-lib.d.ts:

    ts
    declare module 'untyped-lib' {
      export function doSomething(input: any): any
      export const version: string
    }

    This tells TypeScript that importing 'untyped-lib' is allowed and gives minimal surface-level types. It's pragmatic but not ideal for long-term maintainability, because using any removes type safety. Use this to buy time and incrementally improve types.

    2. Determine Runtime Module Semantics: CommonJS vs ESM

    A common source of bugs is assuming the wrong module system. Check the package's package.json for "type": "module" (ESM) or look at the entry file. For CommonJS libraries that use module.exports, your declaration might need export = syntax:

    ts
    declare module 'cjs-lib' {
      function cjsDefault(arg: string): number
      export = cjsDefault
    }

    Consumers should then import with import cjs = require('cjs-lib') or, if you use esModuleInterop, import cjs from 'cjs-lib'. See our guide on configuring esModuleInterop and allowSyntheticDefaultImports to configure compiler options consistently across the team.

    3. Typing Default and Named Exports

    If the library exposes both default and named exports, explicitly type them:

    ts
    declare module 'lib-with-exports' {
      export function parse(s: string): object
      export function stringify(o: object): string
      export default function main(options?: {silent?: boolean}): void
    }

    This produces accurate completions for both import styles. When mapping runtime behavior to types, prefer precise parameter and return types rather than any.

    4. Create an index.d.ts for Packaged Types

    If you're publishing types for wider use (or want clean local consumption), put a proper index.d.ts at the package root and reference it from package.json via the "types" field.

    Example index.d.ts:

    ts
    export interface Config { mode?: 'fast' | 'safe'; retry?: number }
    export function init(cfg?: Config): Promise<void>

    Update package.json to point to it:

    json
    {
      'name': 'my-lib',
      'version': '1.0.0',
      'main': 'dist/index.js',
      'types': 'dist/index.d.ts'
    }

    If you generate declarations as part of your build, read generating declaration files automatically to configure declaration and declarationMap options properly.

    5. Module Augmentation and Patch Types

    Sometimes you need to extend an existing library's types (augmentation) — for instance, adding a plugin method to a third-party library's interface. Use declaration merging:

    ts
    // src/types/thirdparty-augment.d.ts
    import 'some-lib'
    
    declare module 'some-lib' {
      interface SomeLibOptions { extra?: string }
      interface SomeLibInstance { newFeature(): void }
    }

    Make sure the module name matches exactly and that your augmentation file is included in the compilation. Augmentation is powerful but can be brittle if the upstream library's internal shapes change.

    6. Typing Global Scripts and Window Exports

    Some libraries expose globals (e.g., via a script tag). Use declare global to describe those shapes so TypeScript knows about them:

    ts
    // src/types/globals.d.ts
    export {}
    
    declare global {
      interface Window { MyLib: { init(): void; version: string } }
    }

    For a library that assigns to globalThis or window, this pattern avoids repetitively using (window as any). Keep these declarations minimal and clearly named to avoid name collisions.

    7. Gradual Typing: Use Narrow Any and Type Stubs

    Full types can be expensive. A safe pattern is to write minimal, well-named types for the parts you use and keep sidewalks of any for the rest. Prefer structural types over export any everywhere.

    Example:

    ts
    declare module 'analytics-lite' {
      export type EventPayload = { name: string; props?: Record<string, unknown> }
      export function track(e: EventPayload): void
      // internal API left as unknown to avoid false confidence
      export function internalApi(arg: unknown): unknown
    }

    This gives actionable types for the most critical surfaces, enabling safer refactors.

    8. Using JSDoc and checkJs for JavaScript Libraries

    If you own or can edit the JS source, add JSDoc annotations and enable checkJs so TypeScript can infer types without full .d.ts files. Example:

    js
    // lib/compute.js
    /**
     * Compute the answer.
     * @param {{fast?: boolean}} opts
     * @returns {number}
     */
    function compute(opts) { return opts && opts.fast ? 42 : 0 }
    module.exports = { compute }

    Enable in tsconfig.json: set "allowJs": true and "checkJs": true. For migration best practices, see our guide on allowing JavaScript files in a TypeScript project.

    9. Integrating Declarations with Your Build and CI

    Place .d.ts files where the compiler sees them — commonly a top-level types directory or alongside src. If you generate .d.ts files from TypeScript sources, enable "declaration": true. Be mindful of "isolatedModules": true (used by some bundlers) — it forbids certain type-only exports; learn more in understanding isolatedModules for transpilation safety.

    Configure CI to type-check declarations. Avoid producing invalid declaration output by adding a types-only check step (tsc --noEmit). Review our article on using noEmitOnError and noEmit in TypeScript for strategies around emitted artifacts and failing builds.

    10. Publishing, Versioning, and Maintenance

    If you publish types, set the "types" field in package.json and include .d.ts files in the published tarball. Keep your declaration files in sync with code changes. For libraries that produce runtime changes often, consider automated tests that exercise the typing surface via a small TypeScript test suite.

    Keep an eye on upstream changes and prefer contributing types to DefinitelyTyped if the project is popular. For internal-only libraries, document where types live and update the consuming project's typeRoots if needed.

    Advanced Techniques

    Once comfortable with basic declarations, adopt patterns that scale. Use interfaces and type aliases to model re-used shapes and export them for consumers. Use mapped types and generics to express flexible APIs (e.g., typed factory functions). For complex CommonJS modules, combine declare module with namespace and export = patterns to capture the runtime behavior.

    Leverage the technique of re-exporting types from your declaration files to provide stable, documented public surfaces. Use declarationMap to make debugging easier for consumers (see generating declaration files automatically). To increase type-safety on code paths that interact with third-party APIs, enable strict options like strictNullChecks — our configuring strictNullChecks guide helps you tighten nullability semantics without painful churn.

    Finally, when integrating with bundlers, be aware of path casing issues on different file systems. Normalizing file-name casing (and adding checks) avoids confusing build errors; see our tips on force consistent casing in file paths.

    Best Practices & Common Pitfalls

    Dos:

    • Start with the minimum types you need and expand coverage as usage grows.
    • Match runtime semantics (CommonJS vs ESM); incorrect assumptions lead to subtle bugs.
    • Keep declaration files colocated or referenced in typeRoots so the compiler finds them.
    • Add type tests: small .ts files that import the declarations and compile in CI.
    • Use explicit types (interfaces/types) to document API intent.

    Don'ts and pitfalls:

    • Avoid overuse of any; it hides problems and can give false confidence.
    • Don't let declaration drift: when the library changes, update types promptly.
    • Beware of augmentations that rely on internal, unstable APIs — they break silently when upstream changes.
    • If you generate .d.ts in build, ensure CI compiles them; misconfigured builds can publish packages without types. Learn to use "noEmit" and "noEmitOnError" correctly with our guide on using noEmitOnError and noEmit in TypeScript.

    Troubleshooting tips:

    • If types are not picked up, check typeRoots and include globs in tsconfig.json.
    • If your editors show different behavior than tsc, ensure both use the same TypeScript version and the project tsconfig.
    • Use skipLibCheck temporarily to bypass third-party type errors, but avoid relying on it as a long-term cure.

    Real-World Applications

    Manual declaration files are useful in many scenarios: migrating legacy JavaScript codebases to TypeScript, integrating small internal npm packages that never had types authored, or consuming niche third-party libraries that the community has not typed. They are also valuable in monorepos where some packages are still authored in JavaScript but need TypeScript consumers to remain safe.

    For example, when building an internal SDK that wraps multiple small untyped dependencies, writing precise .d.ts files for the SDK ensures your public API is typed while keeping integration complexity low. For teams that publish open-source packages, bundling declaration files allows consumers to benefit immediately, improving adoption.

    Conclusion & Next Steps

    Writing manual declaration files is a pragmatic and powerful skill for intermediate TypeScript developers. Start with minimal ambient declarations to remove friction, then evolve the types into well-structured index.d.ts files that reflect runtime semantics. Integrate checks into CI and keep your declarations in sync with upstream changes.

    Next steps: practice by typing a small untyped library you depend on; review your tsconfig options in light of declaration generation and read the linked guides to solidify build and interoperability knowledge.

    Enhanced FAQ

    Q1: When should I create a manual .d.ts file versus contributing to DefinitelyTyped? A1: Create a local .d.ts if you need fast, project-specific fixes or if the library is internal. If it's a widely used library and your types are generalizable, contribute to DefinitelyTyped so the community benefits. Local declarations can be a first step before preparing a cleaned, well-tested contribution.

    Q2: How do I choose between export = and default export declarations? A2: Mirror runtime behavior. If a library sets module.exports = fn or returns a value via module.exports, use export =. If it uses ES exports (export default or named exports and package.json has "type": "module"), use export default and named exports. If uncertain, inspect the distributed files or test imports in a small Node REPL.

    Q3: Can I ship declaration files without shipping TypeScript source? A3: Yes. Many libraries ship compiled JS and a types file (index.d.ts). Set the "types" field in package.json to point to your declaration file, and include it in the npm package. This gives TypeScript consumers type information without exposing source TS.

    Q4: What if my declaration file gets out of sync with runtime behavior? A4: Out-of-sync types are dangerous. Add type-tests that run in CI to compile small sample usages. If a runtime change happens, bump the types accordingly and consider creating stricter tests that catch API changes during development.

    Q5: How do I handle libraries that expose multiple entry points or subpath imports? A5: Write declaration files for each exported path or create an index that re-exports typed submodules. For packages with subpath imports (like 'lib/foo'), declare modules explicitly:

    ts
    declare module 'lib/foo' { export function foo(): void }

    Q6: Are there helper tools to generate declaration baselines? A6: Tools like dts-gen can scaffold declaration files, and TypeScript can emit declaration files from TS source. However, generated types often need human refinement. For build integration and maps, check the declaration and declarationMap options; see generating declaration files automatically.

    Q7: How do I type a library that mutates globals or the DOM? A7: Use declare global to augment the Window or globalThis interfaces with the provided shapes. Keep these augmentations minimal and scoped to avoid collisions. Example:

    ts
    declare global { interface Window { MyLib: { init(): void } } }

    Q8: Should I enable skipLibCheck to avoid third-party type issues? A8: skipLibCheck can be a useful short-term workaround, but relying on it hides type problems. Prefer to fix or isolate problematic type files and consider contributing fixes upstream.

    Q9: How do I make types play well with esModuleInterop and allowSyntheticDefaultImports? A9: These flags affect import style and how default imports are interpreted from CommonJS modules. If you rely on default import syntax for CJS modules, enable esModuleInterop. For more details and safer configuration, read our guide on configuring esModuleInterop and allowSyntheticDefaultImports.

    Q10: Any advice for long-term maintenance of manual declarations? A10: Treat type definitions as first-class artifacts: version them with your code, add tests, and run type checks in CI. Keep a short CHANGELOG for types and align major type changes with package major versions to reduce breaking changes for consumers. Also, use strict compiler flags incrementally—our configuring strictNullChecks guide provides strategies for tightening types safely.

    If you want hands-on examples tailored to a specific untyped library you use, paste the usage code and the library name and I can draft a starter index.d.ts for you.

    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:20:05 PM
    Next sync: 60s
    Loading CodeFixesHub...