# Dependency hygiene for TypeScript projects

> Source: https://learn-typescript.org/review/dependencies/
> Part of Learn TypeScript, free to read.

TypeScript inherits the entire npm situation — install scripts, slopsquatting, transitive bloat — and that half is covered in [npm dependency hygiene](https://learn-javascript.org/review/dependencies/). Read that first; it is the larger risk.

This page is the part that is specific to TypeScript, and it is mostly about the **second dependency graph** most teams never look at: type definitions.

## `@types` is a supply chain you do not review

```json
"devDependencies": {
  "@types/node": "^22.0.0",
  "@types/express": "^5.0.0",
  "@types/lodash": "^4.17.0"
}
```

Those are npm packages like any other. They are fetched from the registry, they can have install scripts, and their contents flow into your build. Nobody reads them, because they are "just types".

Three practical consequences:

**Type definitions can lie.** A `.d.ts` file describes a library; nothing verifies the description. A definition that says a function returns `string` when it can return `string | undefined` produces confidently wrong code with no `any` anywhere. This is not usually malice — it is drift — but the effect on generated code is the same: the model trusts the type and writes accordingly.

**Version drift is silent.** `@types/express@4` alongside `express@5` compiles fine and describes a different library than the one you are running. There is no mechanism that checks the two agree.

```bash
# find types that have drifted from their runtime package
npm ls --depth=0 2>/dev/null | grep -E '@types/'
```

For each one, check the major version matches the runtime package. This is a five-minute audit that finds real bugs in most codebases over a year old.

**Prefer packages that ship their own types.** A library with `"types"` in its `package.json` has definitions maintained by the same people who maintain the code, versioned together, and updated in the same release. That is strictly better than a community definition in a separate repository on a separate release cadence.

```bash
npm view <name> types typings exports    # does it ship its own?
```

When choosing between two comparable libraries, the one that ships its own types is the safer pick — and it is one fewer package in your tree.

## `skipLibCheck` is a trade, know what you traded

```json
{ "compilerOptions": { "skipLibCheck": true } }
```

Nearly every project sets this, because without it a single broken `.d.ts` in a transitive dependency fails your build. It is a reasonable default.

What you gave up: type checking *of the definitions you are trusting*. If `@types/foo` contains an error, you will not hear about it — you will just get wrong types silently. Worth turning off occasionally on a quiet afternoon to see what it reports.

## Dual publishing and module resolution

The most time-consuming dependency problem in TypeScript, and it is rarely a security issue — just hours.

```json tsconfig.json
{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" } }
```

`nodenext` makes TypeScript resolve modules the way Node actually does, including the `exports` map in each package's `package.json`. Older settings (`node`, `node10`) ignore `exports` and will happily resolve a path that fails at runtime — which is how you get code that compiles and then throws `ERR_PACKAGE_PATH_NOT_EXPORTED` in production.

Two symptoms worth recognising:

- **"This package is ESM-only"** — the package ships no CommonJS build and your project is CJS. `tsx`, dynamic `import()`, or move the project to ESM.
- **Types resolve but the import fails at runtime** — your `moduleResolution` and your runtime disagree. Set `nodenext` and fix what it reports; it is telling you the truth.

`npx @arethetypeswrong/cli <package>` is the tool for diagnosing a package's publishing setup before you adopt it, and it is worth running on any dependency that is giving you resolution trouble.

## `verbatimModuleSyntax` and type-only imports

```json
{ "compilerOptions": { "verbatimModuleSyntax": true } }
```

Forces you to write `import type { Foo }` when you only need the type. Two benefits: the emitted JavaScript matches what you wrote (no surprise elision), and a type-only import cannot accidentally pull a runtime dependency into your bundle.

That second point is a real bundle-size lever. Importing a type from a large library without `import type` can drag the whole library into the output.

## Reducing the graph

Everything in [the JavaScript platform table](https://learn-javascript.org/review/dependencies/) applies. TypeScript adds a few of its own:

| Generated reaches for | Now unnecessary |
|---|---|
| `ts-node` | `tsx`, or Node's built-in type stripping |
| `@types/node-fetch`, `node-fetch` | global `fetch` and its built-in types |
| `@types/uuid`, `uuid` | `crypto.randomUUID()` |
| a `DeepPartial` / `Awaited` helper package | built-in utility types cover most cases |
| `io-ts` + `fp-ts` for validation | zod or valibot, far smaller surface |
| `class-transformer` for plain objects | a schema library, unless you are in NestJS |
| `typescript-is`, transformer-based validators | require a patched compiler; avoid |

That last row deserves a warning of its own: **avoid anything requiring a compiler transformer or a patched `tsc`.** They break on every TypeScript release, they are incompatible with `tsx`, `esbuild` and `swc`, and you will eventually spend a week removing one.

## Version pinning for the compiler itself

```json
"devDependencies": { "typescript": "5.9.2" }
```

Pin TypeScript exactly, not with a caret. Minor releases add checks, and a floating version means CI can start failing on a build you did not change. Upgrade deliberately, read the release notes, and fix what the new checks find — they are usually finding real bugs.

## The audit

```bash
npx depcheck                          # installed and never imported
npx @arethetypeswrong/cli --pack .    # is YOUR package published correctly?
npm ls --depth=0 | grep '@types/'     # do the majors match their runtime packages?
npm audit --omit=dev
npx tsc --noEmit --skipLibCheck false 2>&1 | head -40   # what is skipLibCheck hiding?
```

The `depcheck` one is worth running quarterly. TypeScript projects accumulate `@types` packages for libraries that were removed years ago, and nobody notices because they do not break anything — they just sit in the tree being a surface.

:::verdict The TypeScript-specific policy
1. Prefer libraries that ship their own types. One fewer package, and they cannot drift.
2. Check `@types/*` majors match their runtime packages. Free, and it finds real bugs.
3. `moduleResolution: "nodenext"` so the compiler resolves what Node resolves.
4. Pin the TypeScript version exactly.
5. Never adopt anything requiring a compiler transformer.
:::

## Common questions

### Are `@types` packages a real security risk?

They are npm packages with the same install-time properties as any other, so yes in principle — and the practical risk is lower because type definitions do not execute at runtime. The bigger day-to-day cost is correctness: definitions that drift from the library they describe produce wrong code with no visible `any`.

### Should I use `skipLibCheck`?

Yes, as a default — without it one broken definition in a transitive dependency blocks your build for reasons that are not your fault. Turn it off occasionally to see what it reports, and treat anything it finds in your direct dependencies as worth fixing.

### DefinitelyTyped or bundled types?

Bundled, whenever the choice exists. They are versioned and released with the code, maintained by the same people, and they cannot fall out of sync. Community definitions are excellent for libraries that will never ship their own, and a maintenance lag otherwise.

### How do I stop `@types` accumulating?

`npx depcheck` on a schedule, and remove a package's types in the same commit that removes the package. The reason they accumulate is that removing a dependency does not break anything if its types stay behind.
