Skip to content

fix(type-name-formatter): dedupe colliding identifiers via upfront precommit#1726

Open
mlewando-cp wants to merge 2 commits intoacacode:mainfrom
mlewando-cp:fix/1724-formatted-name-collision
Open

fix(type-name-formatter): dedupe colliding identifiers via upfront precommit#1726
mlewando-cp wants to merge 2 commits intoacacode:mainfrom
mlewando-cp:fix/1724-formatted-name-collision

Conversation

@mlewando-cp
Copy link
Copy Markdown

Summary

Two OpenAPI schema keys that differ only in separator placement — e.g. Foo_Bar and FooBar — both normalize to the same TypeScript identifier (FooBar) via src/type-name-formatter.ts's startCase + whitespace-strip pipeline. The previous cache is keyed by the raw input (hashKey = prefix_name_suffix), not by the formatted output, so collisions go undetected and the generator emits two export interface FooBar { … } declarations — TypeScript declaration-merges them and reports TS2717 whenever the shapes differ.

This PR resolves collisions upfront, in a new precommit(rawNames) pass the generator invokes once after loading schema components and before schema parsing begins. format() shrinks to a pure cache lookup plus a no-collision-logic fallback for dynamically-discovered names (enum keys, extractEnums / extractResponses results). All formatting logic is concentrated in a single private computeFormattedName() helper so format(), precommit(), and the new disableFormatTypeNames / typeNameSeparator options share one pipeline.

Fixes #1724.

Why not resolve collisions in format()?

An earlier iteration of this PR tried a mid-flight "evict-and-rename" approach inside format(): when a canonical incoming name collides with a non-canonical incumbent, move the incumbent to a suffixed slot so the user-declared name survives. It corrupts downstream output.

The schema parser calls format() during Phase 1 (from schema-utils.ts, schema-parser.ts, schema-routes.ts) and captures the return value directly into inline type strings — e.g. this.request<FooBar, any> for a route response. Later Phase-2 work (prepareModelTypecollectModelTypes) calls format() again per schema and captures the result into preparedModelType.name. Phase 2 happens after Phase 1, so definitions always see the final cache state and come out consistent. But any eviction that renames a raw schema mid-flight leaves the earlier Phase-1 captures frozen at the old name, and a route that returns Foo_Bar can end up generic-parameterized on FooBar — which is now the shape of an entirely different schema. A route-reference regression test in this PR would have caught it.

Pre-committing every schema name before the parser runs sidesteps the problem entirely: every format() call returns the final name on the first shot.

Design

Two-pass resolution in precommit

  1. Pass 1 — canonicals claim their slots. For every raw name where rawName === computeFormattedName(rawName) (i.e. the name already matches its formatted output), reserve that identifier. User-declared names like FooBar, FooBar1, User, V1Response all survive regardless of source order.
  2. Pass 2 — non-canonicals suffix-until-free. For every remaining raw name (e.g. Foo_BarFooBar), append the smallest integer suffix not already used by any pre-committed canonical or earlier non-canonical, producing deterministic FooBar, FooBar1, FooBar2, …

format() becomes a lookup

format = (name, options = {}) => {
  // ... typePrefix/typeSuffix/hashKey setup ...
  const cached = this.formattedModelNamesMap.get(hashKey);
  if (cached !== undefined) return cached;

  // Fallback (no collision check): compute inline, cache, return.
  // Reached only for enum keys and schemas added after precommit.
  const result = this.computeFormattedName(name, schemaType, typePrefix, typeSuffix);
  this.formattedModelNamesMap.set(hashKey, result);
  return result;
};

Single source of truth for the pipeline

computeFormattedName is the only place startCase, disableFormatTypeNames, typeNameSeparator, the ALL_CAPS fast path, and the onFormatTypeName hook are applied. precommit and the format() fallback both go through it, so all three formatting options (this PR's collision resolution + disableFormatTypeNames + typeNameSeparator) compose without special-casing.

Enum keys are intentionally excluded

Enum keys live in their own per-enum scope (EnumA.Bar and EnumB.Bar are both valid TS). Cross-enum collisions don't need disambiguation, so precommit and usedFormattedTypeNames only track schemaType === "type-name". Enum keys always hit the fallback path in format(), which preserves existing behavior.

Hook interaction

Collision detection runs on the hook output, not the raw startCase output. If onFormatTypeName maps two distinct raw names to the same string, one still ends up suffixed.

Minimal repro (pre-fix)

# collision-repro.yaml
openapi: 3.0.0
info: { title: repro, version: 1.0.0 }
paths:
  /a:
    get:
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Foo_Bar' }
  /b:
    get:
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FooBar' }
components:
  schemas:
    Foo_Bar:
      type: object
      required: [kind, legacyField]
      properties:
        kind: { type: string, enum: [legacy] }
        legacyField: { type: string }
    FooBar:
      type: object
      required: [kind, newField]
      properties:
        kind: { type: string, enum: [modern] }
        newField: { type: number }

Before: two export interface FooBar declarations, TS2717 once @ts-nocheck is stripped. After: export interface FooBar (for raw FooBar, with kind: "modern") and export interface FooBar1 (for raw Foo_Bar, with kind: "legacy"); route references are consistent.

Tests

Seven new cases under tests/spec/name-collision/:

Case What it asserts
Basic collision Foo_Bar + FooBarFooBar + FooBar1; both kind literals preserved
Pre-existing suffix, natural order Foo_Bar, FooBar, FooBar1 in that order → FooBar, FooBar1, FooBar2; no FooBar11
Pre-existing suffix, reversed order Same three schemas reversed → same outcome (order-independent)
Multiple canonicals FooBar, Foo_Bar, FooBar1, FooBar2 → non-canonical lands at FooBar3, canonicals preserved
Hook interaction onFormatTypeName: () => "Shared"Shared + Shared1
Repeat reference Dog referenced from three endpoints → exactly one export interface Dog
Route-reference correctness With generateClient: true, each route's generic parameter matches the corresponding definition by its unique property. Catches the eviction-era regression described above.

Context

Our client is generated from a Scala backend's OpenAPI spec. Scala's generator naturally emits both underscore-wrapped names (parameterized discriminator variants) and plain names (flat schemas), so having Foo_Bar and FooBar side-by-side is idiomatic output, not a schema authoring mistake. The numeric suffix convention (Foo_Bar1 in some specs) comes from the Scala generator itself, not from swagger-typescript-api — the suffix loop in this PR skips past it correctly.

Historical context: #65 introduced the startCase normalization that underpins the collision. This PR doesn't change that normalization; it adds collision-aware resolution on top of it.

Test plan

  • bun run test → 198 passing (189 pre-existing + 7 name-collision + 2 for the new disableFormatTypeNames/typeNameSeparator suites still green on top of this PR)
  • bun run build clean
  • bun run format:check clean
  • bunx biome check on modified files clean
  • Manual route-reference smoke test on a client-generating spec: route generics match definitions

…ecommit

Two OpenAPI schemas whose raw names differ only in separator placement
(e.g. `Foo_Bar` and `FooBar`) both normalize to `FooBar` via the existing
`startCase` + whitespace-strip pipeline. The previous cache, keyed by the
raw input, never detected the collision, producing two `export interface
FooBar` declarations and TS2717 whenever the shapes differed.

`precommit(rawNames)` resolves the full schema list in two passes before
the parser runs:
  1. Canonical raw names (raw === formatted output) claim their slot.
     User-declared identifiers like `FooBar` and `FooBar1` are preserved
     regardless of source order.
  2. Non-canonical raw names suffix-until-free past every already-claimed
     identifier, producing `FooBar`, `FooBar1`, `FooBar2`, ... deterministically.

`format()` shrinks to a pure cache lookup with a no-collision-logic
fallback for dynamically discovered names (enum keys, extracted schemas).
Integrating resolution upfront is necessary because the generator stashes
`format()` return values into inline type strings during Phase 1 of
schema/route parsing — any mid-flight rename would leave those captures
stale.

Enum keys are intentionally excluded from collision tracking: they are
scoped per-enum block (`EnumA.Bar` and `EnumB.Bar` are both valid).
Collision detection runs after `onFormatTypeName`, so hooks can't
accidentally reintroduce duplicates either.

Adds seven regression cases under tests/spec/name-collision/: basic
collision, pre-existing numeric suffix in natural and reversed source
order, multiple canonicals sharing a base, hook interaction,
repeat-reference sanity check, and route-reference correctness (a
client-generating test that catches any stale inline type string).

Fixes acacode#1724
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 24, 2026

🦋 Changeset detected

Latest commit: 53ca830

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
swagger-typescript-api Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Duplicate export interface when two schemas normalize to same TS name

2 participants