Moving from npm to pnpm, and why I kept TypeScript at ~5.9
The project started with npm, and later I moved the package manager to pnpm. In this note I go through what the switch involved: converting the lockfile, the packageManager and engines fields, the strict node_modules layout, and why I pinned TypeScript to the ~5.9 range.
01Why I switched
The reason was not speed or disk numbers. I did not measure those, so I will not give any numbers here. What mattered to me was strictness. npm installs node_modules as a largely flat tree; a dependency's dependency also sits at the root, and code can import a package that is never listed in package.json. That phantom dependency works today and breaks the day the package that brought it in drops it.
With pnpm, the node_modules at the root exposes only direct dependencies. If the code relies on a package it never declared, I see it right after install, not in production. In a small project that may look like a minor gain, but the habit itself matters: the list in package.json should say what the code actually depends on.
02The lockfile
What I watched most closely was that versions did not drift. Instead of letting pnpm-lock.yaml resolve from scratch, I imported it from the existing package-lock.json, so the resolved versions stayed the same. Then I deleted package-lock.json; two lockfiles side by side leave open the question of which one is right. I noted this in the commit message as well: same resolved versions, old lockfile removed.
The new lockfile starts with lockfileVersion: '9.0' and records each package with its resolved version and peer dependencies. The @unhead/vue entry, for example, also carries the vite, vue and typescript versions it was resolved against. That makes it easier to see in a diff what changed and why.
The settings section at the top of the lockfile is worth reading too: autoInstallPeers: true. In other words, if a package's peer dependency is missing from the project, pnpm installs it itself. In this project peers such as vue are already listed as direct dependencies, so it has little visible effect, but having the setting written into the lockfile makes it explicit how the install will behave.
03Pinning the version
I added two fields to package.json and left the scripts alone.
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build && vite build --ssr src/app/entry-server.ts --outDir dist-ssr && node scripts/prerender.mjs && node scripts/check-seo.mjs",
...
},
"devDependencies": {
...
"typescript": "~5.9",
"vite": "^8.3.4",
"vue-tsc": "^3.3.13"
},
"packageManager": "pnpm@11.9.0",
"engines": {
"node": ">=20.19",
"pnpm": ">=11"
}The packageManager field says which pnpm version the project runs with. On a machine with Corepack enabled (corepack enable), the pnpm command reads this field and fetches the right version itself. I wrote that in the README too; someone cloning the project does not need to think about their global pnpm version. The engines field sets the lower bounds: Node 20.19 and pnpm 11.
The scripts stayed as they were; pnpm runs the scripts in package.json the same way. Only the spelling changed: pnpm dev instead of npm run dev, pnpm build instead of npm run build. The README and the launch configuration that starts the dev server were updated to these commands. The build chain is the same too: type checking with vue-tsc, the client build, the SSR build, prerendering and the SEO check.
When you move a lockfile, the goal is not new versions but the same versions installed by a different tool.
04Why TypeScript ~5.9
Most packages in devDependencies use ^, but TypeScript uses ~5.9. That is a deliberate choice. ^ accepts everything up to the next major version, while ~ accepts only 5.9.x patches. The reason is TypeScript 7: at the time I wrote this note it was not compatible with vue-tsc. vue-tsc type-checks .vue files on top of TypeScript, and the build script runs vue-tsc --noEmit as its very first step. An incompatible TypeScript version means the build breaks at step one.
In the lockfile, typescript is resolved as 5.9.3, and the vue and @unhead/vue entries are tied to that version as well. When vue-tsc gains TypeScript 7 support, widening the range will be a one-line change. Until then I would rather have type checking stop at an explicit boundary than change silently after an install.
05After the switch
This project's dependency list is short to begin with: five packages at runtime (vue, vue-router, @unhead/vue, gsap, animejs), and on the development side Vite, vue-tsc, satori and resvg for the OG images, and a few font packages. That is why the switch fit in a single commit: the new lockfile, the deleted old one, two fields in package.json and the updated commands.
There was also one thing I deliberately did not do during the switch: update dependencies. Had I changed both the package manager and the versions in the same commit, telling which one caused a breakage would have been harder. I changed the tool first and left version upgrades as separate work.
The real value will show later. If I add a new import and forget to put the package in package.json, the install will not hide it; and when I open the project on another machine, I will be working with the same pnpm version, the same lockfile and the same TypeScript.