diff --git a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
index 34fbf7612868d3db0591cd5d782770ce3f43dc6d..aebb2bb8fbeeaa9e3ce1186ac79e6ad11d7869c2 100644
--- a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
+++ b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
@@ -7,11 +7,13 @@
 use Drupal\Core\Field\FieldItemInterface;
 use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
+use Drupal\Core\TypedData\DataDefinitionInterface;
 use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
 use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
 use Drupal\jsonapi\ResourceType\ResourceType;
 use Drupal\serialization\Normalizer\CacheableNormalizerInterface;
 use Drupal\serialization\Normalizer\SerializedColumnNormalizerTrait;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
 use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
 
 /**
@@ -128,6 +130,46 @@ public function denormalize($data, $class, $format = NULL, array $context = []):
 
     $data_internal = [];
     if (!empty($property_definitions)) {
+      $writable_properties = array_keys(array_filter($property_definitions, function (DataDefinitionInterface $data_definition) : bool {
+        return !$data_definition->isReadOnly();
+      }));
+      $invalid_property_names = [];
+      foreach ($data as $property_name => $property_value) {
+        if (!isset($property_definitions[$property_name])) {
+          $alt = static::getAlternatives($property_name, $writable_properties);
+          $invalid_property_names[$property_name] = reset($alt);
+        }
+      }
+      if (!empty($invalid_property_names)) {
+        $suggestions = array_values(array_filter($invalid_property_names));
+        // Only use the "Did you mean"-style error message if there is a
+        // suggestion for every invalid property name.
+        if (count($suggestions) === count($invalid_property_names)) {
+          $format = count($invalid_property_names) === 1
+            ? "The property '%s' does not exist on the '%s' field of type '%s'. Did you mean '%s'?"
+            : "The properties '%s' do not exist on the '%s' field of type '%s'. Did you mean '%s'?";
+          throw new UnexpectedValueException(sprintf(
+            $format,
+            implode("', '", array_keys($invalid_property_names)),
+            $item_definition->getFieldDefinition()->getName(),
+            $item_definition->getFieldDefinition()->getType(),
+            implode("', '", $suggestions)
+          ));
+        }
+        else {
+          $format = count($invalid_property_names) === 1
+            ? "The property '%s' does not exist on the '%s' field of type '%s'. Writable properties are: '%s'."
+            : "The properties '%s' do not exist on the '%s' field of type '%s'. Writable properties are: '%s'.";
+          throw new UnexpectedValueException(sprintf(
+            $format,
+            implode("', '", array_keys($invalid_property_names)),
+            $item_definition->getFieldDefinition()->getName(),
+            $item_definition->getFieldDefinition()->getType(),
+            implode("', '", $writable_properties)
+          ));
+        }
+      }
+
       foreach ($data as $property_name => $property_value) {
         $property_value_class = $property_definitions[$property_name]->getClass();
         $data_internal[$property_name] = $denormalize_property($property_name, $property_value, $property_value_class, $format, $context);
@@ -140,6 +182,37 @@ public function denormalize($data, $class, $format = NULL, array $context = []):
     return $data_internal;
   }
 
+  /**
+   * Provides alternatives for a given array and key.
+   *
+   * @param string $search_key
+   *   The search key to get alternatives for.
+   * @param array $keys
+   *   The search space to search for alternatives in.
+   *
+   * @return string[]
+   *   An array of strings with suitable alternatives.
+   *
+   * @see \Drupal\Component\DependencyInjection\Container::getAlternatives()
+   */
+  private static function getAlternatives(string $search_key, array $keys) : array {
+    // $search_key is user input and could be longer than the 255 string length
+    // limit of levenshtein().
+    if (strlen($search_key) > 255) {
+      return [];
+    }
+
+    $alternatives = [];
+    foreach ($keys as $key) {
+      $lev = levenshtein($search_key, $key);
+      if ($lev <= strlen($search_key) / 3 || strpos($key, $search_key) !== FALSE) {
+        $alternatives[] = $key;
+      }
+    }
+
+    return $alternatives;
+  }
+
   /**
    * Gets a field item instance for use with SerializedColumnNormalizerTrait.
    *
diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
index f17fba164ba0a66fece4a34c8d45861405a3663a..f88fa41416d0e5d0842a6edc8ff7e5f8a593df9e 100644
--- a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
@@ -1393,4 +1393,99 @@ public function testFilteringEntitiesByEntityReferenceTargetId() {
     $this->assertSame('Article created by ' . $users[1]->uuid(), $document['data'][0]['attributes']['title']);
   }
 
+  /**
+   * Ensure PATCHing a non-existing field property results in a helpful error.
+   *
+   * @see https://www.drupal.org/project/drupal/issues/3127883
+   */
+  public function testPatchInvalidFieldPropertyFromIssue3127883() {
+    $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
+
+    // Set up data model.
+    $this->drupalCreateContentType(['type' => 'page']);
+    $this->rebuildAll();
+
+    // Create data.
+    $node = Node::create([
+      'title' => 'foo',
+      'type' => 'page',
+      'body' => [
+        'format' => 'plain_text',
+        'value' => 'Hello World',
+      ],
+    ]);
+    $node->save();
+
+    // Test.
+    $user = $this->drupalCreateUser(['bypass node access']);
+    $url = Url::fromUri('internal:/jsonapi/node/page/' . $node->uuid());
+    $request_options = [
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+      RequestOptions::AUTH => [$user->getAccountName(), $user->pass_raw],
+      RequestOptions::JSON => [
+        'data' => [
+          'type' => 'node--page',
+          'id' => $node->uuid(),
+          'attributes' => [
+            'title' => 'Updated title',
+            'body' => [
+              'value' => 'Hello World … still.',
+              // Intentional typo in the property name!
+              'form' => 'plain_text',
+              // Another intentional typo.
+              // cSpell:disable-next-line
+              'sumary' => 'Boring old "Hello World".',
+              // And finally, one that is completely absurd.
+              'foobarbaz' => '<script>alert("HI!");</script>',
+            ],
+          ],
+        ],
+      ],
+    ];
+    $response = $this->request('PATCH', $url, $request_options);
+
+    // Assert a helpful error response is present.
+    $data = Json::decode((string) $response->getBody());
+    $this->assertSame(422, $response->getStatusCode());
+    $this->assertNotNull($data);
+    // cSpell:disable-next-line
+    $this->assertSame("The properties 'form', 'sumary', 'foobarbaz' do not exist on the 'body' field of type 'text_with_summary'. Writable properties are: 'value', 'format', 'summary'.", $data['errors'][0]['detail']);
+
+    $request_options = [
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+      RequestOptions::AUTH => [$user->getAccountName(), $user->pass_raw],
+      RequestOptions::JSON => [
+        'data' => [
+          'type' => 'node--page',
+          'id' => $node->uuid(),
+          'attributes' => [
+            'title' => 'Updated title',
+            'body' => [
+              'value' => 'Hello World … still.',
+              // Intentional typo in the property name!
+              'form' => 'plain_text',
+              // Another intentional typo.
+              // cSpell:disable-next-line
+              'sumary' => 'Boring old "Hello World".',
+            ],
+          ],
+        ],
+      ],
+    ];
+    $response = $this->request('PATCH', $url, $request_options);
+
+    // Assert a helpful error response is present.
+    $data = Json::decode((string) $response->getBody());
+    $this->assertSame(422, $response->getStatusCode());
+    $this->assertNotNull($data);
+    // cSpell:disable-next-line
+    $this->assertSame("The properties 'form', 'sumary' do not exist on the 'body' field of type 'text_with_summary'. Did you mean 'format', 'summary'?", $data['errors'][0]['detail']);
+  }
+
 }