Repository: microsoft/typespec
Pull request #7230: Make patch not apply optionality transform by default
Author: login: timotheeguerin
Labels: name: lib:http
Created: 2025-05-05T21:50:23Z | Merged: 2025-05-06T17:15:17Z



Diff:
diff --git a/.chronus/changes/patch-implicit-optionality-false-2025-4-5-21-50-59.md b/.chronus/changes/patch-implicit-optionality-false-2025-4-5-21-50-59.md
new file mode 100644
index 00000000000..37c1946bd2b
--- /dev/null
+++ b/.chronus/changes/patch-implicit-optionality-false-2025-4-5-21-50-59.md
@@ -0,0 +1,24 @@
+---
+# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
+changeKind: breaking
+packages:
+  - "@typespec/http"
+---
+
+Changed `@patch` so that it does not apply the "implicit optionality" transform by default anymore.
+
+```diff lang=tsp
+@patch op update(@body pet: Pet): void;
+```
+
+To use JSON Merge-Patch to update resources, replace the body property with an instance of `MergePatchUpdate` as follows:
+
+```tsp
+@patch op update(@body pet: MergePatchUpdate<Pet>): void;
+```
+
+Or, keep the old behavior by explicitly enabling `implicitOptionality` in the `@patch` options:
+
+```tsp
+@patch(#{ implicitOptionality: true }) op update(@body pet: Pet): void;
+```
diff --git a/.chronus/changes/patch-implicit-optionality-false-2025-4-5-22-29-52.md b/.chronus/changes/patch-implicit-optionality-false-2025-4-5-22-29-52.md
new file mode 100644
index 00000000000..c09aecdd357
--- /dev/null
+++ b/.chronus/changes/patch-implicit-optionality-false-2025-4-5-22-29-52.md
@@ -0,0 +1,9 @@
+---
+# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
+changeKind: fix
+packages:
+  - "@typespec/rest"
+  - "@typespec/http-specs"
+---
+
+Handle new behavior of `@patch` keeping old behavior
diff --git a/packages/http-server-csharp/test/generation.test.ts b/packages/http-server-csharp/test/generation.test.ts
index 06c8b7b5da6..949f5d91e20 100644
--- a/packages/http-server-csharp/test/generation.test.ts
+++ b/packages/http-server-csharp/test/generation.test.ts
@@ -559,7 +559,7 @@ it("generates standard scalar array for uniqueItems properties", async () => {
         arrUniqueColors: Array<string>;
       }
         
-     @patch @route("/Foo") op update(...Foo): Foo;
+     @patch(#{implicitOptionality: true}) @route("/Foo") op update(...Foo): Foo;
 
       `,
     [
@@ -607,7 +607,7 @@ it("generates standard array properties", async () => {
         arrColors: Array<string>;
       }
         
-     @patch @route("/Foo") op update(...Foo): Foo[];
+     @patch(#{implicitOptionality: true})@route("/Foo") op update(...Foo): Foo[];
 
       `,
     [
@@ -639,7 +639,7 @@ it("generates bytes array properties", async () => {
         arrSBytes: int8[];
       }
         
-     @patch @route("/Foo") op update(...Foo): int8[];
+     @patch(#{implicitOptionality: true})@route("/Foo") op update(...Foo): int8[];
 
       `,
     [
diff --git a/packages/http-specs/specs/type/model/visibility/main.tsp b/packages/http-specs/specs/type/model/visibility/main.tsp
index 7a666f87de5..80be39ab019 100644
--- a/packages/http-specs/specs/type/model/visibility/main.tsp
+++ b/packages/http-specs/specs/type/model/visibility/main.tsp
@@ -98,7 +98,7 @@ op putModel(@body input: VisibilityModel): void;
   }
   ```
   """)
-@patch
+@patch(#{ implicitOptionality: true })
 op patchModel(@body input: VisibilityModel): void;
 
 @scenario
diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts
index b67083b9e39..e413a4160ad 100644
--- a/packages/http/src/lib.ts
+++ b/packages/http/src/lib.ts
@@ -195,6 +195,12 @@ export const $lib = createTypeSpecLibrary({
         default: paramMessage`The 'contents' property of the file model must be a scalar type that extends 'string' or 'bytes'. Found '${"type"}'.`,
       },
     },
+    "patch-implicit-optional": {
+      severity: "warning",
+      messages: {
+        default: `Patch operation stopped applying an implicit optional transform to the body in 1.0.0. Use @patch(#{implicitOptional: false}) to restore the old behavior.`,
+      },
+    },
     "merge-patch-contains-null": {
       severity: "error",
       messages: {
diff --git a/packages/http/src/metadata.ts b/packages/http/src/metadata.ts
index 7e8020d5c6b..a43feffa180 100644
--- a/packages/http/src/metadata.ts
+++ b/packages/http/src/metadata.ts
@@ -34,7 +34,6 @@ import { HttpVerb, OperationParameterOptions } from "./types.js";
 // Used in @link JsDoc tag.
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import type { PatchOptions } from "../generated-defs/TypeSpec.Http.js";
-import { isMergePatchBody } from "./experimental/merge-patch/internal.js";
 import { includeInapplicableMetadataInPayload } from "./private.decorators.js";
 
 /**
@@ -381,15 +380,12 @@ export function resolveRequestVisibility(
   // If the verb is PATCH, then we need to add the patch flag to the visibility in order for
   // later processes to properly apply it.
   if (verb === "patch") {
-    const patchOptionality = isMergePatchBody(program, operation.parameters)
-      ? false
-      : (getPatchOptions(program, operation)?.implicitOptionality ?? true);
+    const implicitOptionality = getPatchOptions(program, operation)?.implicitOptionality;
 
-    if (patchOptionality) {
+    if (implicitOptionality) {
       visibility |= Visibility.Patch;
     }
   }
-
   return visibility;
 }
 
diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts
index e98bcd15bfa..4facbb25561 100644
--- a/packages/http/src/parameters.ts
+++ b/packages/http/src/parameters.ts
@@ -5,7 +5,9 @@ import {
   Operation,
   Program,
 } from "@typespec/compiler";
-import { getOperationVerb, getPathOptions } from "./decorators.js";
+import { getOperationVerb, getPatchOptions, getPathOptions } from "./decorators.js";
+import { isMergePatchBody } from "./experimental/merge-patch/internal.js";
+import { createDiagnostic } from "./lib.js";
 import { resolveRequestVisibility } from "./metadata.js";
 import { HttpPayloadDisposition, resolveHttpPayload } from "./payload.js";
 import {
@@ -114,6 +116,22 @@ function getOperationParametersForVerb(
       },
     }),
   );
+  const implicitOptionality = getPatchOptions(program, operation)?.implicitOptionality;
+  // TODO: remove in 6month after 1.0.0. (November 2025)
+  if (
+    verb === "patch" &&
+    resolvedBody &&
+    implicitOptionality === undefined &&
+    !isMergePatchBody(program, resolvedBody?.type) &&
+    !resolvedBody.contentTypes.includes("application/merge-patch+json") // Above statement doesn't detect Spread merge patch
+  ) {
+    diagnostics.add(
+      createDiagnostic({
+        code: "patch-implicit-optional",
+        target: operation,
+      }),
+    );
+  }
 
   for (const item of metadata) {
     switch (item.kind) {
diff --git a/packages/http/test/merge-patch.test.ts b/packages/http/test/merge-patch.test.ts
index 420d7b20360..646e182c405 100644
--- a/packages/http/test/merge-patch.test.ts
+++ b/packages/http/test/merge-patch.test.ts
@@ -174,6 +174,7 @@ describe("http operation support", () => {
         name: string;
         description?: string;
       }
+        
       @patch op update(@header("Content-type") contentType: "application/json", @body body: MergePatchUpdate<Foo>): void;`);
     expectDiagnostics(diag, {
       code: "@typespec/http/merge-patch-content-type",
@@ -231,6 +232,7 @@ describe("http operation support", () => {
         name: string;
         description?: string;
       }
+      #suppress "@typespec/http/patch-implicit-optional" "For test only ignore correct merge patch"
       @patch op update(@header("Content-type") contentType: "application/json", ...MergePatchUpdate<Foo>): void;`);
     expectDiagnostics(diag, {
       code: "@typespec/http/merge-patch-content-type",
diff --git a/packages/openapi3/test/metadata.test.ts b/packages/openapi3/test/metadata.test.ts
index 25aeb5d839b..4ab93228386 100644
--- a/packages/openapi3/test/metadata.test.ts
+++ b/packages/openapi3/test/metadata.test.ts
@@ -91,7 +91,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
     });
   });
 
-  it("will expose create visibility properties on PATCH model using @requestVisibility", async () => {
+  it("will expose create visibility properties on PATCH model using (legacy implicitOptionality)", async () => {
     const res = await openApiFor(`
       model M {
         @visibility(Lifecycle.Read) r: string;
@@ -99,7 +99,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
         @visibility(Lifecycle.Read, Lifecycle.Update, Lifecycle.Create) ruc?: string;
       }
       @parameterVisibility(Lifecycle.Create, Lifecycle.Update)
-      @route("/") @patch op createOrUpdate(...M): M; 
+      @route("/") @patch(#{implicitOptionality: true}) op createOrUpdate(...M): M; 
     `);
 
     const response = res.paths["/"].patch.responses["200"].content["application/json"].schema;
@@ -155,7 +155,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
     });
   });
 
-  it("ensures properties are required for array updates", async () => {
+  it("ensures properties are required for array updates (legacy implicitOptionality)", async () => {
     const res = await openApiFor(`
       model Person {
         @visibility(Lifecycle.Read) id: string;
@@ -176,7 +176,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
         person: Person;
         relationship: string;
       }
-      @route("/") @patch op update(...Person): Person; 
+      @route("/") @patch(#{implicitOptionality: true}) op update(...Person): Person; 
     `);
 
     const response = res.paths["/"].patch.responses["200"].content["application/json"].schema;
@@ -206,7 +206,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
     });
   });
 
-  it("can make properties optional", async () => {
+  it("can make properties optional (legacy implicitOptionality)", async () => {
     const res = await openApiFor(`
       model Widget { 
         name: string; 
@@ -217,7 +217,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
         weight: float64;
       }
       @post op create(...Widget): void;
-      @patch op update(...Widget): void;
+      @patch(#{implicitOptionality: true}) op update(...Widget): void;
   `);
 
     const requestSchema = res.paths["/"].patch.requestBody.content["application/json"].schema;
@@ -358,7 +358,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
       @get get(...M): M;
       @post create(...M): M;
       @put createOrUpdate(...M): M;
-      @patch update(...M): M;
+      @patch(#{implicitOptionality: true}) update(...M): M;
       @delete delete(...M): void; 
     }
 
@@ -367,7 +367,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
       @get get(...D): D;
       @post create(...D): D;
       @put createOrUpdate(...D): D;
-      @patch update(...D): D;
+      @patch(#{implicitOptionality: true}) update(...D): D;
       @delete delete(...D): void; 
     }
   
@@ -376,7 +376,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
       @get op get(id: string): R;
       @post op create(...R): R;
       @put op createOrUpdate(...R): R;
-      @patch op update(...R): R;
+      @patch(#{implicitOptionality: true}) op update(...R): R;
       @delete op delete(...D): void; 
     }
 
@@ -384,7 +384,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
       @get op get(id: string): U;
       @post op create(...U): U;
       @put op createOrUpdate(...U): U;
-      @patch op update(...U): U;
+      @patch(#{implicitOptionality: true}) op update(...U): U;
       @delete op delete(...U): void;
     }
     `,
@@ -1080,7 +1080,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
     });
   });
 
-  it("don't create multiple scalars with different visibility if they are the same", async () => {
+  it("don't create multiple scalars with different visibility if they are the same (legacy implicitOptionality)", async () => {
     const res = await openApiFor(`
       scalar uuid extends string;
 
@@ -1088,7 +1088,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
         id: uuid;
       }
       
-      @patch op test(...Bar): Bar;
+      @patch(#{implicitOptionality: true}) op test(...Bar): Bar;
     `);
 
     deepStrictEqual(Object.keys(res.components.schemas), ["Bar", "BarUpdate", "uuid"]);
@@ -1103,7 +1103,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
         id: string;
       }
       
-      @patch op test(bar: Bar): void;
+      @patch(#{implicitOptionality: true}) op test(bar: Bar): void;
 
       model Foo {
         bar: Bar;
diff --git a/packages/playground-website/samples/http.tsp b/packages/playground-website/samples/http.tsp
index 03ef5cc3c4b..82fe8a0246f 100644
--- a/packages/playground-website/samples/http.tsp
+++ b/packages/playground-website/samples/http.tsp
@@ -5,8 +5,7 @@ using Http;
 namespace DemoService;
 
 model Widget {
-  @visibility(Lifecycle.Read, Lifecycle.Update)
-  @path
+  @visibility(Lifecycle.Read)
   id: string;
 
   weight: int32;
@@ -22,10 +21,20 @@ model Error {
 @route("/widgets")
 @tag("Widgets")
 interface Widgets {
+  /** List widgets */
   @get list(): Widget[] | Error;
-  @get read(@path id: string): Widget | Error;
-  @post create(...Widget): Widget | Error;
-  @patch update(...Widget): Widget | Error;
-  @delete delete(@path id: string): void | Error;
-  @route("{id}/analyze") @post analyze(@path id: string): string | Error;
+
+  /** Read widgets */
+  @get read(@path id: Widget.id): Widget | Error;
+
+  /** Create a widget */
+  @post create(@body widget: Widget): Widget | Error;
+
+  /** Update a widget */
+  @patch update(@path id: Widget.id, @body widget: MergePatchUpdate<Widget>): Widget | Error;
+
+  /** Delete a widget */
+  @delete delete(@path id: Widget.id): void | Error;
+  /** Analyze a widget */
+  @route("{id}/analyze") @post analyze(@path id: Widget.id): string | Error;
 }
diff --git a/packages/rest/lib/resource.tsp b/packages/rest/lib/resource.tsp
index 8baef463b80..4031be092f5 100644
--- a/packages/rest/lib/resource.tsp
+++ b/packages/rest/lib/resource.tsp
@@ -132,6 +132,7 @@ interface ResourceCreateOrUpdate<Resource extends {}, Error> {
   @autoRoute
   @doc("Creates or update an instance of the resource.")
   @createsOrUpdatesResource(Resource)
+  @patch(#{ implicitOptionality: true }) // for legacy behavior
   createOrUpdate(
     ...ResourceParameters<Resource>,
     @bodyRoot resource: ResourceCreateOrUpdateModel<Resource>,
@@ -187,6 +188,7 @@ interface ResourceUpdate<Resource extends {}, Error> {
   @autoRoute
   @doc("Updates an existing instance of the resource.")
   @updatesResource(Resource)
+  @patch(#{ implicitOptionality: true }) // for legacy behavior
   update(
     ...ResourceParameters<Resource>,
     @bodyRoot properties: ResourceCreateOrUpdateModel<Resource>,
@@ -337,6 +339,7 @@ interface SingletonResourceUpdate<Singleton extends {}, Resource extends {}, Err
   @doc("Updates the singleton resource.")
   @segmentOf(Singleton)
   @updatesResource(Singleton)
+  @patch(#{ implicitOptionality: true }) // for legacy behavior
   update(
     ...ResourceParameters<Resource>,
 
@@ -395,6 +398,7 @@ interface ExtensionResourceCreateOrUpdate<Extension extends {}, Resource extends
   @autoRoute
   @doc("Creates or update an instance of the extension resource.")
   @createsOrUpdatesResource(Extension)
+  @patch(#{ implicitOptionality: true }) // for legacy behavior
   createOrUpdate(
     ...ResourceParameters<Resource>,
     ...ResourceParameters<Extension>,
@@ -442,6 +446,7 @@ interface ExtensionResourceUpdate<Extension extends {}, Resource extends {}, Err
   @autoRoute
   @doc("Updates an existing instance of the extension resource.")
   @updatesResource(Extension)
+  @patch(#{ implicitOptionality: true }) // for legacy behavior
   update(
     ...ResourceParameters<Resource>,
     ...ResourceParameters<Extension>,
diff --git a/packages/samples/specs/init/main.tsp b/packages/samples/specs/init/main.tsp
index e78d2653a01..82fe8a0246f 100644
--- a/packages/samples/specs/init/main.tsp
+++ b/packages/samples/specs/init/main.tsp
@@ -5,8 +5,7 @@ using Http;
 namespace DemoService;
 
 model Widget {
-  @visibility(Lifecycle.Read, Lifecycle.Update)
-  @path
+  @visibility(Lifecycle.Read)
   id: string;
 
   weight: int32;
@@ -24,15 +23,18 @@ model Error {
 interface Widgets {
   /** List widgets */
   @get list(): Widget[] | Error;
+
   /** Read widgets */
-  @get read(@path id: string): Widget | Error;
+  @get read(@path id: Widget.id): Widget | Error;
+
   /** Create a widget */
-  @post create(...Widget): Widget | Error;
+  @post create(@body widget: Widget): Widget | Error;
+
   /** Update a widget */
-  @patch update(...Widget): Widget | Error;
-  /** Delete a widget */
-  @delete delete(@path id: string): void | Error;
+  @patch update(@path id: Widget.id, @body widget: MergePatchUpdate<Widget>): Widget | Error;
 
+  /** Delete a widget */
+  @delete delete(@path id: Widget.id): void | Error;
   /** Analyze a widget */
-  @route("{id}/analyze") @post analyze(@path id: string): string | Error;
+  @route("{id}/analyze") @post analyze(@path id: Widget.id): string | Error;
 }
diff --git a/packages/samples/specs/todoApp/main.tsp b/packages/samples/specs/todoApp/main.tsp
index cc381efbf9e..cb4321b2127 100644
--- a/packages/samples/specs/todoApp/main.tsp
+++ b/packages/samples/specs/todoApp/main.tsp
@@ -260,11 +260,7 @@ namespace TodoItems {
   ): WithStandardErrors<TodoItem | InvalidTodoItem>;
 
   @get op get(@path id: TodoItem.id): TodoItem | NotFoundErrorResponse;
-  @patch op update(
-    @header contentType: "application/merge-patch+json",
-    @path id: TodoItem.id,
-    @body patch: TodoItemPatch,
-  ): TodoItem;
+  @patch op update(@path id: TodoItem.id, @body patch: MergePatchUpdate<TodoItem>): TodoItem;
   @delete op delete(
     @path id: TodoItem.id,
   ): WithStandardErrors<NoContentResponse | NotFoundErrorResponse>;
diff --git a/packages/samples/specs/visibility/visibility.tsp b/packages/samples/specs/visibility/visibility.tsp
index 59d55be8871..1ae77106f48 100644
--- a/packages/samples/specs/visibility/visibility.tsp
+++ b/packages/samples/specs/visibility/visibility.tsp
@@ -72,5 +72,5 @@ namespace Hello {
   op list(): ListResult<Person>;
 
   @TypeSpec.Rest.updatesResource(Person)
-  op updateRest(@body person: Person): ReadablePerson;
+  op updateRest(@body person: MergePatchUpdate<Person>): ReadablePerson;
 }
diff --git a/packages/samples/test/output/init/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/init/@typespec/openapi3/openapi.yaml
index 1ddeb430960..033850055e5 100644
--- a/packages/samples/test/output/init/@typespec/openapi3/openapi.yaml
+++ b/packages/samples/test/output/init/@typespec/openapi3/openapi.yaml
@@ -51,7 +51,7 @@ paths:
         content:
           application/json:
             schema:
-              $ref: '#/components/schemas/WidgetCreate'
+              $ref: '#/components/schemas/Widget'
   /widgets/{id}:
     get:
       operationId: Widgets_read
@@ -62,6 +62,7 @@ paths:
           required: true
           schema:
             type: string
+            readOnly: true
       responses:
         '200':
           description: The request has succeeded.
@@ -81,7 +82,12 @@ paths:
       operationId: Widgets_update
       description: Update a widget
       parameters:
-        - $ref: '#/components/parameters/Widget.id'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: string
+            readOnly: true
       responses:
         '200':
           description: The request has succeeded.
@@ -100,9 +106,9 @@ paths:
       requestBody:
         required: true
         content:
-          application/json:
+          application/merge-patch+json:
             schema:
-              $ref: '#/components/schemas/WidgetUpdate'
+              $ref: '#/components/schemas/WidgetMergePatchUpdate'
     delete:
       operationId: Widgets_delete
       description: Delete a widget
@@ -112,6 +118,7 @@ paths:
           required: true
           schema:
             type: string
+            readOnly: true
       responses:
         '204':
           description: 'There is no content to send for this request, but the headers may be useful. '
@@ -133,6 +140,7 @@ paths:
           required: true
           schema:
             type: string
+            readOnly: true
       responses:
         '200':
           description: The request has succeeded.
@@ -149,13 +157,6 @@ paths:
       tags:
         - Widgets
 components:
-  parameters:
-    Widget.id:
-      name: id
-      in: path
-      required: true
-      schema:
-        type: string
   schemas:
     Error:
       type: object
@@ -177,6 +178,7 @@ components:
       properties:
         id:
           type: string
+          readOnly: true
         weight:
           type: integer
           format: int32
@@ -185,21 +187,7 @@ components:
           enum:
             - red
             - blue
-    WidgetCreate:
-      type: object
-      required:
-        - weight
-        - color
-      properties:
-        weight:
-          type: integer
-          format: int32
-        color:
-          type: string
-          enum:
-            - red
-            - blue
-    WidgetUpdate:
+    WidgetMergePatchUpdate:
       type: object
       properties:
         weight:
@@ -210,3 +198,14 @@ components:
           enum:
             - red
             - blue
+      description: |-
+        Create a MergePatch Request body for updating the given resource Model.
+        The MergePatch request created by this template provides a TypeSpec description of a
+        JSON MergePatch request that can successfully update the given resource.
+        The transformation follows the definition of JSON MergePatch requests in
+        rfc 7396: https://www.rfc-editor.org/rfc/rfc7396,
+        applying the merge-patch transform recursively to keyed types in the resource Model.
+
+        Using this template in a PATCH request body overrides the `implicitOptionality`
+        setting for PATCH operations and sets `application/merge-patch+json` as the request
+        content-type.
diff --git a/packages/samples/test/output/todoApp/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/todoApp/@typespec/openapi3/openapi.yaml
index d806506d336..4bd41459c2d 100644
--- a/packages/samples/test/output/todoApp/@typespec/openapi3/openapi.yaml
+++ b/packages/samples/test/output/todoApp/@typespec/openapi3/openapi.yaml
@@ -126,7 +126,7 @@ paths:
         content:
           application/merge-patch+json:
             schema:
-              $ref: '#/components/schemas/TodoItems.TodoItemPatch'
+              $ref: '#/components/schemas/TodoItemMergePatchUpdate'
     delete:
       operationId: TodoItems_delete
       parameters:
@@ -459,6 +459,45 @@ components:
           $ref: '#/components/schemas/TodoLabels'
         _dummy:
           type: string
+    TodoItemMergePatchUpdate:
+      type: object
+      properties:
+        title:
+          type: string
+          maxLength: 255
+          description: The item's title
+        assignedTo:
+          type: integer
+          format: int64
+          description: User that the todo is assigned to
+          readOnly: true
+          nullable: true
+        description:
+          type: string
+          nullable: true
+          description: A longer description of the todo item in markdown format
+        status:
+          type: string
+          enum:
+            - NotStarted
+            - InProgress
+            - Completed
+          description: The status of the todo item
+        labels:
+          allOf:
+            - $ref: '#/components/schemas/TodoLabelsMergePatchUpdateOrCreate'
+          nullable: true
+      description: |-
+        Create a MergePatch Request body for updating the given resource Model.
+        The MergePatch request created by this template provides a TypeSpec description of a
+        JSON MergePatch request that can successfully update the given resource.
+        The transformation follows the definition of JSON MergePatch requests in
+        rfc 7396: https://www.rfc-editor.org/rfc/rfc7396,
+        applying the merge-patch transform recursively to keyed types in the resource Model.
+
+        Using this template in a PATCH request body overrides the `implicitOptionality`
+        setting for PATCH operations and sets `application/merge-patch+json` as the request
+        content-type.
     TodoItems.InvalidTodoItem:
       type: object
       allOf:
@@ -544,6 +583,25 @@ components:
         color:
           type: string
           pattern: ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
+    TodoLabelRecordMergePatchUpdateOrCreate:
+      type: object
+      properties:
+        name:
+          type: string
+        color:
+          type: string
+          nullable: true
+          pattern: ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
+    TodoLabelRecordMergePatchUpdateReplaceOnly:
+      type: object
+      required:
+        - name
+      properties:
+        name:
+          type: string
+        color:
+          type: string
+          pattern: ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
     TodoLabels:
       anyOf:
         - type: string
@@ -554,6 +612,16 @@ components:
         - type: array
           items:
             $ref: '#/components/schemas/TodoLabelRecord'
+    TodoLabelsMergePatchUpdateOrCreate:
+      anyOf:
+        - type: string
+        - type: array
+          items:
+            type: string
+        - $ref: '#/components/schemas/TodoLabelRecordMergePatchUpdateOrCreate'
+        - type: array
+          items:
+            $ref: '#/components/schemas/TodoLabelRecordMergePatchUpdateReplaceOnly'
     User:
       type: object
       required:
diff --git a/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml
index 9161085fe33..032224d6e47 100644
--- a/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml
+++ b/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml
@@ -67,9 +67,9 @@ paths:
       requestBody:
         required: true
         content:
-          application/json:
+          application/merge-patch+json:
             schema:
-              $ref: '#/components/schemas/PersonUpdate'
+              $ref: '#/components/schemas/PersonMergePatchUpdate'
   /hello/{id}:
     get:
       operationId: Hello_read
@@ -174,6 +174,46 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/PersonRelativeCreateOrUpdateItem'
+    PersonMergePatchUpdate:
+      type: object
+      properties:
+        name:
+          type: string
+        other:
+          type: string
+        relatives:
+          type: array
+          items:
+            $ref: '#/components/schemas/PersonRelativeMergePatchUpdateReplaceOnly'
+      description: |-
+        Create a MergePatch Request body for updating the given resource Model.
+        The MergePatch request created by this template provides a TypeSpec description of a
+        JSON MergePatch request that can successfully update the given resource.
+        The transformation follows the definition of JSON MergePatch requests in
+        rfc 7396: https://www.rfc-editor.org/rfc/rfc7396,
+        applying the merge-patch transform recursively to keyed types in the resource Model.
+
+        Using this template in a PATCH request body overrides the `implicitOptionality`
+        setting for PATCH operations and sets `application/merge-patch+json` as the request
+        content-type.
+    PersonMergePatchUpdateReplaceOnly:
+      type: object
+      required:
+        - secret
+        - name
+        - test
+        - relatives
+      properties:
+        secret:
+          type: string
+        name:
+          type: string
+        test:
+          type: string
+        relatives:
+          type: array
+          items:
+            $ref: '#/components/schemas/PersonRelativeMergePatchUpdateReplaceOnly'
     PersonReadOrCreateOrUpdateOrDeleteOrQueryItem:
       type: object
       required:
@@ -229,52 +269,26 @@ components:
           $ref: '#/components/schemas/PersonCreateOrUpdateItem'
         relationship:
           type: string
-    PersonRelativeReadOrCreateOrUpdateOrDeleteOrQueryItem:
+    PersonRelativeMergePatchUpdateReplaceOnly:
       type: object
       required:
         - person
         - relationship
       properties:
         person:
-          $ref: '#/components/schemas/PersonReadOrCreateOrUpdateOrDeleteOrQueryItem'
+          $ref: '#/components/schemas/PersonMergePatchUpdateReplaceOnly'
         relationship:
           type: string
-    PersonRelativeUpdateItem:
+    PersonRelativeReadOrCreateOrUpdateOrDeleteOrQueryItem:
       type: object
       required:
         - person
         - relationship
       properties:
         person:
-          $ref: '#/components/schemas/PersonUpdateItem'
+          $ref: '#/components/schemas/PersonReadOrCreateOrUpdateOrDeleteOrQueryItem'
         relationship:
           type: string
-    PersonUpdate:
-      type: object
-      properties:
-        name:
-          type: string
-        other:
-          type: string
-        relatives:
-          type: array
-          items:
-            $ref: '#/components/schemas/PersonRelativeUpdateItem'
-    PersonUpdateItem:
-      type: object
-      required:
-        - name
-        - other
-        - relatives
-      properties:
-        name:
-          type: string
-        other:
-          type: string
-        relatives:
-          type: array
-          items:
-            $ref: '#/components/schemas/PersonRelativeUpdateItem'
     ReadablePerson:
       type: object
       required:
diff --git a/vitest.workspace.ts b/vitest.workspace.ts
index 3e6206bec6d..987ec23df94 100644
--- a/vitest.workspace.ts
+++ b/vitest.workspace.ts
@@ -18,7 +18,7 @@ export const defaultTypeSpecVitestConfig = defineConfig({
     outputFile: {
       junit: "./test-results.xml",
     },
-    exclude: ["node_modules", "dist/**/*.test.*"],
+    exclude: ["**/node_modules", "dist/**/*.test.*", "temp/**/*.test.*"],
   },
   server: {
     watch: {


Reviews:
user: login: witemple-msft
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T00:27:10Z
user: login: timotheeguerin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T01:00:13Z
user: login: witemple-msft
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-06T16:48:34Z

Review comments:
diff_hunk: @@ -0,0 +1,24 @@
+---
+# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
+changeKind: breaking
+packages:
+  - "@typespec/http"
+---
+
+Make patch not apply optionality transform by default anymore. 
+
+```diff lang=tsp
+@patch op update(@body pet: Pet): void;
+```
+
+Replace with either the following to respect json merge patch:
+
+```tsp
+@patch op update(@body pet: MergePatchUpdate<Pet>): void;
+```
+
+or keep the old behavior
+
+```tsp
+@patch(#{ implicitOptional: true }) op update(@body pet: Pet): void;
path: .chronus/changes/patch-implicit-optionality-false-2025-4-5-21-50-59.md
user: login: witemple-msft
body: ```suggestion
@patch(#{ implicitOptionality: true }) op update(@body pet: Pet): void;
```
created_at: 2025-05-06T00:22:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,24 @@
+---
+# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
+changeKind: breaking
+packages:
+  - "@typespec/http"
+---
+
+Make patch not apply optionality transform by default anymore. 
path: .chronus/changes/patch-implicit-optionality-false-2025-4-5-21-50-59.md
user: login: witemple-msft
body: ```suggestion
Changed `@patch` so that it does not apply the "implicit optionality" transform by default anymore.
```
created_at: 2025-05-06T00:23:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,24 @@
+---
+# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
+changeKind: breaking
+packages:
+  - "@typespec/http"
+---
+
+Make patch not apply optionality transform by default anymore. 
+
+```diff lang=tsp
+@patch op update(@body pet: Pet): void;
+```
+
+Replace with either the following to respect json merge patch:
path: .chronus/changes/patch-implicit-optionality-false-2025-4-5-21-50-59.md
user: login: witemple-msft
body: ```suggestion
To use JSON Merge-Patch to update resources, replace the body property with an instance of `MergePatchUpdate` as follows:
```
created_at: 2025-05-06T00:24:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,24 @@
+---
+# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
+changeKind: breaking
+packages:
+  - "@typespec/http"
+---
+
+Make patch not apply optionality transform by default anymore. 
+
+```diff lang=tsp
+@patch op update(@body pet: Pet): void;
+```
+
+Replace with either the following to respect json merge patch:
+
+```tsp
+@patch op update(@body pet: MergePatchUpdate<Pet>): void;
+```
+
+or keep the old behavior
path: .chronus/changes/patch-implicit-optionality-false-2025-4-5-21-50-59.md
user: login: witemple-msft
body: ```suggestion
Or, keep the old behavior by explicitly enabling `implicitOptionality` in the `@patch` options:
```
created_at: 2025-05-06T00:25:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,8 @@
+---
+# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
+changeKind: fix
+packages:
+  - "@typespec/rest"
path: .chronus/changes/patch-implicit-optionality-false-2025-4-5-22-29-52.md
user: login: timotheeguerin
body: ```suggestion
  - "@typespec/rest"
  - "@typespec/http-specs"
```
created_at: 2025-05-06T01:00:13Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-05T21:52:57Z
body: <!--chronus-github-change-commenter-->
All changed packages have been documented.
 - :white_check_mark: `@typespec/http-specs`
 - :white_check_mark: `@typespec/http`
 - :white_check_mark: `@typespec/rest`

<details>
<summary>Show changes</summary>

### `@typespec/http` - _breaking_ [✏️](https://github.com/timotheeguerin/typespec/edit/patch-implicit-optionality-false/.chronus/changes/patch-implicit-optionality-false-2025-4-5-21-50-59.md?pr=/microsoft/typespec/pull/7230)
> Changed `@patch` so that it does not apply the "implicit optionality" transform by default anymore.,> ,> ```diff lang=tsp,> @patch op update(@body pet: Pet): void;,> ```,> ,> To use JSON Merge-Patch to update resources, replace the body property with an instance of `MergePatchUpdate` as follows:,> ,> ```tsp,> @patch op update(@body pet: MergePatchUpdate<Pet>): void;,> ```,> ,> Or, keep the old behavior by explicitly enabling `implicitOptionality` in the `@patch` options:,> ,> ```tsp,> @patch(#{ implicitOptionality: true }) op update(@body pet: Pet): void;,> ```
### `@typespec/rest` - _fix_ [✏️](https://github.com/timotheeguerin/typespec/edit/patch-implicit-optionality-false/.chronus/changes/patch-implicit-optionality-false-2025-4-5-22-29-52.md?pr=/microsoft/typespec/pull/7230)
> Handle new behavior of `@patch` keeping old behavior
### `@typespec/http-specs` - _fix_ [✏️](https://github.com/timotheeguerin/typespec/edit/patch-implicit-optionality-false/.chronus/changes/patch-implicit-optionality-false-2025-4-5-22-29-52.md?pr=/microsoft/typespec/pull/7230)
> Handle new behavior of `@patch` keeping old behavior
</details>

author_association: CONTRIBUTOR
user: login: azure-sdk
created_at: 2025-05-05T22:11:00Z
body: <!-- _TSP_TRYIT_COMMENT_ -->
You can try these changes here

| [🛝 Playground]( https://cadlplayground.z22.web.core.windows.net/prs/7230/)|[🌐 Website](https://tspwebsitepr.z22.web.core.windows.net/prs/7230/)|[🛝 VSCode Extension]( https://artprodcus3.artifacts.visualstudio.com/A0fb41ef4-5012-48a9-bf39-4ee3de03ee35/590cfd2a-581c-4dcb-a12e-6568ce786175/_apis/artifact/cGlwZWxpbmVhcnRpZmFjdDovL2F6dXJlLXNkay9wcm9qZWN0SWQvNTkwY2ZkMmEtNTgxYy00ZGNiLWExMmUtNjU2OGNlNzg2MTc1L2J1aWxkSWQvNDgzMTAwMy9hcnRpZmFjdE5hbWUvdnNjb2RlLWV4dGVuc2lvbg2/content?format=zip) |
| ---|---|--- |
author_association: COLLABORATOR