Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Using @types Packages in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

TypeScript Declaration Files: JavaScript Contracts
TypeScript
11m
Oct 13, 2026

TypeScript Declaration Files: JavaScript Contracts

A .d.ts file is not an implementation. It is a public contract that lets TypeScript reason about JavaScript safely and usefully.

#TypeScript#Declaration Files+4
Building Custom Utility Types in TypeScript
TypeScript
12m
Sep 15, 2026

Building Custom Utility Types in TypeScript

TypeScript's built-in utility types are useful because they transform a type you already trust. This post shows how to build the same kind of focused helpers yourself: shallow and deep partials, mutable views, nullable fields, and API-safe update inputs.

#TypeScript#Custom Utility Types+4
Recursive Types in TypeScript
TypeScript
10m
Aug 9, 2026

Recursive Types in TypeScript

Some data structures are inherently self-referential — a tree node that contains other tree nodes, a JSON value that can itself be a JSON object containing more JSON values, a linked list whose nodes point to other nodes of the same type. TypeScript's recursive types handle all of these by allowing a type alias to reference itself in its own definition.

#Recursive Types#TypeScript Advanced Types+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What an @types Package Supplies
  • Choose the Source of Truth
  • Install and Align Versions Intentionally
  • Understand Type Visibility
  • Handle Missing or Incorrect Definitions Safely
  • Key Takeaways
TypeScript

Using @types Packages in TypeScript

October 20, 2026•6 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Centred package dependency diagram connecting a JavaScript library, matching @types contract, TypeScript compiler, and verified runtime
Using @types Packages in TypeScript

Installing a JavaScript library solves a runtime problem: Node, a bundler, or the browser can load its implementation. TypeScript still needs a second artifact to understand the library’s public surface. Sometimes the package ships that artifact itself. When it does not, the declarations often arrive through an @types package published from DefinitelyTyped.

This is convenient, but it is not automatic truth. An @types package is a versioned description of a runtime dependency. Treat it as a contract that must match the package you execute, the import style you use, and the compiler configuration that decides which declarations are visible.

What an @types Package Supplies

.d.ts files contain type information only; they produce no JavaScript. The current TypeScript declaration guide (opens in new tab) explains the normal resolution order: a package may bundle declarations, or community definitions can be installed under the @types scope and discovered automatically from node_modules/@types.

pnpm add lodash
pnpm add -D @types/lodash

After that installation, code gets the contract that the declaration author exposed:

import groupBy from "lodash/groupBy";
 
const byStatus = groupBy(
  [
    { id: "a", status: "open" },
    { id: "b", status: "closed" },
  ],
  "status"
);

The compiler can now reason about the import, argument shape, and return structure. It does not inspect or change lodash at runtime. A passing type check cannot prove that the installed JavaScript behaves as the declaration promises.

Choose the Source of Truth

Before adding @types, determine whether the runtime package already owns its declarations. Look at its package.json for types, typings, or an exports condition with a types path. If the package bundles types, do not add a duplicate @types package just because a search result suggested it. Competing declarations can cause duplicate identifier errors or subtly different expectations.

Use this decision order:

  1. Prefer declarations bundled by the runtime library.
  2. If absent, look for the matching community package under @types.
  3. If neither exists, write the narrowest local declaration or wrapper justified by actual runtime behaviour.
  4. If you publish a library, ship generated declarations with your source when possible; otherwise contribute independent definitions to DefinitelyTyped.

The TypeScript publishing guide (opens in new tab) makes the same recommendation for authors: source-owned generated declarations should travel with the package, while libraries without bundled types can use the @types organization. The distinction matters because source owners can update implementation and types in the same release.

Never Add Types Just to Silence an Error

A declaration package can be the wrong major version, describe a different import style, or expose APIs your installed runtime does not have. Verify the package and a representative runtime call before treating a compiler error as solved.

Install and Align Versions Intentionally

The type package generally follows the runtime package’s naming and major-version lineage, but the exact compatibility story varies. Read the package metadata and release notes when a library has major API changes. Pinning the runtime and its type package through your lockfile makes an unexpected declaration change easier to diagnose.

{
  "devDependencies": {
    "@types/legacy-widget": "^4.2.0"
  },
  "dependencies": {
    "legacy-widget": "^4.7.0"
  }
}

