fix(http): apply the merge patch transform to the base model by Om-singhaI · Pull Request #12299 · microsoft/typespec · GitHub
Skip to content

fix(http): apply the merge patch transform to the base model - #12299

Open
om singhal (Om-singhaI) wants to merge 4 commits into
microsoft:mainfrom
Om-singhaI:fix/merge-patch-base-model
Open

om singhal (Om-singhaI) wants to merge 4 commits into
microsoft:mainfrom
Om-singhaI:fix/merge-patch-base-model

Conversation

@Om-singhaI

@Om-singhaI om singhal (Om-singhaI) commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #11534

The merge patch mutator in packages/http/src/merge-patch.ts transforms the properties and indexer of a model, but the clone keeps baseModel pointing at the original base. So for model Widget extends Base, MergePatchUpdate<Widget> emitted allOf: [{ $ref: Base }], inherited required properties stayed required, and {} was rejected. Nested WidgetMergePatchUpdateOrCreate had the same allOf.

The Model mutator now runs baseModel through the same mutator and sets the result on the clone. The base gets the matching name, such as BaseMergePatchUpdate or BaseMergePatchUpdateOrCreate, and is shared with MergePatchUpdate<Base>. Inherited properties follow the model's own rules: a required id becomes optional but not nullable, optional ones also take null, and a discriminator on the base stays required. I kept the base instead of flattening so emitters still see the inheritance and discriminator. A patched base's discriminator mapping still lists the unpatched derived models, as MergePatchUpdate<Pet> does today.

Three changes to note:

  1. A visible inherited @path or other metadata property now reports merge-patch-contains-metadata, as the model's own properties do, unless the derived model overrides it. On main it only got the metadata-ignored warning.
  2. A transformed model or union with @friendlyName now gets a friendly name from the same name template, so a patched BaseDto base is emitted as BaseDtoMergePatchUpdate and the original keeps BaseDto. Without this, the patched base would reuse BaseDto and openapi3 would report duplicate-type-name, which root and nested models already hit on main. model WidgetPatch is MergePatchUpdate<Widget> still emits as WidgetPatch. When the original friendly named model is not emitted itself (for example it lives outside the service namespace, or omit-unreachable-types is on), its patch is now WidgetDtoMergePatchUpdate instead of WidgetDto.
  3. A property that the transform makes optional drops @key, @madeOptional and @madeRequired in the patch, since it is optional in every version. Inherited properties need this now that the base is patched, and own properties get it too: on main an own @key reports no-optional-key, an own @madeRequired reports made-required-optional, and an own @madeOptional stays required in the patch schema of earlier versions. Properties that stay required, such as discriminators and the properties of an element in a replace only array, keep them.

One case still differs from main: a @resource model whose @key is inherited now reports resource-missing-key on the patch model, because the patch has no key. A @resource model with its own @key already reports this on main, so I left it for a separate change.

Smaller output changes: a Record<Thing> base now patches its values, and a read only inherited @key @path no longer warns.

I added 17 cases to packages/http/test/merge-patch.test.ts, 5 to packages/openapi3/test/merge-patch.test.ts, and a changeset.

Testing, built from this branch:

  • vitest run test/merge-patch.test.ts: 54 passed in packages/http and 11 in packages/openapi3. With src/merge-patch.ts from main, 21 of the 22 new cases fail; the other checks an error main already reports.
  • vitest run with two workers: 447 passed in packages/http, 2717 in packages/openapi3.
  • The issue's snippet as OpenAPI 3.1: {}, {"label":"x"} and {"label":null} now validate against WidgetPatch.

The merge patch mutator transformed the properties and indexer of a
model but left the clone's baseModel pointing at the original base. For
a model that uses extends, MergePatchUpdate emitted an allOf to the
unpatched base, so inherited required properties stayed required and an
empty patch was rejected.

