Review & Verify Updated 2026-09 9 min read View as Markdown

Dependency hygiene for TypeScript projects

Everything npm does wrong, plus a second parallel dependency graph made of type definitions that nobody reviews.

TypeScript inherits the entire npm situation — install scripts, slopsquatting, transitive bloat — and that half is covered in npm dependency hygiene. 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.

shell
# 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.

shell
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.

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 applies. TypeScript adds a few of its own:

Generated reaches forNow unnecessary
ts-nodetsx, or Node's built-in type stripping
@types/node-fetch, node-fetchglobal fetch and its built-in types
@types/uuid, uuidcrypto.randomUUID()
a DeepPartial / Awaited helper packagebuilt-in utility types cover most cases
io-ts + fp-ts for validationzod or valibot, far smaller surface
class-transformer for plain objectsa schema library, unless you are in NestJS
typescript-is, transformer-based validatorsrequire 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#

shell
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.

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.

Get the TypeScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.