Skip to content

Proposal: implicit optional @types/* peers for packages that peer-depend on a runtime package but declare @types/* nowhere #1

Description

@LineGu

Problem

The current readPackage hook only acts when a package declares @types/x in its devDependencies. Many popular packages reference React types in their published .d.ts but declare @types/react nowhere — not even in devDependencies (e.g. framer-motion, @emotion/react, @dnd-kit/*, recoil). For these, the plugin is a no-op, and TypeScript falls back to the hidden hoist directory (node_modules/.pnpm/node_modules/), picking an arbitrary @types/react version.

In a 138-workspace monorepo where @types/react 17/18/19 coexist, we measured that typecheck results depend on the install path (empty→full install passes; an install tree previously touched by --prod --filter fails with 609 TS2786 errors), because the hoist fallback content differs per install history. After adopting this plugin, we still had to hand-maintain 22 packageExtensions entries solely for packages with zero @types declarations — and that list rots as dependencies change.

Proposal

For every runtime peer dependency foo (skipping @types/* itself), if the manifest has no @types/foo in dependencies, peerDependencies, or peerDependenciesMeta, add:

pkg.peerDependencies[typesName] = '*'
pkg.peerDependenciesMeta = { ...pkg.peerDependenciesMeta, [typesName]: { optional: true } }
// scoped: @scope/foo -> @types/scope__foo

Peer-depending on foo means "I use the consumer's copy of foo" — so the package's types should resolve to the consumer's @types/foo as well. This rule restores that correspondence at the declaration level.

Why optional peers are the right shape

  • pnpm decides link creation by declaration presence; optional vs required only gates the missing-peer warning. An optional peer is sufficient to make the package resolve the consumer's @types/react directly instead of the hoist fallback.
  • autoInstallPeers does not install optional peers, so JS-only consumers get no extra installs and no warnings — the rule is inert unless the consumer already provides the types.
  • Unlike the existing devDep-based rule, this reads only peerDependencies, so it is unaffected by @pnpm/plugin-types-fixer does not work when packages are already cached in the store (bundledManifest strips devDependencies) pnpm#10698 (cached bundled manifests stripping devDependencies makes the current rule non-deterministic).

Known cost / open question

Peers materialize as resolution suffixes in pnpm, so this rule:

  1. changes resolution keys for every package that peer-depends on a typed runtime package (one-time lockfile churn on adoption), and
  2. splits instances where multiple @types/x versions coexist — though in that scenario per-subtree types are arguably the correct outcome (it's what we reproduced manually with packageExtensions + '*').

Given that, I'd expect this to fit better as an opt-in flag on the plugin (or a sibling config dependency) rather than a default.

Has this direction been considered? And are there costs or failure modes I'm missing that led to the current devDependencies-signal-only design? Curious to hear your thoughts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions