Skip to content

fix(openapi3): missing discriminator mapping entry when first union variant causes circular emit#10268

Open
jensdev wants to merge 1 commit intomicrosoft:mainfrom
jensdev:main
Open

fix(openapi3): missing discriminator mapping entry when first union variant causes circular emit#10268
jensdev wants to merge 1 commit intomicrosoft:mainfrom
jensdev:main

Conversation

@jensdev
Copy link
Copy Markdown

@jensdev jensdev commented Apr 3, 2026

Problem

When a TypeSpec model uses @discriminator on a base type and all subtypes are referenced through a named union, the first variant in the union is silently dropped from the generated discriminator.mapping in the OpenAPI output.

Example:

@discriminator("classification")
model Pokemon { classification: string; }

model NormalPokemon extends Pokemon { classification: "normal"; }
model LegendaryPokemon extends Pokemon { classification: "legendary"; }
model MythicalPokemon extends Pokemon { classification: "mythical"; }

union PokemonVariant {
  normal: NormalPokemon,
  legendary: LegendaryPokemon,
  mythical: MythicalPokemon,
}

Generated (broken):

discriminator:
  propertyName: classification
  mapping:
    legendary: '#/components/schemas/LegendaryPokemon'
    mythical: '#/components/schemas/MythicalPokemon'
    # 'normal' is missing!

Root Cause

getDiscriminatorMapping calls emitTypeReference for each derived model. The first variant in the union (NormalPokemon) is already mid-emission when applyDiscriminator runs — because emitting NormalPokemon triggered the emission of its base Pokemon, which immediately calls applyDiscriminator.

For a model that is currently being emitted, emitTypeReference returns a Placeholder (to handle the circular reference). The existing code does:

mapping[key] = (ref.value as any).$ref;

Placeholder has no $ref property, so this evaluates to undefined. The Placeholder does resolve correctly later (via its onValue callback mechanism), but nobody registered a listener to update the mapping — so mapping["normal"] stays undefined and is silently dropped during YAML serialisation.

The second and third variants (LegendaryPokemon, MythicalPokemon) are not yet in the emit stack at this point, so their emitTypeReference calls return proper declarations and their $ref values are captured correctly.

Fix

Check whether ref.value is a Placeholder. If so, register an onValue listener that writes the resolved $ref into the mapping once the circular reference unwinds. Placeholder is already imported in this file.

getDiscriminatorMapping(variants: Map<string, Type>) {
  const mapping: Record<string, string> | undefined = {};
  for (const [key, model] of variants.entries()) {
    const ref = this.emitter.emitTypeReference(model);
    compilerAssert(ref.kind === "code", "Unexpected ref schema. Should be kind: code");
    if (ref.value instanceof Placeholder) {
      ref.value.onValue((resolvedValue) => {
        mapping[key] = (resolvedValue as any).$ref;
      });
    } else {
      mapping[key] = (ref.value as any).$ref;
    }
  }
  return mapping;
}

The onValue callback fires before YAML serialisation, so the mapping object is complete by the time the output is written.

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

Labels

emitter:openapi3 Issues for @typespec/openapi3 emitter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant