diff --git a/core/lib/Drupal/Core/Theme/Registry.php b/core/lib/Drupal/Core/Theme/Registry.php
index a0af702..b5e9902 100644
--- a/core/lib/Drupal/Core/Theme/Registry.php
+++ b/core/lib/Drupal/Core/Theme/Registry.php
@@ -343,6 +343,7 @@ protected function build() {
     // and preprocess functions comes first.
     foreach (array_reverse($this->theme->getBaseThemes()) as $base) {
       // If the base theme uses a theme engine, process its hooks.
+      /** @var \Drupal\Core\Theme\ActiveTheme $base */
       $base_path = $base->getPath();
       if ($this->theme->getEngine()) {
         $this->processExtension($cache, $this->theme->getEngine(), 'base_theme_engine', $base->getName(), $base_path);
@@ -366,12 +367,6 @@ protected function build() {
     $this->themeManager->alterForTheme($this->theme, 'theme_registry', $cache);
 
     // @todo Implement more reduction of the theme registry entry.
-    // Optimize the registry to not have empty arrays for functions.
-    foreach ($cache as $hook => $info) {
-      if (empty($info['preprocess functions'])) {
-        unset($cache[$hook]['preprocess functions']);
-      }
-    }
     $this->registry[$this->theme->getName()] = $cache;
 
     return $this->registry[$this->theme->getName()];
@@ -430,13 +425,6 @@ protected function build() {
   protected function processExtension(array &$cache, $name, $type, $theme, $path) {
     $result = [];
 
-    $hook_defaults = [
-      'variables' => TRUE,
-      'render element' => TRUE,
-      'pattern' => TRUE,
-      'base hook' => TRUE,
-    ];
-
     $module_list = array_keys($this->moduleHandler->getModuleList());
 
     // Invoke the hook_theme() implementation, preprocess what is returned, and
@@ -445,130 +433,31 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
     if (function_exists($function)) {
       $result = $function($cache, $type, $theme, $path);
       foreach ($result as $hook => $info) {
+        // If there was no string $hook provided retrieve it from the ThemeHook.
+        if ($info instanceof ThemeHook && is_int($hook)) {
+          $hook = $info->getName();
+        }
+
+        // @todo Remove support for legacy array-based theme hooks.
+        if (is_array($info)) {
+          $info = ThemeHook::createFromLegacy($hook, $info);
+        }
+
         // When a theme or engine overrides a module's theme function
         // $result[$hook] will only contain key/value pairs for information being
         // overridden.  Pull the rest of the information from what was defined by
         // an earlier hook.
+        $cached_info = isset($cache[$hook]) ? $cache[$hook] : NULL;
 
-        // Fill in the type and path of the module, theme, or engine that
+        // Fill in the name, type, and path of the module, theme, or engine that
         // implements this theme function.
-        $result[$hook]['type'] = $type;
-        $result[$hook]['theme path'] = $path;
+        $info->setProvider($name);
+        $info->setProviderType($type);
+        $info->setThemePath($path);
 
-        // If a theme hook has a base hook, mark its preprocess functions always
-        // incomplete in order to inherit the base hook's preprocess functions.
-        if (!empty($result[$hook]['base hook'])) {
-          $result[$hook]['incomplete preprocess functions'] = TRUE;
-        }
-
-        if (isset($cache[$hook]['includes'])) {
-          $result[$hook]['includes'] = $cache[$hook]['includes'];
-        }
-
-        // Load the includes, as they may contain preprocess functions.
-        if (isset($info['includes'])) {
-          foreach ($info['includes'] as $include_file) {
-            include_once $this->root . '/' . $include_file;
-          }
-        }
-
-        // If the theme implementation defines a file, then also use the path
-        // that it defined. Otherwise use the default path. This allows
-        // system.module to declare theme functions on behalf of core .include
-        // files.
-        if (isset($info['file'])) {
-          $include_file = isset($info['path']) ? $info['path'] : $path;
-          $include_file .= '/' . $info['file'];
-          include_once $this->root . '/' . $include_file;
-          $result[$hook]['includes'][] = $include_file;
-        }
-
-        // A template file is the default implementation for a theme hook, but
-        // if the theme hook specifies a function callback instead, check to
-        // ensure the function actually exists.
-        if (isset($info['function'])) {
-          if (!function_exists($info['function'])) {
-            throw new \BadFunctionCallException(sprintf(
-              'Theme hook "%s" refers to a theme function callback that does not exist: "%s"',
-              $hook,
-              $info['function']
-            ));
-          }
-        }
-        // Provide a default naming convention for 'template' based on the
-        // hook used. If the template does not exist, the theme engine used
-        // should throw an exception at runtime when attempting to include
-        // the template file.
-        elseif (!isset($info['template'])) {
-          $info['template'] = strtr($hook, '_', '-');
-          $result[$hook]['template'] = $info['template'];
-        }
-
-        // Prepend the current theming path when none is set. This is required
-        // for the default theme engine to know where the template lives.
-        if (isset($result[$hook]['template']) && !isset($info['path'])) {
-          $result[$hook]['path'] = $path . '/templates';
-        }
-
-        // If the default keys are not set, use the default values registered
-        // by the module.
-        if (isset($cache[$hook])) {
-          $result[$hook] += array_intersect_key($cache[$hook], $hook_defaults);
-        }
-
-        // Preprocess variables for all theming hooks, whether the hook is
-        // implemented as a template or as a function. Ensure they are arrays.
-        if (!isset($info['preprocess functions']) || !is_array($info['preprocess functions'])) {
-          $info['preprocess functions'] = [];
-          $prefixes = [];
-          if ($type == 'module') {
-            // Default variable preprocessor prefix.
-            $prefixes[] = 'template';
-            // Add all modules so they can intervene with their own variable
-            // preprocessors. This allows them to provide variable preprocessors
-            // even if they are not the owner of the current hook.
-            $prefixes = array_merge($prefixes, $module_list);
-          }
-          elseif ($type == 'theme_engine' || $type == 'base_theme_engine') {
-            // Theme engines get an extra set that come before the normally
-            // named variable preprocessors.
-            $prefixes[] = $name . '_engine';
-            // The theme engine registers on behalf of the theme using the
-            // theme's name.
-            $prefixes[] = $theme;
-          }
-          else {
-            // This applies when the theme manually registers their own variable
-            // preprocessors.
-            $prefixes[] = $name;
-          }
-          foreach ($prefixes as $prefix) {
-            // Only use non-hook-specific variable preprocessors for theming
-            // hooks implemented as templates. See the @defgroup themeable
-            // topic.
-            if (isset($info['template']) && function_exists($prefix . '_preprocess')) {
-              $info['preprocess functions'][] = $prefix . '_preprocess';
-            }
-            if (function_exists($prefix . '_preprocess_' . $hook)) {
-              $info['preprocess functions'][] = $prefix . '_preprocess_' . $hook;
-            }
-          }
-        }
-        // Check for the override flag and prevent the cached variable
-        // preprocessors from being used. This allows themes or theme engines
-        // to remove variable preprocessors set earlier in the registry build.
-        if (!empty($info['override preprocess functions'])) {
-          // Flag not needed inside the registry.
-          unset($result[$hook]['override preprocess functions']);
-        }
-        elseif (isset($cache[$hook]['preprocess functions']) && is_array($cache[$hook]['preprocess functions'])) {
-          $info['preprocess functions'] = array_merge($cache[$hook]['preprocess functions'], $info['preprocess functions']);
-        }
-        $result[$hook]['preprocess functions'] = $info['preprocess functions'];
+        // Process the theme hook.
+        $cache[$hook] = $info->process($this->root, $theme, $module_list, $cached_info);
       }
-
-      // Merge the newly created theme hooks into the existing cache.
-      $cache = $result + $cache;
     }
 
     // Let themes have variable preprocessors even if they didn't register a
@@ -577,17 +466,14 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
       foreach ($cache as $hook => $info) {
         // Check only if not registered by the theme or engine.
         if (empty($result[$hook])) {
-          if (!isset($info['preprocess functions'])) {
-            $cache[$hook]['preprocess functions'] = [];
-          }
           // Only use non-hook-specific variable preprocessors for theme hooks
           // implemented as templates. See the @defgroup themeable topic.
-          if (isset($info['template']) && function_exists($name . '_preprocess')) {
-            $cache[$hook]['preprocess functions'][] = $name . '_preprocess';
+          if ($info->getTemplate() && function_exists($name . '_preprocess')) {
+            $info->addPreprocessFunction($name . '_preprocess');
           }
           if (function_exists($name . '_preprocess_' . $hook)) {
-            $cache[$hook]['preprocess functions'][] = $name . '_preprocess_' . $hook;
-            $cache[$hook]['theme path'] = $path;
+            $info->addPreprocessFunction($name . '_preprocess_' . $hook);
+            $info->setThemePath($path);
           }
         }
       }
@@ -599,63 +485,71 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
    *
    * @param string $hook
    *   The name of the suggestion hook to complete.
-   * @param array $cache
+   * @param \Drupal\Core\Theme\ThemeHook[] $cache
    *   The theme registry, as documented in
    *   \Drupal\Core\Theme\Registry::processExtension().
    */
   protected function completeSuggestion($hook, array &$cache) {
     $previous_hook = $hook;
-    $incomplete_previous_hook = [];
+    $incomplete_previous_hook = NULL;
     // Continue looping if the candidate hook doesn't exist or if the candidate
     // hook has incomplete preprocess functions, and if the candidate hook is a
     // suggestion (has a double underscore).
-    while ((!isset($cache[$previous_hook]) || isset($cache[$previous_hook]['incomplete preprocess functions']))
+    while ((!isset($cache[$previous_hook]) || $cache[$previous_hook]->isIncomplete())
       && $pos = strrpos($previous_hook, '__')) {
       // Find the first existing candidate hook that has incomplete preprocess
       // functions.
-      if (isset($cache[$previous_hook]) && !$incomplete_previous_hook && isset($cache[$previous_hook]['incomplete preprocess functions'])) {
+      if (isset($cache[$previous_hook]) && !$incomplete_previous_hook && $cache[$previous_hook]->isIncomplete()) {
         $incomplete_previous_hook = $cache[$previous_hook];
-        unset($incomplete_previous_hook['incomplete preprocess functions']);
       }
       $previous_hook = substr($previous_hook, 0, $pos);
-      $this->mergePreprocessFunctions($hook, $previous_hook, $incomplete_previous_hook, $cache);
+      $this->mergeHookFromSuggestion($hook, $previous_hook, $cache, $incomplete_previous_hook);
     }
 
-    // In addition to processing suggestions, include base hooks.
-    if (isset($cache[$hook]['base hook'])) {
-      // In order to retain the additions from above, pass in the current hook
-      // as the parent hook, otherwise it will be overwritten.
-      $this->mergePreprocessFunctions($hook, $cache[$hook]['base hook'], $cache[$hook], $cache);
+    // If a theme hook specifies a base hook, and that base hook is its own
+    // theme hook and has a complete list of preprocess functions, merge it into
+    // the current hook.
+    $base_hook = $cache[$hook]->getBaseHook();
+    if ($base_hook && isset($cache[$base_hook]) && !$cache[$base_hook]->isIncomplete()) {
+      $cache[$hook] = $cache[$hook]->merge($cache[$base_hook]);
     }
+
+    $cache[$hook]->markComplete();
   }
 
   /**
-   * Merges the source hook's preprocess functions into the destination hook's.
+   * Merges the source hook into the destination hook.
    *
    * @param string $destination_hook_name
-   *   The name of the hook to merge preprocess functions to.
+   *   The name of the hook being merged into.
    * @param string $source_hook_name
-   *   The name of the hook to merge preprocess functions from.
-   * @param array $parent_hook
+   *   The name of the hook being merged from.
+   * @param \Drupal\Core\Theme\ThemeHook[] $cache
+   *   The theme registry, as documented in
+   *   \Drupal\Core\Theme\Registry::processExtension().
+   * @param \Drupal\Core\Theme\ThemeHook $parent_hook
    *   The parent hook if it exists. Either an incomplete hook from suggestions
    *   or a base hook.
-   * @param array $cache
-   *   The theme registry, as documented in
-   *   \Drupal\Core\Theme\Registry::processExtension().
    */
-  protected function mergePreprocessFunctions($destination_hook_name, $source_hook_name, $parent_hook, array &$cache) {
-    // If base hook exists clone of it for the preprocess function
-    // without a template.
-    // @see https://www.drupal.org/node/2457295
-    if (isset($cache[$source_hook_name]) && (!isset($cache[$source_hook_name]['incomplete preprocess functions']) || !isset($cache[$destination_hook_name]['incomplete preprocess functions']))) {
-      $cache[$destination_hook_name] = $parent_hook + $cache[$source_hook_name];
-      if (isset($parent_hook['preprocess functions'])) {
-        $diff = array_diff($parent_hook['preprocess functions'], $cache[$source_hook_name]['preprocess functions']);
-        $cache[$destination_hook_name]['preprocess functions'] = array_merge($cache[$source_hook_name]['preprocess functions'], $diff);
+  protected function mergeHookFromSuggestion($destination_hook_name, $source_hook_name, array $cache, ThemeHook $parent_hook = NULL) {
+    // If the source hook doesn't exist, do not continue.
+    if (!isset($cache[$source_hook_name])) {
+      return;
+    }
+
+    // If either of the source or destination hook have complete preprocess
+    // functions, or the destination hook does not exist yet, continue.
+    if (!$cache[$source_hook_name]->isIncomplete() || (!isset($cache[$destination_hook_name]) || !$cache[$destination_hook_name]->isIncomplete())) {
+      $to_be_merged = $cache[$source_hook_name];
+      // If a parent hook was provided, use it as the basis for a merged result.
+      if ($parent_hook) {
+        $to_be_merged = $parent_hook->merge($to_be_merged);
       }
+      $cache[$destination_hook_name] = ThemeHook::createFromExisting($destination_hook_name, $to_be_merged)->merge($to_be_merged);
+
       // If a base hook isn't set, this is the actual base hook.
-      if (!isset($cache[$source_hook_name]['base hook'])) {
-        $cache[$destination_hook_name]['base hook'] = $source_hook_name;
+      if (!$cache[$destination_hook_name]->getBaseHook()) {
+        $cache[$destination_hook_name]->setBaseHook($source_hook_name);
       }
     }
   }
@@ -676,6 +570,7 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) {
     // expected naming conventions.
     $prefixes = array_keys((array) $this->moduleHandler->getModuleList());
     foreach (array_reverse($theme->getBaseThemes()) as $base) {
+      /** @var \Drupal\Core\Theme\ActiveTheme $base */
       $prefixes[] = $base->getName();
     }
     if ($theme->getEngine()) {
@@ -718,16 +613,16 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) {
     ksort($suggestion_level);
     foreach ($suggestion_level as $level => $item) {
       foreach ($item as $preprocessor => $hook) {
-        if (isset($cache[$hook]['preprocess functions']) && !in_array($hook, $cache[$hook]['preprocess functions'])) {
+        if (isset($cache[$hook]) && !$cache[$hook]->hasPreprocessFunction($hook)) {
           // Add missing preprocessor to existing hook.
-          $cache[$hook]['preprocess functions'][] = $preprocessor;
+          $cache[$hook]->addPreprocessFunction($preprocessor);
         }
         elseif (!isset($cache[$hook]) && strpos($hook, '__')) {
           // Process non-existing hook and register it.
           // Look for a previously defined hook that is either a less specific
           // suggestion hook or the base hook.
           $this->completeSuggestion($hook, $cache);
-          $cache[$hook]['preprocess functions'][] = $preprocessor;
+          $cache[$hook]->addPreprocessFunction($preprocessor);
         }
       }
     }
@@ -738,18 +633,8 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) {
       // The 'base hook' is only applied to derivative hooks already registered
       // from a pattern. This is typically set from
       // drupal_find_theme_functions() and drupal_find_theme_templates().
-      if (isset($info['incomplete preprocess functions'])) {
+      if ($info->isIncomplete()) {
         $this->completeSuggestion($hook, $cache);
-        unset($cache[$hook]['incomplete preprocess functions']);
-      }
-
-      // Optimize the registry.
-      if (isset($cache[$hook]['preprocess functions']) && empty($cache[$hook]['preprocess functions'])) {
-        unset($cache[$hook]['preprocess functions']);
-      }
-      // Ensure uniqueness.
-      if (isset($cache[$hook]['preprocess functions'])) {
-        $cache[$hook]['preprocess functions'] = array_unique($cache[$hook]['preprocess functions']);
       }
     }
   }
diff --git a/core/lib/Drupal/Core/Theme/ThemeHook.php b/core/lib/Drupal/Core/Theme/ThemeHook.php
new file mode 100644
index 0000000..a3ea16e
--- /dev/null
+++ b/core/lib/Drupal/Core/Theme/ThemeHook.php
@@ -0,0 +1,1070 @@
+<?php
+
+namespace Drupal\Core\Theme;
+
+/**
+ * Provides a value object for a theme hook.
+ *
+ * @internal
+ *
+ * @todo Consider removing \ArrayAccess in Drupal 9.0.x.
+ */
+class ThemeHook implements \ArrayAccess {
+
+  /**
+   * The type of provider of the theme hook.
+   *
+   * This is automatically derived and does not need to be specified.
+   *
+   * @var string
+   */
+  protected $type;
+
+  /**
+   * The provider of the theme hook.
+   *
+   * This is automatically derived and does not need to be specified.
+   *
+   * @var string
+   */
+  protected $provider;
+
+  /**
+   * The directory path of the theme or module.
+   *
+   * This is automatically derived and does not need to be specified.
+   *
+   * @var string
+   */
+  protected $theme_path;
+
+  /**
+   * An array of default values to be passed to the template.
+   *
+   * @var mixed[]|null
+   */
+  protected $variables;
+
+  /**
+   * The name of the renderable element to pass to the theme function.
+   *
+   * @var string
+   */
+  protected $render_element;
+
+  /**
+   * A regular expression pattern.
+   *
+   * @var string|null
+   */
+  protected $pattern;
+
+  /**
+   * The base theme hook name, if one exists.
+   *
+   * @var string|null
+   */
+  protected $base_hook;
+
+  /**
+   * An array of files to be included.
+   *
+   * The file paths must be relative to the Drupal root directory.
+   *
+   * @var string[]
+   */
+  protected $includes = [];
+
+  /**
+   * The file the implementation resides in.
+   *
+   * This file will be included prior to the theme being rendered, to make sure
+   * that the function or preprocess function (as needed) is actually loaded.
+   *
+   * @var string
+   */
+  protected $file;
+
+  /**
+   * The function name to invoke for the theme implementation.
+   *
+   * @var string
+   *
+   * @deprecated in Drupal 8.0.x, will be removed in Drupal 9.0.x.
+   */
+  protected $function;
+
+  /**
+   * The template name for this theme implementation.
+   *
+   * @var string|null
+   */
+  protected $template;
+
+  /**
+   * The path to the theme implementation, if it exists.
+   *
+   * The path must be relative to the Drupal root directory.
+   *
+   * @var string|null
+   */
+  protected $path;
+
+  /**
+   * A list of functions used to preprocess this data.
+   *
+   * @var string[]
+   */
+  protected $preprocess_functions = [];
+
+  /**
+   * Whether the list of preprocess functions is incomplete.
+   *
+   * @var bool
+   */
+  protected $incomplete_preprocess_functions = FALSE;
+
+  /**
+   * Whether standard preprocess functions should be ignored.
+   *
+   * @var bool
+   */
+  protected $override_preprocess_functions = FALSE;
+
+  /**
+   * Stores extra data.
+   *
+   * @var mixed[]
+   *
+   * @deprecated Only used as part of the \ArrayAccess backwards compatibility.
+   */
+  protected $extra = [];
+
+  /**
+   * The machine name of the theme hook.
+   *
+   * @var string
+   */
+  protected $name;
+
+  /**
+   * Constructs a new ThemeHook.
+   *
+   * @param string $name
+   *   The machine name of the theme hook.
+   */
+  protected function __construct($name) {
+    $this->name = $name;
+  }
+
+  /**
+   * Creates a new ThemeHook instance.
+   *
+   * @param string $name
+   *   The machine name of the theme hook.
+   *
+   * @return static
+   */
+  public static function create($name) {
+    return new static($name);
+  }
+
+  /**
+   * Creates a new ThemeHook instance based on an existing ThemeHook.
+   *
+   * This does not copy every single property, only those considered default.
+   *
+   * @param string $name
+   *   The machine name of the theme hook.
+   * @param \Drupal\Core\Theme\ThemeHook $other
+   *   Another theme hook to use as the basis for a new theme hook.
+   *
+   * @return static
+   */
+  public static function createFromExisting($name, ThemeHook $other) {
+    $instance = static::create($name);
+    $instance->handleDefaultValues($other);
+    return $instance;
+  }
+
+  /**
+   * Constructs a new ThemeHook using the legacy array-based format.
+   *
+   * @param string $name
+   *   The machine name of the theme hook.
+   * @param array $values
+   *   An array of values.
+   *
+   * @return static
+   *
+   * @deprecated
+   */
+  public static function createFromLegacy($name, array $values) {
+    $instance = static::create($name);
+    foreach ($values as $key => $value) {
+      $instance->offsetSet($key, $value);
+    }
+    return $instance;
+  }
+
+  /**
+   * Merges another theme hook into the values of the current theme hook.
+   *
+   * This does not modify the current theme hook, but returns a new instance.
+   *
+   * @param \Drupal\Core\Theme\ThemeHook $other
+   *   Another theme hook to use as the basis for a new theme hook.
+   *
+   * @return static
+   */
+  public function merge(ThemeHook $other) {
+    $result = clone $this;
+
+    // If this object doesn't have a value, use the value from the other object.
+    if (!$result->getProviderType()) {
+      $result->setProviderType($other->getProviderType());
+    }
+    if (!$result->getFile()) {
+      $result->setFile($other->getFile());
+    }
+    if (!$result->getThemePath()) {
+      $result->setThemePath($other->getThemePath());
+    }
+    if (!$result->getPath()) {
+      $result->setPath($other->getPath());
+    }
+    if (!$result->isPreprocessOverridden()) {
+      $result->overridePreprocess($other->isPreprocessOverridden());
+    }
+    if ($other->hasVariables() && !$result->hasVariables()) {
+      $result->setVariables($other->getVariables());
+    }
+    if (!$result->getPattern()) {
+      $result->setPattern($other->getPattern());
+    }
+    if (!$result->getBaseHook()) {
+      $result->setBaseHook($other->getBaseHook());
+    }
+    if ($other->getRenderElement() && !$result->getRenderElement()) {
+      $result->setRenderElement($other->getRenderElement());
+    }
+
+    // Only copy over the function and template if the original has neither.
+    if (!$result->getFunction() && !$result->getTemplate()) {
+      $result->setTemplate($other->getTemplate());
+      $result->setFunction($other->getFunction());
+    }
+
+    // If the other object does not yet have a complete list of preprocess
+    // functions, mark the result as incomplete.
+    if ($other->isIncomplete()) {
+      $result->markIncomplete();
+    }
+
+    // Merge all includes together.
+    $result->setIncludes(array_merge($other->getIncludes(), $result->getIncludes()));
+
+    // Check for the override flag and prevent the cached variable preprocessors
+    // from being used. This allows themes or theme engines to remove variable
+    // preprocessors set earlier in the registry build.
+    if (!$result->isPreprocessOverridden()) {
+      $result->setPreprocessFunctions(array_merge($other->getPreprocessFunctions(), $result->getPreprocessFunctions()));
+    }
+
+    return $result;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function &offsetGet($name) {
+    $value = NULL;
+    $name = str_replace(' ', '_', $name);
+    if (property_exists($this, $name)) {
+      $value = &$this->{$name};
+    }
+    else {
+      if (isset($this->extra[$name])) {
+        $value = &$this->extra[$name];
+      }
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetSet($name, $value) {
+    $name = str_replace(' ', '_', $name);
+    if (property_exists($this, $name)) {
+      $this->{$name} = $value;
+    }
+    else {
+      $this->extra[$name] = $value;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetUnset($name) {
+    $name = str_replace(' ', '_', $name);
+    if (property_exists($this, $name)) {
+      $reflection = new \ReflectionClass($this);
+      $this->{$name} = $reflection->getDefaultProperties()[$name];
+    }
+    else {
+      unset($this->extra[$name]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetExists($name) {
+    $name = str_replace(' ', '_', $name);
+    if (property_exists($this, $name)) {
+      return isset($this->{$name});
+    }
+    else {
+      return isset($this->extra[$name]);
+    }
+  }
+
+  /**
+   * Gets the directory path of the theme or module.
+   *
+   * @return string
+   *   The directory path of the theme or module.
+   */
+  public function getThemePath() {
+    return $this->theme_path;
+  }
+
+  /**
+   * Sets the directory path of the theme or module.
+   *
+   * @param string $theme_path
+   *   The directory path of the theme or module.
+   *
+   * @return $this
+   */
+  public function setThemePath($theme_path) {
+    $this->theme_path = $theme_path;
+
+    return $this;
+  }
+
+  /**
+   * Gets the type of provider of the theme hook.
+   *
+   * @return string
+   *   The type of provider of the theme hook. May be one of:
+   *   - 'module': A module is being checked for theme implementations.
+   *   - 'base_theme_engine': A theme engine is being checked for a theme that
+   *     is a parent of the actual theme being used.
+   *   - 'theme_engine': A theme engine is being checked for the actual theme
+   *     being used.
+   *   - 'base_theme': A base theme is being checked for theme implementations.
+   *   - 'theme': The actual theme in use is being checked.
+   */
+  public function getProviderType() {
+    return $this->type;
+  }
+
+  /**
+   * Sets the type of provider of the theme hook.
+   *
+   * @param string $type
+   *   The type of provider of the theme hook.
+   *
+   * @return $this
+   */
+  public function setProviderType($type) {
+    $this->type = $type;
+
+    return $this;
+  }
+
+  /**
+   * Gets the provider of the theme hook.
+   *
+   * @return string
+   *   The provider of the theme hook.
+   */
+  public function getProvider() {
+    return $this->type;
+  }
+
+  /**
+   * Sets the provider of the theme hook.
+   *
+   * @param string $type
+   *   The provider of the theme hook.
+   *
+   * @return $this
+   */
+  public function setProvider($type) {
+    $this->type = $type;
+
+    return $this;
+  }
+
+  /**
+   * Returns the default values to be passed to the template.
+   *
+   * @return mixed[]|null
+   *   An associative array where the keys are names of variables, and the
+   *   values are the default values if they are not given in the render array.
+   *   If this returns NULL, self::getRenderElement() will be used instead.
+   */
+  public function getVariables() {
+    return $this->variables;
+  }
+
+  /**
+   * Sets the default values to be passed to the template.
+   *
+   * Only used for #theme in render array. Template implementations receive each
+   * array key as a variable in the template file (so they must be legal
+   * PHP/Twig variable names). Function implementations are passed the variables
+   * in a single $variables function argument. If you are using these variables
+   * in a render array, prefix the variable names defined here with a #.
+   *
+   * @param mixed[]|null $variables
+   *   An associative array where the keys are names of variables, and the
+   *   values are the default values if they are not given in the render array.
+   *
+   * @return $this
+   */
+  public function setVariables($variables) {
+    $this->variables = $variables;
+
+    return $this;
+  }
+
+  /**
+   * Returns whether this theme hook has variables to pass to the template.
+   *
+   * @return bool
+   *   TRUE if the theme hook uses variables, FALSE otherwise.
+   */
+  public function hasVariables() {
+    return isset($this->variables);
+  }
+
+  /**
+   * Gets the name of the renderable element.
+   *
+   * @return string
+   *   The name of the renderable element to pass to the theme function.
+   */
+  public function getRenderElement() {
+    return $this->render_element;
+  }
+
+  /**
+   * Sets the name of the renderable element.
+   *
+   * Used for render element items only. This name is used as the name of the
+   * variable that holds the renderable element or tree in preprocess functions.
+   *
+   * @param string $render_element
+   *   The name of the renderable element to pass to the theme function.
+   *
+   * @return $this
+   */
+  public function setRenderElement($render_element) {
+    $this->render_element = $render_element;
+
+    return $this;
+  }
+
+  /**
+   * Gets the regular expression pattern.
+   *
+   * @return string|null
+   *   A regular expression pattern, if one exists.
+   */
+  public function getPattern() {
+    return $this->pattern;
+  }
+
+  /**
+   * Sets the regular expression pattern.
+   *
+   * A pattern allows the theme implementation to have a dynamic name.
+   *
+   * The convention is to use __ to differentiate the dynamic portion of the
+   * theme. For example, to allow forums to be themed individually, the pattern
+   * might be: 'forum__'. Then, when the forum is rendered, following render
+   * array can be used:
+   *   @code
+   *   $render_array = array(
+   *     '#theme' => array('forum__' . $tid, 'forum'),
+   *     '#forum' => $forum,
+   *   );
+   *   @endcode
+   *
+   * @param string|null $pattern
+   *   A regular expression pattern, or NULL to set no pattern.
+   *
+   * @return $this
+   */
+  public function setPattern($pattern) {
+    $this->pattern = $pattern;
+
+    return $this;
+  }
+
+  /**
+   * Returns if this theme hook has a regular expression pattern set.
+   *
+   * @return bool
+   *   TRUE if a regular expression pattern is set, FALSE otherwise.
+   */
+  public function hasPattern() {
+    return !is_null($this->pattern);
+  }
+
+  /**
+   * Gets the base theme hook name.
+   *
+   * @return string|null
+   *   The name of a theme hook to use as the basis for this theme hook, or NULL
+   *   if no base hook exists.
+   */
+  public function getBaseHook() {
+    return $this->base_hook;
+  }
+
+  /**
+   * Sets the base theme hook name.
+   *
+   * Used for theme suggestions only.
+   *
+   * Instead of this suggestion's implementation being used directly, the base
+   * hook will be invoked with this implementation as its first suggestion. The
+   * base hook's files will be included and the base hook's preprocess functions
+   * will be called in addition to any suggestion's preprocess functions. If an
+   * implementation of hook_theme_suggestions_HOOK() (where HOOK is the base
+   * hook) changes the suggestion order, a different suggestion may be used in
+   * place of this suggestion. If after hook_theme_suggestions_HOOK() this
+   * suggestion remains the first suggestion, then this suggestion's function or
+   * template will be used to generate the rendered output.
+   *
+   * @param string|null $base_hook
+   *   The name of a theme hook to use as the basis for this theme hook, or NULL
+   *   if no base hook exists.
+   *
+   * @return $this
+   */
+  public function setBaseHook($base_hook) {
+    $this->base_hook = $base_hook;
+
+    return $this;
+  }
+
+  /**
+   * Gets the array of files to be included.
+   *
+   * @return string[]
+   *   An array of files to be included. The file paths are relative to the
+   *   Drupal root directory.
+   */
+  public function getIncludes() {
+    return $this->includes;
+  }
+
+  /**
+   * Sets the array of files to be included.
+   *
+   * @param string[] $includes
+   *   An array of files to be included. The file paths must be relative to the
+   *   Drupal root directory.
+   *
+   * @return $this
+   */
+  public function setIncludes(array $includes) {
+    $this->includes = $includes;
+
+    return $this;
+  }
+
+  /**
+   * Adds a file to be included.
+   *
+   * @param string $include
+   *   A path relative to the Drupal root directory, or NULL if no path is set.
+   *
+   * @return $this
+   */
+  public function addInclude($include) {
+    $includes = $this->getIncludes();
+    $includes[] = $include;
+    $this->setIncludes($includes);
+
+    return $this;
+  }
+
+  /**
+   * Returns whether any files are specified for inclusion.
+   *
+   * @return bool
+   *   TRUE if files exist to be included, FALSE otherwise.
+   */
+  public function hasIncludes() {
+    return !empty($this->includes);
+  }
+
+  /**
+   * Gets the file the theme implementation resides in.
+   *
+   * @return string
+   *   The file the theme implementation resides in.
+   */
+  public function getFile() {
+    return $this->file;
+  }
+
+  /**
+   * Gets the file the theme implementation resides in.
+   *
+   * This file will be included prior to the theme being rendered, to make sure
+   * that the function or preprocess function (as needed) is actually loaded.
+   *
+   * @param string $file
+   *   The file the theme implementation resides in.
+   *
+   * @return $this
+   */
+  public function setFile($file) {
+    $this->file = $file;
+
+    return $this;
+  }
+
+  /**
+   * Gets the function name to invoke for this implementation, if it exists.
+   *
+   * If this is set, self::getTemplate() will be ignored.
+   *
+   * @return string|null
+   *   The function name to invoke for the theme implementation.
+   *
+   * @deprecated in Drupal 8.0.x, will be removed in Drupal 9.0.x.
+   */
+  public function getFunction() {
+    return $this->function;
+  }
+
+  /**
+   * Sets the function name to invoke for this implementation.
+   *
+   * If this is set, self::getTemplate() will be ignored.
+   *
+   * @param string $function
+   *   The function name to invoke for the theme implementation.
+   *
+   * @return $this
+   *
+   * @deprecated in Drupal 8.0.x, will be removed in Drupal 9.0.x.
+   */
+  public function setFunction($function) {
+    $this->function = $function;
+
+    return $this;
+  }
+
+  /**
+   * Gets the template name to use for the theme implementation.
+   *
+   * This is ignored if self::getFunction() is set.
+   *
+   * @return string
+   *   The template name for this theme implementation.
+   */
+  public function getTemplate() {
+    return $this->template;
+  }
+
+  /**
+   * Sets the template name to use for the theme implementation.
+   *
+   * This is ignored if self::getFunction() is set. If neither this nor
+   * self::getFunction() are specified, a default template name will be assumed.
+   * For example, if a module registers the 'search_result' theme hook,
+   * 'search-result' will be assigned as its template name.
+   *
+   * @param string|null $template
+   *   The template name for this theme implementation. Do not add 'html.twig'
+   *   on the end of the template name. The extension will be added
+   *   automatically by the default rendering engine (which is Twig).
+   *
+   * @return $this
+   */
+  public function setTemplate($template) {
+    $this->template = $template;
+
+    return $this;
+  }
+
+  /**
+   * Gets the path to the theme implementation, if it exists.
+   *
+   * @return string|null
+   *   A path relative to the Drupal root directory, or NULL if no path is set.
+   */
+  public function getPath() {
+    return $this->path;
+  }
+
+  /**
+   * Overrides the path to the file containing the theme implementation.
+   *
+   * If this is used, self::setTemplate() should also be called.
+   *
+   * @param string|null $path
+   *   A path relative to the Drupal root directory, or NULL to use the default
+   *   theme path.
+   *
+   * @return $this
+   */
+  public function setPath($path) {
+    $this->path = $path;
+
+    return $this;
+  }
+
+  /**
+   * Gets the list of functions used to preprocess this data.
+   *
+   * @return string[]
+   *   An array of functions to be called during the preprocess phase.
+   */
+  public function getPreprocessFunctions() {
+    return $this->preprocess_functions;
+  }
+
+  /**
+   * Sets the list of functions used to preprocess this data.
+   *
+   * Ordinarily this won't be used; it's automatically filled in. By default,
+   * for a module this will be filled in as template_preprocess_HOOK. For a
+   * theme this will be filled in as twig_preprocess and twig_preprocess_HOOK as
+   * well as themename_preprocess and themename_preprocess_HOOK.
+   *
+   * @param string[] $preprocess_functions
+   *   An array of functions to be called during the preprocess phase.
+   *
+   * @return $this
+   */
+  public function setPreprocessFunctions(array $preprocess_functions) {
+    $this->preprocess_functions = array_values(array_unique($preprocess_functions));
+
+    return $this;
+  }
+
+  /**
+   * Indicates if a given preprocess function will be used for this theme hook.
+   *
+   * @param string $preprocess_function
+   *   The name of a preprocess function.
+   *
+   * @return bool
+   *   TRUE if this preprocess function will be used for this theme hook,
+   *   FALSE otherwise.
+   */
+  public function hasPreprocessFunction($preprocess_function) {
+    return in_array($preprocess_function, $this->getPreprocessFunctions());
+  }
+
+  /**
+   * Adds a preprocess function to this theme hook.
+   *
+   * @param string $preprocess_function
+   *   The name of a preprocess function.
+   *
+   * @return $this
+   */
+  public function addPreprocessFunction($preprocess_function) {
+    $preprocess_functions = $this->getPreprocessFunctions();
+    $preprocess_functions[] = $preprocess_function;
+    $this->setPreprocessFunctions($preprocess_functions);
+
+    return $this;
+  }
+
+  /**
+   * Determines if standard preprocess functions should be ignored.
+   *
+   * @return bool
+   *   TRUE if standard preprocess functions should be ignored, FALSE otherwise.
+   */
+  public function isPreprocessOverridden() {
+    return $this->override_preprocess_functions;
+  }
+
+  /**
+   * Prevents standard preprocess functions from running if set to TRUE.
+   *
+   * This can be used to give a theme FULL control over how variables are set.
+   * For example, if a theme wants total control over how certain variables in
+   * the page.html.twig are set, this can be set to true. Please keep in mind
+   * that when this is used by a theme, that theme becomes responsible for
+   * making sure necessary variables are set.
+   *
+   * @param bool $status
+   *   (optional) TRUE if standard preprocess functions should be ignored, FALSE
+   *   otherwise. Defaults to TRUE.
+   *
+   * @return $this
+   */
+  public function overridePreprocess($status = TRUE) {
+    $this->override_preprocess_functions = (bool) $status;
+
+    return $this;
+  }
+
+  /**
+   * Returns whether the list of preprocess functions is incomplete.
+   *
+   * Generally this is for internal use only. A completely built registry should
+   * contain only completed theme hooks.
+   *
+   * @return bool
+   *   TRUE if the list of preprocess functions is incomplete.
+   */
+  public function isIncomplete() {
+    return $this->incomplete_preprocess_functions;
+  }
+
+  /**
+   * Marks this theme hook as being incomplete.
+   *
+   * @see self::isIncomplete()
+   *
+   * @return $this
+   */
+  public function markIncomplete() {
+    $this->incomplete_preprocess_functions = TRUE;
+
+    return $this;
+  }
+
+  /**
+   * Marks this theme hook as being complete.
+   *
+   * @see self::isIncomplete()
+   *
+   * @return $this
+   */
+  public function markComplete() {
+    $this->incomplete_preprocess_functions = FALSE;
+
+    return $this;
+  }
+
+  /**
+   * Gets the name of the theme hook.
+   *
+   * @return string
+   *   The machine name of the theme hook.
+   */
+  public function getName() {
+    return $this->name;
+  }
+
+  /**
+   * Process a theme hook.
+   *
+   * This is intended for usage by the theme registry.
+   *
+   * @param string $root
+   *   The app root.
+   * @param string $theme
+   *   The theme currently being processed.
+   * @param array $module_list
+   *   An array of module names.
+   * @param \Drupal\Core\Theme\ThemeHook|null $existing_theme_hook
+   *   An existing theme hook, if it exists.
+   *
+   * @return static
+   */
+  public function process($root, $theme, array $module_list, ThemeHook $existing_theme_hook = NULL) {
+    if (!$this->getRenderElement() && !$this->hasVariables() && !$this->getBaseHook()) {
+      // @todo Convert this to an exception.
+      @trigger_error(sprintf('The "%s" theme hook must have either a render element, variables, or a base hook', $this->getName()), E_USER_DEPRECATED);
+    }
+
+    $hook = $this->getName();
+
+    // If a theme hook has a base hook, mark its preprocess functions always
+    // incomplete in order to inherit the base hook's preprocess functions.
+    if ($this->getBaseHook()) {
+      $this->markIncomplete();
+    }
+
+    $this->handleIncludes($root, $existing_theme_hook);
+
+    // A template file is the default implementation for a theme hook, but if
+    // the theme hook specifies a function callback instead, check to ensure the
+    // function actually exists.
+    if ($function = $this->getFunction()) {
+      if (!function_exists($function)) {
+        throw new \BadFunctionCallException(sprintf(
+          'Theme hook "%s" refers to a theme function callback that does not exist: "%s"',
+          $hook,
+          $function
+        ));
+      }
+    }
+    // Provide a default naming convention for 'template' based on the hook
+    // used. If the template does not exist, the theme engine used should throw
+    // an exception at runtime when attempting to include the template file.
+    elseif (!$this->getTemplate()) {
+      $this->setTemplate(strtr($hook, '_', '-'));
+    }
+
+    // Prepend the current theming path when none is set. This is required for
+    // the default theme engine to know where the template lives.
+    if ($this->getTemplate() && !$this->getPath()) {
+      $this->setPath($this->getThemePath() . '/templates');
+    }
+
+    if ($existing_theme_hook) {
+      $this->handleDefaultValues($existing_theme_hook);
+    }
+    $this->handlePreprocess($theme, $module_list);
+
+    // Merge the newly created theme hooks into the existing cache.
+    if ($existing_theme_hook) {
+      return $this->merge($existing_theme_hook);
+    }
+    else {
+      return $this;
+    }
+  }
+
+  /**
+   * Handles including any files for the processing of this theme hook.
+   *
+   * @param string $root
+   *   The app root.
+   * @param \Drupal\Core\Theme\ThemeHook|null $existing_theme_hook
+   *   An existing theme hook, if it exists.
+   */
+  protected function handleIncludes($root, ThemeHook $existing_theme_hook = NULL) {
+    if ($existing_theme_hook && $existing_theme_hook->hasIncludes()) {
+      $this->setIncludes($existing_theme_hook->getIncludes());
+    }
+
+    // If the theme implementation defines a file, then also use the path
+    // that it defined. Otherwise use the default path. This allows
+    // system.module to declare theme functions on behalf of core .include
+    // files.
+    if ($this->getFile()) {
+      $include_file = $this->getPath() ?: $this->getThemePath();
+      $include_file .= '/' . $this->getFile();
+      $this->addInclude($include_file);
+    }
+
+    // Load the includes, as they may contain preprocess functions.
+    if ($this->hasIncludes()) {
+      foreach ($this->getIncludes() as $include_file) {
+        include_once $root . '/' . $include_file;
+      }
+    }
+  }
+
+  /**
+   * Handles merging in any default values from an existing theme hook.
+   *
+   * @param \Drupal\Core\Theme\ThemeHook $existing_theme_hook
+   *   An existing theme hook.
+   */
+  protected function handleDefaultValues(ThemeHook $existing_theme_hook) {
+    if ($existing_theme_hook->hasVariables() && !$this->hasVariables()) {
+      $this->setVariables($existing_theme_hook->getVariables());
+    }
+    if (!$this->getPattern()) {
+      $this->setPattern($existing_theme_hook->getPattern());
+    }
+    if (!$this->getBaseHook()) {
+      $this->setBaseHook($existing_theme_hook->getBaseHook());
+    }
+    if ($existing_theme_hook->getRenderElement() && !$this->getRenderElement()) {
+      $this->setRenderElement($existing_theme_hook->getRenderElement());
+    }
+  }
+
+  /**
+   * Generates the list of preprocess functions from the theme and module list.
+   *
+   * @param string $theme
+   *   The theme currently being processed.
+   * @param array $module_list
+   *   An array of module names.
+   */
+  protected function handlePreprocess($theme, array $module_list) {
+    // Preprocess variables for all theming hooks, whether the hook is
+    // implemented as a template or as a function. Ensure they are arrays.
+    if (!$this->getPreprocessFunctions()) {
+      $provider_name = $this->getProvider();
+      $type = $this->getProviderType();
+      $hook = $this->getName();
+
+      $prefixes = [];
+      if ($type == 'module') {
+        // Default variable preprocessor prefix.
+        $prefixes[] = 'template';
+        // Add all modules so they can intervene with their own variable
+        // preprocessors. This allows them to provide variable preprocessors
+        // even if they are not the owner of the current hook.
+        $prefixes = array_merge($prefixes, $module_list);
+      }
+      elseif ($type == 'theme_engine' || $type == 'base_theme_engine') {
+        // Theme engines get an extra set that come before the normally
+        // named variable preprocessors.
+        $prefixes[] = $provider_name . '_engine';
+        // The theme engine registers on behalf of the theme using the
+        // theme's name.
+        $prefixes[] = $theme;
+      }
+      else {
+        // This applies when the theme manually registers their own variable
+        // preprocessors.
+        $prefixes[] = $provider_name;
+      }
+      foreach ($prefixes as $prefix) {
+        // Only use non-hook-specific variable preprocessors for theming
+        // hooks implemented as templates. See the @defgroup themeable
+        // topic.
+        if ($this->getTemplate() && function_exists($prefix . '_preprocess')) {
+          $this->addPreprocessFunction($prefix . '_preprocess');
+        }
+        if (function_exists($prefix . '_preprocess_' . $hook)) {
+          $this->addPreprocessFunction($prefix . '_preprocess_' . $hook);
+        }
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __sleep() {
+    $values = get_object_vars($this);
+
+    // In order to minimize the registry, exclude any key with default values.
+    $default_properties = (new \ReflectionClass($this))->getDefaultProperties();
+    foreach ($values as $key => $value) {
+      if (array_key_exists($key, $default_properties) && $default_properties[$key] === $value) {
+        unset($values[$key]);
+      }
+    }
+
+    return array_keys($values);
+  }
+
+}
diff --git a/core/modules/system/tests/modules/theme_test/theme_test.module b/core/modules/system/tests/modules/theme_test/theme_test.module
index 2e6f164..2c94d7f 100644
--- a/core/modules/system/tests/modules/theme_test/theme_test.module
+++ b/core/modules/system/tests/modules/theme_test/theme_test.module
@@ -6,6 +6,7 @@
  */
 
 use Drupal\Core\Extension\Extension;
+use Drupal\Core\Theme\ThemeHook;
 
 /**
  * Implements hook_theme().
@@ -66,6 +67,8 @@ function theme_test_theme($existing, $type, $theme, $path) {
       'bar' => '',
     ],
   ];
+  $items[] = ThemeHook::create('theme_test_object_based')
+    ->setRenderElement('elements');
   return $items;
 }
 
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php b/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
index a962fec..4d86c46 100644
--- a/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
@@ -5,6 +5,7 @@
 use Drupal\Core\Path\CurrentPathStack;
 use Drupal\Core\Path\PathMatcherInterface;
 use Drupal\Core\Theme\Registry;
+use Drupal\Core\Theme\ThemeHook;
 use Drupal\Core\Utility\ThemeRegistry;
 use Drupal\KernelTests\KernelTestBase;
 
@@ -141,7 +142,17 @@ public function testSuggestionPreprocessFunctions() {
     $preprocess_functions = $registry_theme->get()['theme_test_preprocess_suggestions__kitten__bearcat']['preprocess functions'];
     $this->assertIdentical($expected_preprocess_functions, $preprocess_functions, 'Suggestion implemented as a template correctly inherits preprocess functions.');
 
+    // Test an object-based theme hook.
+    $info = $registry_theme->get()['theme_test_object_based'];
+    $this->assertSame(['template_preprocess'], $info['preprocess functions']);
+    $this->assertSame('elements', $info['render element']);
+
     $this->assertTrue(isset($registry_theme->get()['theme_test_preprocess_suggestions__kitten__meerkat__tarsier__moose']), 'Preprocess function with an unimplemented lower-level suggestion is added to the registry.');
+    foreach ($registry_theme->get() as $name => $info) {
+      $this->assertInstanceOf(ThemeHook::class, $info);
+      $this->assertSame($name, $info->getName());
+      $this->assertTrue($info->getTemplate() || $info->getFunction());
+    }
   }
 
   /**
diff --git a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php
index f2cd22e..39417ec 100644
--- a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php
+++ b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php
@@ -9,6 +9,7 @@
 
 use Drupal\Core\Theme\ActiveTheme;
 use Drupal\Core\Theme\Registry;
+use Drupal\Core\Theme\ThemeHook;
 use Drupal\Tests\UnitTestCase;
 
 /**
@@ -177,7 +178,7 @@ public function testGetRegistryForModule() {
   /**
    * @covers ::postProcessExtension
    * @covers ::completeSuggestion
-   * @covers ::mergePreprocessFunctions
+   * @covers ::mergeHookFromSuggestion
    *
    * @dataProvider providerTestPostProcessExtension
    *
@@ -191,6 +192,20 @@ public function testGetRegistryForModule() {
   public function testPostProcessExtension($defined_functions, $hooks, $expected) {
     static::$functions['user'] = $defined_functions;
 
+    // @todo The BC layer is in \Drupal\Core\Theme\Registry::processExtension()
+    //   which this test bypasses via reflection. These legacy conversion calls
+    //   are necessary until the test data and expectations are converted.
+    foreach ($expected as $name => $hook) {
+      if (is_array($hook)) {
+        $expected[$name] = ThemeHook::createFromLegacy($name, $hook);
+      }
+    }
+    foreach ($hooks as $name => $hook) {
+      if (is_array($hook)) {
+        $hooks[$name] = ThemeHook::createFromLegacy($name, $hook);
+      }
+    }
+
     $theme = $this->prophesize(ActiveTheme::class);
     $theme->getBaseThemes()->willReturn([]);
     $theme->getName()->willReturn('test');
diff --git a/core/tests/Drupal/Tests/Core/Theme/ThemeHookTest.php b/core/tests/Drupal/Tests/Core/Theme/ThemeHookTest.php
new file mode 100644
index 0000000..65a4650
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Theme/ThemeHookTest.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\Tests\Core\Theme;
+
+use Drupal\Core\Theme\ThemeHook;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Theme\ThemeHook
+ * @group Theme
+ */
+class ThemeHookTest extends UnitTestCase {
+
+  /**
+   * @covers ::process
+   */
+  public function testProcessBadFunctionCall() {
+    $hook = ThemeHook::create('foo')
+      ->setRenderElement('elements')
+      ->setFunction('nonexistent');
+    $this->setExpectedException(\BadFunctionCallException::class, 'Theme hook "foo" refers to a theme function callback that does not exist: "nonexistent"');
+    $hook->process('', '', []);
+  }
+
+}
