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.
Table of Contents
- 1. What peer dependencies are and when you need them
- 2. peerDependencies vs. dependencies vs. devDependencies
- 3. peerDependenciesMeta and optional peers
- 4. Providing types for peer dependencies without bundling them
- 5. Setting version ranges for peer types correctly
- 6. Test matrix: securing peer versions in CI
- 7. Type-only imports for peer packages
- 8. Common mistakes: duplicate type declarations and duplicate identifiers
- 9. Peer dependency strategies compared
- 10. Summary
- 11. FAQ
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.