Declaring Peer Dependencies in a Type-Safe Way
AI generated
<T>
type
TypeScript · Peer Dependencies · npm · Libraries
Declaring Peer Dependencies in a Type-Safe Way
without bundled types and without duplicate identifier errors

A TypeScript library that extends React, Vue or another host library must not bundle its types itself, otherwise two copies of the same type declaration collide for the consumer. Declaring peer dependencies in a type-safe way means setting up the right version range, the right imports and the right CI test matrix for exactly that problem.

17 min read peerDependenciesMeta · import type · version ranges · CI matrix TypeScript 5.x · npm 10+

1. What peer dependencies are and when you need them

A peer dependency is a dependency that a library requires, but must not bring along itself, because the consumer already has that dependency installed in their own project. The classic example is a React hooks package or a Vue plugin: both expect React or Vue respectively to already be provided by the host project, and a separate additional installation would lead to two different React instances in the same bundle, causing runtime errors such as "Invalid Hook Call".

For a TypeScript library, an additional dimension comes into play: not only the runtime library itself, but also its type declarations must not end up duplicated in the project. Peer dependencies are therefore always the right choice when a library is tightly coupled to a host library whose types get extended or whose instances need to be shared, for example with plugin systems, framework integrations or build tool adapters.

2. peerDependencies vs. dependencies vs. devDependencies

Choosing the right dependency category in package.json decides whether a TypeScript library works cleanly for consumers or leads to version conflicts. dependencies are packages that every installation brings along regardless of what the consumer has already installed, which is correct for helper libraries nobody needs to share. peerDependencies, on the other hand, signal: "This version is required, but you, the consumer, must provide it yourself." devDependencies are only needed to develop the library itself and never end up in the published package.

A common mistake is accidentally listing a host library as a regular dependency instead of a peerDependency. The result: npm installs a second copy of React or Vue in node_modules, TypeScript suddenly sees two different @types/react packages and reports type conflicts, even though the code looks correct at first glance. Correct declaration in package.json is therefore the first and most important step.


{
  "name": "@mironsoft/react-form-toolkit",
  "version": "1.0.0",
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  },
  "devDependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "@types/react": "^18.3.0",
    "@types/react-dom": "^18.3.0"
  }
}

3. peerDependenciesMeta and optional peers

Not every peer dependency is needed by all consumers. A TypeScript library that supports both React and Vue through separate adapters does not want to force anyone to install both frameworks. That is exactly what peerDependenciesMeta is for: with the field optional: true, a peer dependency is marked as non mandatory, and npm no longer emits a warning when that dependency is missing.

It is important that peerDependenciesMeta only controls the package manager's behavior, not TypeScript's own behavior. Your own code additionally needs runtime checks or conditional imports to handle the case that an optional peer dependency might be missing, otherwise the library crashes on the first access to the missing package despite a correctly configured package.json.


{
  "peerDependencies": {
    "react": ">=18.0.0",
    "vue": ">=3.3.0"
  },
  "peerDependenciesMeta": {
    "react": { "optional": true },
    "vue": { "optional": true }
  }
}

4. Providing types for peer dependencies without bundling them

The key principle when handling types for peer dependencies is: never list the host library's @types packages under dependencies, only under devDependencies. During development of your own TypeScript library those types are needed to compile your own code against the correct signatures, but they must never become part of the published package, because the consumer brings their own, possibly newer version of the types.

If @types packages accidentally end up as a regular dependency in the published package, npm installs two versions of the same type declarations, TypeScript recognizes them as different, nominally incompatible types and reports errors such as "Type 'ReactNode' is not assignable to type 'ReactNode'", even though the name is identical. This confusing error is practically always a symptom of incorrectly declared peer type dependencies.

5. Setting version ranges for peer types correctly

The version range in peerDependencies should be as wide as possible and as narrow as necessary. A range that is too narrow like "react": "18.2.0" forces every consumer onto exactly that version, which in practice almost never works, because projects rarely have exactly the same patch version of a large library installed. The proven approach is a range across several major versions, provided your own TypeScript library has actually been tested against all of them, for example "react": "^17.0.0 || ^18.0.0 || ^19.0.0".

For the types themselves, an additional rule applies: your own library should pin devDependencies to the lowest supported version of the peer dependency and its types, not the newest. This ensures your own library actually compiles against the oldest declared version and does not accidentally use API surface from a newer version that does not yet exist in the declared minimum version.


{
  "peerDependencies": {
    "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "react": "17.0.2",
    "@types/react": "17.0.80"
  }
}

6. Test matrix: securing peer versions in CI

A wide version range in peerDependencies is only a promise, not proof. Without automated tests against several actual peer versions, nobody knows whether the TypeScript library really works with React 17 the same way it does with React 19. A CI matrix that repeats the build and the tests against every supported major version of the peer dependency uncovers compatibility problems before consumers report them.

It is especially important in this matrix to install not just the runtime library but also the matching @types version, because TypeScript compatibility problems frequently occur exclusively at the type level while the code runs fine at runtime. A build that only varies the runtime library but always uses the same types misses exactly this class of bugs.