The base model now goes through the same mutator, so it is emitted as
BaseMergePatchUpdate (or its OrCreate and ReplaceOnly variants) and the
inherited properties follow the same rules as the model's own.
@azure-pipelines

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Friendly-named base models can cause duplicate schema names and break previously valid OpenAPI emission.

1 open issue
What changed in this PR

Fixes #11534 by applying HTTP merge-patch transforms to base models, so inherited properties follow patch rules while preserving inheritance.

Changes:

  • Transform base models using the matching merge-patch mode.
  • Add inheritance, discriminator, and OpenAPI schema regression tests.
  • Document the fix and diagnostic changes.
File Description
packages/​openapi3/​test/​merge-patch.test.ts Verify patched base schema references.
packages/​http/​test/​merge-patch.test.ts Cover inherited properties, discriminators, and nested transforms.
packages/​http/​src/​merge-patch.ts Transform and assign patched base models.
.chronus/​changes/​fix-merge-patch-base-model-2026-9-16-18-59-38.md Document inheritance behavior and diagnostic changes.

Review effort: Balanced

Comment thread packages/http/src/merge-patch.ts Outdated
A model or union cloned by the merge patch mutator replays the
decorators of its source, so `@friendlyName` gave the clone the same
emitter name as the original. Now that the base model goes through the
mutator, a base with a friendly name hit `duplicate-type-name` in
OpenAPI whenever the original base was also emitted. Root and nested
models with a friendly name already did on main.

When the mutator renames a clone, it now also rewrites the clone's
friendly name with the same name template, so BaseDto becomes
BaseDtoMergePatchUpdate (or its OrCreate and ReplaceOnly variants). The
original keeps its friendly name, and `model X is MergePatchUpdate<T>`
still uses its own name.
Copilot AI balanced review requested due to automatic review settings October 18, 2026 21:06
@Om-singhaI

Copy link
Copy Markdown
Contributor Author

I pushed a follow up commit for the @friendlyName case.

When the mutator renames a model or union that has a friendly name, it now builds the clone's friendly name from the same name template and leaves the original alone. The base keeps BaseDto and its patched copy becomes BaseDtoMergePatchUpdate. Nested models get ThingDtoMergePatchUpdateOrCreate and ThingDtoMergePatchUpdateReplaceOnly, and a base with @friendlyName("{name}Envelope", T) becomes InnerEnvelopeMergePatchUpdate. This also covers root and nested models with a friendly name, which already hit duplicate-type-name on main. model WidgetPatch is MergePatchUpdate<Widget> still emits as WidgetPatch.

One output change: when the original friendly named model is not emitted itself (for example it lives outside the service namespace, or omit-unreachable-types is on), its patch came out as WidgetDto on main and comes out as WidgetDtoMergePatchUpdate now.

I added two cases to packages/http/test/merge-patch.test.ts and two to packages/openapi3/test/merge-patch.test.ts, including a PATCH that takes MergePatchUpdate<Widget> and returns Widget with both schemas emitted. All four fail on the previous commit and pass now. Full runs: 435 passed in packages/http and 2715 in packages/openapi3.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Base traversal rejects metadata properties even when the derived model overrides them with non-metadata properties.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Avoid inherited metadata errors for overridden properties

packages/​http/​src/​merge-patch.ts:420

For model Base { @path id: string; } model Widget extends Base { id: string; }, traversing the base now reports merge-patch-contains-metadata on Base.id and blocks emission, even though the effective Widget.id has no metadata (packages/compiler/src/core/checker.ts:8984–9002 excludes overridden properties). Validate metadata against each patch subject's effective properties, independently of cached base transformation, and add a regression that also verifies patching Base directly still reports the error.

1 resolved since last review

Review effort: Balanced

Since the base model goes through the merge patch mutator, the base
reported `merge-patch-contains-metadata` for its own metadata
properties. For `model Base { @path id: string; }` and
`model Widget extends Base { id: string; }`, patching Widget failed even
though Widget.id has no metadata. On main it compiled with only the
`metadata-ignored` warning.

The metadata check now runs when a model is used as a patch subject, on
its own properties and the inherited ones it does not override
(`walkPropertiesInherited`). A model reached only as a base is not
checked on its own, and each property is reported once per mutator, so
patching Base directly, or through a derived model that does not
override id, still reports it in either order. The check is registered
before the model's properties are transformed, so a base that is patched
again through its own properties (`parent?: Base`) is still checked.
Record values now go through `cachedMutateSubgraph` like the other
nested types so their check runs too.

Metadata properties are also left out of the transformed model, so the
patched base does not keep the overridden property.
Copilot AI balanced review requested due to automatic review settings October 19, 2026 23:10
@Om-singhaI

Copy link
Copy Markdown
Contributor Author

I pushed one more commit for a case the base model change broke: a derived model that overrides an inherited metadata property.

For model Base { @path id: string; name: string; } and model Widget extends Base { id: string; }, patching Widget reported merge-patch-contains-metadata on Base.id, although Widget.id has no metadata. On main this compiled with only the metadata-ignored warning.

The check now runs for each patch subject, on its own properties and the inherited ones it does not override. So MergePatchUpdate<Base>, a derived model that keeps the @path id, and a Base that is patched through its own parent?: Base property still get the error, whichever operation comes first. The patched base also leaves metadata out, so here BaseMergePatchUpdate has only name and WidgetMergePatchUpdate has an optional id.

I added eight cases to packages/http/test/merge-patch.test.ts (@path and @header overrides next to a plain size override, an override two levels down, Base patched directly after Widget, a sibling that does not override id, and three ways Base is patched again through its own properties) and one to packages/openapi3/test/merge-patch.test.ts. The four override cases fail on the previous commit and pass now. Full runs: 443 passed in packages/http and 2716 in packages/openapi3.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Transforming visible inherited key properties can now cause compilation errors because optional properties retain their @key decorator.

2 open issues

Review effort: Balanced

Comment thread packages/http/src/merge-patch.ts
Comment thread packages/http/src/merge-patch.ts
@pkg-pr-new

pkg-pr-new Bot commented Oct 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/http@12299
npm i https://pkg.pr.new/@typespec/openapi3@12299

commit: cbebae8

@github-actions

Copy link
Copy Markdown
Contributor

All changed packages have been documented.

  • ✅ @typespec/http
Show changes

@typespec/http - fix ✏️

Apply the MergePatchUpdate and MergePatchCreateOrUpdate transform to the base model of a model that uses extends, so inherited properties become optional in the patch shape instead of coming from the unpatched base model. A visible inherited metadata property now reports merge-patch-contains-metadata, as the model's own properties do, unless the derived model overrides it. A transformed model or union with @friendlyName now gets a friendly name from the same name template, such as BaseDtoMergePatchUpdate for BaseDto, so OpenAPI no longer reports duplicate-type-name when the original is also emitted.

Since the base model goes through the merge patch mutator, an inherited
`@key` property becomes optional in the patched base but keeps `@key`.
Replaying it reports `no-optional-key`, so for
`model Base { @key id: string; }` and `model Widget extends Base {}`,
`MergePatchUpdate<Widget>` failed to compile. On main it compiled
because the base was not patched. An inherited `@madeRequired` property
failed the same way with `made-required-optional`, and an inherited
`@madeOptional` property stayed required in the patch schema of the
versions before it was made optional.

A property clone that the transform makes optional now leaves out
`@key`, `@madeOptional` and `@madeRequired`, since a patch property is
optional in every version. The source keeps them, and so do clones that
stay required, such as the properties of an element in a replace only
array. The same rule covers a model's own properties, which already hit
these problems on main.
@Om-singhaI

Copy link
Copy Markdown
Contributor Author

This branch has not been deployed

No deployments
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 lib:http

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: MergePatchUpdate keeps allOf on the unpatched base, so an empty merge patch is rejected

2 participants