Problem
CheckModelUnicity compares model identity using raw fileName strings from the TypeScript AST. In a pnpm monorepo with injectWorkspacePackages: true, the same .d.ts file is reachable via two different filesystem paths:
- The real package path:
packages/feature-foo-data/dist/Schema.d.ts
- The injected copy in
node_modules: node_modules/.pnpm/@scope+feature-foo-data@file+packages+feature-foo-data_.../node_modules/@scope/feature-foo-data/dist/Schema.d.ts
These are physically separate files (hardlinked copies) with identical content and identical pos offsets, but different fileName values. TSOA sees them as two conflicting definitions and throws:
Error: Found 2 different model definitions for model PromptConfig:
orig: [{"fileName":"…/node_modules/.pnpm/@one100+feature-voice-agent-data@file+…/dist/domain/db/VoiceAgentConfigSchema.d.ts","pos":13540}],
act: [{"fileName":"…/packages/feature-voice-agent-data/dist/domain/db/VoiceAgentConfigSchema.d.ts","pos":13540}]
With symlinked workspace packages (the pnpm default before v10), both paths resolve to the same inode and TypeScript reports a single fileName. With injected packages, they're separate copies.
Root cause
In metadataGenerator.ts, CheckModelUnicity does a raw string comparison:
pos.fileName === origPos.fileName
This doesn't account for multiple filesystem paths resolving to the same logical file.
Suggested fix
Normalize fileName via fs.realpathSync (or path.resolve) before comparing. For injected/hardlinked files, realpathSync returns the canonical path, collapsing duplicates:
import { realpathSync } from 'fs';
public CheckModelUnicity(refName: string, positions: Array<{ fileName: string; pos: number }>) {
const normalize = (pos: { fileName: string; pos: number }) => ({
fileName: realpathSync(pos.fileName),
pos: pos.pos,
});
const normalizedPositions = positions.map(normalize);
if (!this.modelDefinitionPosMap[refName]) {
this.modelDefinitionPosMap[refName] = normalizedPositions;
} else {
const origPositions = this.modelDefinitionPosMap[refName];
if (!(origPositions.length === normalizedPositions.length &&
normalizedPositions.every(pos =>
origPositions.find(origPos => pos.pos === origPos.pos && pos.fileName === origPos.fileName)
))) {
throw new Error(`Found 2 different model definitions for model ${refName}: orig: ${JSON.stringify(origPositions)}, act: ${JSON.stringify(normalizedPositions)}`);
}
}
}
Alternatively, the normalization could happen earlier — when fileName is first read from the TypeScript SourceFile node — so all downstream consumers benefit.
Reproduction
- Create a pnpm monorepo with
injectWorkspacePackages: true in pnpm-workspace.yaml
- Have a workspace package that exports a type used in a TSOA controller's response DTO
- Run
tsoa spec-and-routes
- TSOA finds the type via two paths and throws the duplicate model error
Environment
- tsoa: 6.6.0
- pnpm: 10.x with
injectWorkspacePackages: true
- TypeScript: 5.9.x
- Node: 24.x
Problem
CheckModelUnicitycompares model identity using rawfileNamestrings from the TypeScript AST. In a pnpm monorepo withinjectWorkspacePackages: true, the same.d.tsfile is reachable via two different filesystem paths:packages/feature-foo-data/dist/Schema.d.tsnode_modules:node_modules/.pnpm/@scope+feature-foo-data@file+packages+feature-foo-data_.../node_modules/@scope/feature-foo-data/dist/Schema.d.tsThese are physically separate files (hardlinked copies) with identical content and identical
posoffsets, but differentfileNamevalues. TSOA sees them as two conflicting definitions and throws:With symlinked workspace packages (the pnpm default before v10), both paths resolve to the same inode and TypeScript reports a single
fileName. With injected packages, they're separate copies.Root cause
In
metadataGenerator.ts,CheckModelUnicitydoes a raw string comparison:This doesn't account for multiple filesystem paths resolving to the same logical file.
Suggested fix
Normalize
fileNameviafs.realpathSync(orpath.resolve) before comparing. For injected/hardlinked files,realpathSyncreturns the canonical path, collapsing duplicates:Alternatively, the normalization could happen earlier — when
fileNameis first read from the TypeScriptSourceFilenode — so all downstream consumers benefit.Reproduction
injectWorkspacePackages: trueinpnpm-workspace.yamltsoa spec-and-routesEnvironment
injectWorkspacePackages: true