For an application, definitions are usually development dependencies because the compiler needs them but the deployed JavaScript does not. For a published library whose public declarations expose types from another package, the dependency may need to be available to consumers. That is a package-authoring decision; do not blindly copy an app’s dependency placement into a library.

Version alignment is more than a major number. Confirm:

  • the package name and import subpath match your runtime import;
  • the declaration supports your TypeScript version and module-resolution mode;
  • optional peer dependencies are installed when their exported types are referenced;
  • an integration test exercises the feature you are relying on.

When a type error appears after a dependency update, inspect both sides of the contract. The runtime package may have changed a return value, the definitions may have corrected a prior over-permissive type, or your code may have relied on an undocumented deep import. Treat the error as evidence, not noise.

Understand Type Visibility

By default, TypeScript finds visible @types packages in node_modules/@types directories up the folder tree. The typeRoots reference (opens in new tab) notes an important exception: once you specify typeRoots, only packages beneath those configured folders are included.

{
  "compilerOptions": {
    "typeRoots": ["./typings", "./node_modules/@types"]
  }
}

That configuration can be appropriate for a controlled environment, but it is a common reason an installed @types package appears invisible. In most application projects, omit typeRoots unless you have a concrete reason to replace the default discovery behaviour.

The types compiler option is different. It limits which type packages contribute globals to the compilation; it does not make ordinary module imports impossible to resolve. Use it deliberately when global declarations from test runners, Node, or browser extensions would otherwise leak into a project’s ambient environment.

{
  "compilerOptions": {
    "types": ["node", "vitest/globals"]
  }
}

In a monorepo, run tsc --showConfig from the package that fails. It reveals the effective configuration after inherited configs, which is often more useful than reading the root tsconfig.json in isolation.

Handle Missing or Incorrect Definitions Safely

If no definition exists, resist the tempting emergency declaration:

declare module "mystery-library";

It makes every import from that module any, which turns off the very boundary you intended to protect. It can be a short-lived unblocking step, but it should be tracked as debt rather than accepted as an integration strategy.

Instead, model the small surface you actually use, validate at the runtime boundary, and keep uncertainty from spreading:

// types/mystery-library.d.ts
declare module "mystery-library" {
  export function normalize(input: string): string;
}
import { normalize } from "mystery-library";
 
export function normalizeCustomerLabel(value: string): string {
  const result: unknown = normalize(value);
  if (typeof result !== "string") {
    throw new Error("mystery-library returned a non-string value");
  }
  return result;
}

If the definition is wrong, reproduce the mismatch with a small type test and a runtime test. Then decide whether a local wrapper, a module augmentation, an upstream issue, or a contribution is the most durable correction. The library-structures guide (opens in new tab) is useful here because declaration shape must reflect how the library is consumed—module, global, UMD, callable export—not how we wish it looked.

Avoid skipLibCheck as a reflexive fix. It can reduce noise in a large dependency graph, but it does not align incompatible contracts. If you use it for build performance or known third-party friction, retain targeted integration tests around the libraries that are important to your product.

Debug a Missing-Types Error Systematically

When the compiler reports that it cannot find a module or its declarations, work outward from the exact import:

  1. Verify the runtime package and the exact public subpath are installed.
  2. Inspect the runtime package’s package.json for bundled declarations and export-map conditions.
  3. Search for the matching @types package only if bundled types are absent.
  4. Inspect the effective compiler configuration with tsc --showConfig, especially moduleResolution, types, and typeRoots.
  5. Use tsc --traceResolution to see the paths TypeScript actually considered.
  6. Add a narrow local declaration or wrapper only after you can state the runtime contract it represents.

This sequence avoids two costly mistakes: installing random type packages until the error changes, and making a global any declaration that hides a packaging problem until production.

Key Takeaways

  • @types packages provide compile-time contracts for JavaScript libraries; they do not alter runtime code.
  • Prefer bundled types when a package ships them, then use a matching @types package only when needed.
  • Keep runtime and type packages aligned in version, import path, compiler mode, and real integration behaviour.
  • Understand typeRoots and types before concluding that TypeScript cannot see an installed definition.
  • When types are missing or wrong, create a narrow verified boundary instead of spreading any or casts through the application.

Next, we will go from consuming declarations to authoring them: modelling a real JavaScript library, choosing module versus global shape, and testing the contract as a public API.