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/lodashAfter 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:
- Prefer declarations bundled by the runtime library.
- If absent, look for the matching community package under
@types. - If neither exists, write the narrowest local declaration or wrapper justified by actual runtime behaviour.
- 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:
- Verify the runtime package and the exact public subpath are installed.
- Inspect the runtime package’s
package.jsonfor bundled declarations and export-map conditions. - Search for the matching
@typespackage only if bundled types are absent. - Inspect the effective compiler configuration with
tsc --showConfig, especiallymoduleResolution,types, andtypeRoots. - Use
tsc --traceResolutionto see the paths TypeScript actually considered. - 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
@typespackages 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
@typespackage only when needed. - Keep runtime and type packages aligned in version, import path, compiler mode, and real integration behaviour.
- Understand
typeRootsandtypesbefore concluding that TypeScript cannot see an installed definition. - When types are missing or wrong, create a narrow verified boundary instead of spreading
anyor 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.