# .github/workflows/peer-matrix.yml
name: Peer Dependency Matrix
on: [push, pull_request]
jobs:
  test:
    strategy:
      matrix:
        react-version: ["17.0.2", "18.3.1", "19.0.0"]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm install
      - run: npm install react@${{ matrix.react-version }} react-dom@${{ matrix.react-version }} --no-save
      - run: npm run typecheck
      - run: npm test

7. Type-only imports for peer packages

When a TypeScript library only needs the types of a peer dependency but no direct access to its JavaScript code at runtime, import type is the right choice. This syntax guarantees the import is fully removed at compile time and creates no runtime dependency, even if a bundler accidentally tries to resolve the regular import. That is especially important for optional peer dependencies where the consumer may not have installed the package at all.

Without import type, a regular import statement that was only meant for type information can still be compiled into a real runtime import under certain bundler configurations, when isolatedModules is active and the compiler cannot be certain the import is type-only. Explicit import type removes this uncertainty entirely and makes the intent immediately obvious to other developers reading the code.


// adapters/react-adapter.ts — type-only import, no runtime dependency
import type { ReactNode, ComponentType } from "react";

export interface FormFieldAdapter<TProps> {
  component: ComponentType<TProps>;
  render(props: TProps): ReactNode;
}

// Runtime code never touches "react" directly here —
// consumers who never install react still get correct typings.

8. Common mistakes: duplicate type declarations and duplicate identifiers

By far the most common mistake with peer dependencies in TypeScript is the error message "Duplicate identifier" or "Type X is not assignable to type X", even though both types have the identical name. The cause is practically always that two different copies of the same @types package exist in the node_modules tree, either because the library incorrectly bundles the types, or because different version ranges in a monorepo lead to duplicate installation.

Diagnosis works reliably with npm ls @types/react, which lists every installed instance of a type package in the dependency tree. If the package appears multiple times with different versions, that is the root cause. The fix is almost always to correct your own peer dependency declaration, or to deliberately force a single version through overrides or resolutions in npm, pnpm or yarn.

9. Peer dependency strategies compared

Depending on the type of coupling between a TypeScript library and its host library, different strategies are appropriate. The table below maps the most common situations to the fitting approach.

Situation Wrong approach Correct approach
React plugin react as dependency react as peerDependency
Optional Vue adapter Mandatory peer without meta peerDependenciesMeta.optional
Only types needed Regular import import type
Multiple major versions Testing against one version only CI matrix across all major versions

These four situations cover the majority of peer dependency cases in typical TypeScript libraries. Distinguishing them consistently avoids the vast majority of support requests around type conflicts for consumers.

Mironsoft

TypeScript libraries, plugin architecture and CI test matrices

Want to clean up peer dependency chaos in your library?

We audit existing dependency declarations, set up correct peer dependencies together with a test matrix, and permanently eliminate duplicate identifier problems for your consumers.

Dependency audit

Checking package.json for misclassified peer dependencies

CI matrix

Setting up automated tests against multiple peer major versions

Support reduction

Systematically avoiding duplicate identifier errors for consumers

10. Summary

Declaring peer dependencies in a type-safe way means getting three things right at once: choosing the correct dependency category in package.json, never bundling the corresponding @types packages into the published package, and consistently using type-only imports for peer packages whose runtime code stays optional. peerDependenciesMeta with optional: true turns additional frameworks into genuine options instead of mandatory dependencies.

The final building block is a CI test matrix that actually verifies claims about supported version ranges instead of merely asserting them in package.json. The most common error class, duplicate identifiers caused by duplicated installed type declarations, can be reliably diagnosed with npm ls and permanently avoided through correct peer dependency declaration.

Declaring peer dependencies type-safely — the essentials at a glance

Declaration

List host libraries as peerDependencies, list their types exclusively as devDependencies.

Optional peers

Set peerDependenciesMeta.optional and guard runtime code against missing packages.

Type-only imports

import type for peer types without a runtime dependency, guarantees complete removal at build time.

CI validation

A test matrix across every supported major version including matching @types packages.

11. FAQ: Declaring Peer Dependencies Type-Safely

1Why not React as a regular dependency?
A second React instance would cause Invalid Hook Call. peerDependencies ensures a single instance.
2Where do @types packages go?
Exclusively under devDependencies, never in the published package, otherwise consumers get duplicate types.
3What does optional: true do?
Suppresses the npm warning for a missing peer, code still needs guards for the missing case.
4How wide should the version range be?
As wide as actually tested, usually across several major versions joined with or.
5Why a matrix instead of one test?
A wide range is only a promise, the matrix proves actual compatibility.
6When to use import type?
Whenever only type information is needed without runtime code, guarantees full removal at build time.
7How to recognize duplicate identifier errors?
A type reported as incompatible with itself, npm ls @types/packagename usually shows two versions.
8Does npm overrides fix it?
Usually yes, but the actual incorrect dependency declaration should still be fixed.
9Install matching @types for every peer version in CI?
Yes, otherwise type level incompatibilities are missed.
10Does this apply to build tool plugins too?
Yes, the same principle applies to Vite or webpack plugins against their respective build tool.