This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Project Overview
npmx is an open-source browser for the npm registry. It helps developers explore package metadata, versions, dependencies, documentation, and other information.
I worked on npmx issue #2791, which reported an incorrect event in the package-version timeline. npmx could say that a package had removed its TypeScript types even though valid declaration files were still included in the published package.
My fix is available in npmx PR #3102.
Bug Fix
A package can expose TypeScript declarations explicitly through fields such as types or typings. It can also provide declarations implicitly by placing them beside its JavaScript entry points.
For example:
dist/index.mjs
dist/index.d.mts
TypeScript can use dist/index.d.mts even when package.json does not contain a top-level types field.
npmx already understood this pattern on its package-analysis page. However, the timeline endpoint used hasBuiltInTypes(), which only checked package metadata.
This caused the two parts of npmx to disagree:
- The package page correctly showed “Types included.”
- The timeline incorrectly showed “TypeScript types removed.”
Code
The fix keeps the fast metadata check as the first step. It performs the more expensive published-file analysis only for visible versions that could be part of a type-removal sequence.
The latest implementation selects every untyped version that has an older typed version somewhere earlier in the package’s history:
const possibleTypeRemovals = visibleVersions.filter(version => {
if (version.hasTypes) return false
const versionIndex = allVersions.indexOf(version)
return allVersions
.slice(versionIndex + 1)
.some(previousVersion => previousVersion.hasTypes)
})
Checking the full sequence is important. Consider these versions:
1.0.0 — explicit types field
2.0.0 — colocated .d.mts declaration
3.0.0 — colocated .d.mts declaration
An earlier version of my fix analyzed only 2.0.0, because it was directly beside the metadata transition. The latest version also analyzes 3.0.0 and any other affected version in the sequence.
For each candidate, the timeline fetches the package metadata and published file tree. It then reuses the project’s existing analyzePackage() utility:
const { pkg, typesPackage, files } =
await fetchPackageWithTypesAndFiles(packageName, version.version)
const analysis = analyzePackage(pkg, {
typesPackage,
files,
})
if (analysis.types.kind === 'included') {
version.hasTypes = true
}
If the additional lookup fails, the endpoint preserves the original metadata-only result instead of failing the complete timeline request.
Tests
I started by running the existing test suite and confirming that all 1,716 tests passed before making changes.
I then added a regression test with three package versions:
-
1.0.0declares types explicitly. -
2.0.0publishesindex.mjsandindex.d.mts. -
3.0.0also publishesindex.mjsandindex.d.mts.
The test verifies that both implicit versions are analyzed and that all three versions report TypeScript types.
expect(fetchPackageWithTypesAndFilesMock)
.toHaveBeenCalledWith('my-pkg', '2.0.0')
expect(fetchPackageWithTypesAndFilesMock)
.toHaveBeenCalledWith('my-pkg', '3.0.0')
expect(result.versions).toHaveLength(3)
expect(result.versions[0]?.hasTypes).toBe(true)
expect(result.versions[1]?.hasTypes).toBe(true)
expect(result.versions[2]?.hasTypes).toBe(true)
I also added a genuine-removal test. In that case, the newer version publishes index.mjs without a matching declaration file, so hasTypes correctly remains unset.
The final validation included:
- TypeScript type checking
- Repository lint checks
- The focused timeline test suite
- All 82 unit-test files
- The complete unit-test suite with the new regression coverage
- GitHub continuous-integration checks
My Improvements
The final change improves the timeline in several ways:
- It recognizes colocated
.d.mtsdeclarations. - It reuses the existing package-analysis implementation.
- It handles consecutive implicit-type versions after a metadata transition.
- It preserves genuine “types removed” events.
- It avoids failing the entire timeline when an additional file lookup is unavailable.
- Its tests verify array length before using optional indexed access.
What I Learned
The main lesson was that package metadata does not always describe everything inside a published npm package. Removing a types field does not necessarily mean that TypeScript support was removed.
I also learned why regression tests should cover a sequence rather than only one transition. Testing just 1.0.0 → 2.0.0 missed the behavior of 3.0.0. Extending the test revealed that every affected version after the transition needed file-aware analysis.
Review feedback also improved the test quality. I replaced non-null assertions on indexed versions with an explicit length assertion and optional access.
Most importantly, I reused npmx’s existing analysis logic instead of implementing another declaration-detection algorithm. This keeps the package page and timeline consistent.
Before and After
Before:
1.0.0 — TypeScript types included
2.0.0 — TypeScript types removed
3.0.0 — TypeScript types not detected
This could appear even when 2.0.0 and 3.0.0 contained valid .d.mts declarations.
After:
1.0.0 — TypeScript types included
2.0.0 — TypeScript types included
3.0.0 — TypeScript types included
A release that genuinely removes both the metadata and the declaration files is still treated as a type removal.
Current Status
The latest implementation is in commit bdeddc4c on npmx PR #3102.
The automated tests and CI checks pass. CodeRabbit’s latest review reported no remaining actionable comments. The pull request is open and awaiting an upstream maintainer review.
Top comments (0)