Skip to content

CheckModelUnicity fails with pnpm injected workspace packages (duplicate paths to same file) #1853

@simllll

Description

@simllll

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:

  1. The real package path: packages/feature-foo-data/dist/Schema.d.ts
  2. 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

  1. Create a pnpm monorepo with injectWorkspacePackages: true in pnpm-workspace.yaml
  2. Have a workspace package that exports a type used in a TSOA controller's response DTO
  3. Run tsoa spec-and-routes
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions