diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 4cc6424..4564c10 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -20,6 +20,7 @@
 use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\Render\RenderableInterface;
 use Drupal\Core\Template\Attribute;
+use Drupal\Core\Theme\ThemeHook;
 use Drupal\Core\Theme\ThemeSettings;
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Render\Element;
@@ -124,7 +125,7 @@ function drupal_theme_rebuild() {
 /**
  * Allows themes and/or theme engines to discover overridden theme functions.
  *
- * @param array $cache
+ * @param \Drupal\Core\Theme\ThemeHook[] $cache
  *   The existing cache of theme hooks to test against.
  * @param array $prefixes
  *   An array of prefixes to test, in reverse order of importance.
@@ -149,20 +150,23 @@ function drupal_find_theme_functions($cache, $prefixes) {
       // refers to a base hook, not to another suggestion, and all suggestions
       // are found using the base hook's pattern, not a pattern from an
       // intermediary suggestion.
-      $pattern = isset($info['pattern']) ? $info['pattern'] : ($hook . '__');
+      $pattern = $info->getPattern() ?: $hook . '__';
       // Grep only the functions which are within the prefix group.
       list($first_prefix,) = explode('_', $prefix, 2);
-      if (!isset($info['base hook']) && !empty($pattern) && isset($grouped_functions[$first_prefix])) {
+      if (!$info->getBaseHook() && !empty($pattern) && isset($grouped_functions[$first_prefix])) {
         $matches = preg_grep('/^' . $prefix . '_' . $pattern . '/', $grouped_functions[$first_prefix]);
         if ($matches) {
           foreach ($matches as $match) {
             $new_hook = substr($match, strlen($prefix) + 1);
-            $arg_name = isset($info['variables']) ? 'variables' : 'render element';
-            $implementations[$new_hook] = [
-              'function' => $match,
-              $arg_name => $info[$arg_name],
-              'base hook' => $hook,
-            ];
+            $implementations[$new_hook] = ThemeHook::createFromExisting($new_hook, $cache[$hook])
+              ->setFunction($match)
+              ->setBaseHook($hook);
+            if ($info->getVariables()) {
+              $implementations[$new_hook]->setVariables($info->getVariables());
+            }
+            else {
+              $implementations[$new_hook]->setRenderElement($info->getRenderElement());
+            }
           }
         }
       }
@@ -170,9 +174,8 @@ function drupal_find_theme_functions($cache, $prefixes) {
       // that in what is returned so that the registry knows that the theme has
       // this implementation.
       if (function_exists($prefix . '_' . $hook)) {
-        $implementations[$hook] = [
-          'function' => $prefix . '_' . $hook,
-        ];
+        $implementations[$hook] = ThemeHook::createFromExisting($hook, $cache[$hook])
+          ->setFunction($prefix . '_' . $hook);
       }
     }
   }
@@ -183,12 +186,14 @@ function drupal_find_theme_functions($cache, $prefixes) {
 /**
  * Allows themes and/or theme engines to easily discover overridden templates.
  *
- * @param $cache
+ * @param \Drupal\Core\Theme\ThemeHook[] $cache
  *   The existing cache of theme hooks to test against.
  * @param $extension
  *   The extension that these templates will have.
  * @param $path
  *   The path to search.
+ *
+ * @return \Drupal\Core\Theme\ThemeHook[]
  */
 function drupal_find_theme_templates($cache, $extension, $path) {
   $implementations = [];
@@ -231,21 +236,17 @@ function drupal_find_theme_templates($cache, $extension, $path) {
     // for the purposes of searching.
     $hook = strtr($template, '-', '_');
     if (isset($cache[$hook])) {
-      $implementations[$hook] = [
-        'template' => $template,
-        'path' => dirname($file->uri),
-      ];
+      $implementations[$hook] = ThemeHook::createFromExisting($hook, $cache[$hook])
+        ->setTemplate($template)
+        ->setPath(dirname($file->uri));
     }
 
     // Match templates based on the 'template' filename.
     foreach ($cache as $hook => $info) {
-      if (isset($info['template'])) {
-        if ($template === $info['template']) {
-          $implementations[$hook] = [
-            'template' => $template,
-            'path' => dirname($file->uri),
-          ];
-        }
+      if ($template === $info->getTemplate()) {
+        $implementations[$hook] = ThemeHook::createFromExisting($hook, $cache[$hook])
+          ->setTemplate($template)
+          ->setPath(dirname($file->uri));
       }
     }
   }
@@ -256,8 +257,8 @@ function drupal_find_theme_templates($cache, $extension, $path) {
   // the use of 'pattern' and 'base hook'.
   $patterns = array_keys($files);
   foreach ($cache as $hook => $info) {
-    $pattern = isset($info['pattern']) ? $info['pattern'] : ($hook . '__');
-    if (!isset($info['base hook']) && !empty($pattern)) {
+    $pattern = $info->getPattern() ?: $hook . '__';
+    if (!$info->getBaseHook() && !empty($pattern)) {
       // Transform _ in pattern to - to match file naming scheme
       // for the purposes of searching.
       $pattern = strtr($pattern, '_', '-');
@@ -270,13 +271,17 @@ function drupal_find_theme_templates($cache, $extension, $path) {
           $file = str_replace($extension, '', $file);
           // Put the underscores back in for the hook name and register this
           // pattern.
-          $arg_name = isset($info['variables']) ? 'variables' : 'render element';
-          $implementations[strtr($file, '-', '_')] = [
-            'template' => $file,
-            'path' => dirname($files[$match]->uri),
-            $arg_name => $info[$arg_name],
-            'base hook' => $hook,
-          ];
+          $hook_name = strtr($file, '-', '_');
+          $implementations[$hook_name] = ThemeHook::createFromExisting($hook, $cache[$hook])
+            ->setTemplate($file)
+            ->setPath(dirname($files[$match]->uri))
+            ->setBaseHook($hook);
+          if ($info->getVariables()) {
+            $implementations[$hook_name]->setVariables($info->getVariables());
+          }
+          else {
+            $implementations[$hook_name]->setRenderElement($info->getRenderElement());
+          }
         }
       }
     }
@@ -1214,8 +1219,7 @@ function template_preprocess(&$variables, $hook, $info) {
 
   // When theming a render element, merge its #attributes into
   // $variables['attributes'].
-  if (isset($info['render element'])) {
-    $key = $info['render element'];
+  if ($key = $info->getRenderElement()) {
     if (isset($variables[$key]['#attributes'])) {
       $variables['attributes'] = NestedArray::mergeDeep($variables['attributes'], $variables[$key]['#attributes']);
     }
diff --git a/core/lib/Drupal/Core/Render/theme.api.php b/core/lib/Drupal/Core/Render/theme.api.php
index 44fcdd0..3d58978 100644
--- a/core/lib/Drupal/Core/Render/theme.api.php
+++ b/core/lib/Drupal/Core/Render/theme.api.php
@@ -1092,87 +1092,8 @@ function hook_page_bottom(array &$page_bottom) {
  *   The directory path of the theme or module, so that it doesn't need to be
  *   looked up.
  *
- * @return array
- *   An associative array of information about theme implementations. The keys
- *   on the outer array are known as "theme hooks". For theme suggestions,
- *   instead of the array key being the base theme hook, the key is a theme
- *   suggestion name with the format 'base_hook_name__sub_hook_name'.
- *   For render elements, the key is the machine name of the render element.
- *   The array values are themselves arrays containing information about the
- *   theme hook and its implementation. Each information array must contain
- *   either a 'variables' element (for using a #theme element) or a
- *   'render element' element (for render elements), but not both.
- *   The following elements may be part of each information array:
- *   - variables: Only used for #theme in render array: an array of variables,
- *     where the array keys are the names of the variables, and the array
- *     values are the default values if they are not given in the 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 #.
- *   - render element: Used for render element items only: the name of the
- *     renderable element or element tree to pass to the theme function. This
- *     name is used as the name of the variable that holds the renderable
- *     element or tree in preprocess and process functions.
- *   - file: 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.
- *   - path: Override the path of the file to be used. Ordinarily the module or
- *     theme path will be used, but if the file will not be in the default
- *     path, include it here. This path should be relative to the Drupal root
- *     directory.
- *   - template: If specified, the theme implementation is a template file, and
- *     this is the template name. 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.) If 'path' is specified, 'template'
- *     should also be specified. If neither 'template' nor 'function' 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.
- *   - function: (deprecated in Drupal 8.0.x, will be removed in Drupal 9.0.x)
- *     If specified, this will be the function name to invoke for this
- *     implementation. If neither 'template' nor 'function' are specified, a
- *     default template name will be assumed. See above for more details.
- *   - base hook: Used for theme suggestions only: the base theme hook name.
- *     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.
- *   - pattern: A regular expression pattern to be used to allow this 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
- *   - preprocess functions: A 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.
- *   - override preprocess functions: Set to TRUE when a theme does NOT want
- *     the standard preprocess functions to run. 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.
- *   - type: (automatically derived) Where the theme hook is defined:
- *     'module', 'theme_engine', or 'theme'.
- *   - theme path: (automatically derived) The directory path of the theme or
- *     module, so that it doesn't need to be looked up.
+ * @return \Drupal\Core\Theme\ThemeHook[]
+ *   An array of theme hook objects.
  *
  * @see themeable
  * @see hook_theme_registry_alter()
diff --git a/core/lib/Drupal/Core/Template/Loader/ThemeRegistryLoader.php b/core/lib/Drupal/Core/Template/Loader/ThemeRegistryLoader.php
index 67a82dd..67ebedb 100644
--- a/core/lib/Drupal/Core/Template/Loader/ThemeRegistryLoader.php
+++ b/core/lib/Drupal/Core/Template/Loader/ThemeRegistryLoader.php
@@ -49,11 +49,11 @@ protected function findTemplate($name, $throw = TRUE) {
 
     if ($theme_registry->has($hook)) {
       $info = $theme_registry->get($hook);
-      if (isset($info['path'])) {
-        $path = $info['path'] . '/' . $name;
+      if ($info->getPath()) {
+        $path = $info->getPath() . '/' . $name;
       }
-      elseif (isset($info['template'])) {
-        $path = $info['template'] . '.html.twig';
+      elseif ($info->getTemplate()) {
+        $path = $info->getTemplate() . '.html.twig';
       }
       if (isset($path) && is_file($path)) {
         return $this->cache[$name] = $path;
diff --git a/core/lib/Drupal/Core/Theme/Registry.php b/core/lib/Drupal/Core/Theme/Registry.php
index a0af702..14da26b 100644
--- a/core/lib/Drupal/Core/Theme/Registry.php
+++ b/core/lib/Drupal/Core/Theme/Registry.php
@@ -215,7 +215,7 @@ protected function init($theme_name = NULL) {
   /**
    * Returns the complete theme registry from cache or rebuilds it.
    *
-   * @return array
+   * @return \Drupal\Core\Theme\ThemeHook[]
    *   The complete theme registry data array.
    *
    * @see Registry::$registry
@@ -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()];
@@ -380,28 +375,10 @@ protected function build() {
   /**
    * Process a single implementation of hook_theme().
    *
-   * @param array $cache
+   * @param \Drupal\Core\Theme\ThemeHook[] $cache
    *   The theme registry that will eventually be cached; It is an associative
-   *   array keyed by theme hooks, whose values are associative arrays
-   *   describing the hook:
-   *   - 'type': The passed-in $type.
-   *   - 'theme path': The passed-in $path.
-   *   - 'function': The name of the function generating output for this theme
-   *     hook. Either defined explicitly in hook_theme() or, if neither
-   *     'function' nor 'template' is defined, then the default theme function
-   *     name is used. The default theme function name is the theme hook
-   *     prefixed by either 'theme_' for modules or '$name_' for everything
-   *     else. If 'function' is defined, 'template' is not used.
-   *   - 'template': The filename of the template generating output for this
-   *     theme hook. The template is in the directory defined by the 'path' key
-   *     of hook_theme() or defaults to "$path/templates".
-   *   - 'variables': The variables for this theme hook as defined in
-   *     hook_theme(). If there is more than one implementation and 'variables'
-   *     is not specified in a later one, then the previous definition is kept.
-   *   - 'render element': The renderable element for this theme hook as defined
-   *     in hook_theme(). If there is more than one implementation and
-   *     'render element' is not specified in a later one, then the previous
-   *     definition is kept.
+   *   array keyed by theme hooks, whose values are \Drupal\Core\Theme\ThemeHook
+   *   value objects.
    *   - See the @link themeable Theme system overview topic @endlink for
    *     detailed documentation.
    * @param string $name
@@ -430,13 +407,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 +415,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 +448,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,34 +467,37 @@ 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'])) {
-        $incomplete_previous_hook = $cache[$previous_hook];
-        unset($incomplete_previous_hook['incomplete preprocess functions']);
+      if (isset($cache[$previous_hook]) && !$incomplete_previous_hook && $cache[$previous_hook]->isIncomplete()) {
+        $incomplete_previous_hook = clone $cache[$previous_hook];
+        $incomplete_previous_hook->markComplete();
       }
       $previous_hook = substr($previous_hook, 0, $pos);
-      $this->mergePreprocessFunctions($hook, $previous_hook, $incomplete_previous_hook, $cache);
+      $this->mergePreprocessFunctions($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();
   }
 
   /**
@@ -636,26 +507,32 @@ protected function completeSuggestion($hook, array &$cache) {
    *   The name of the hook to merge preprocess functions to.
    * @param string $source_hook_name
    *   The name of the hook to merge preprocess functions from.
-   * @param array $parent_hook
+   * @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 mergePreprocessFunctions($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 = clone $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);
       }
     }
   }
@@ -663,7 +540,7 @@ protected function mergePreprocessFunctions($destination_hook_name, $source_hook
   /**
    * Completes the theme registry adding discovered functions and hooks.
    *
-   * @param array $cache
+   * @param \Drupal\Core\Theme\ThemeHook[] $cache
    *   The theme registry as documented in
    *   \Drupal\Core\Theme\Registry::processExtension().
    * @param \Drupal\Core\Theme\ActiveTheme $theme
@@ -676,6 +553,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 +596,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 +616,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..88af08f
--- /dev/null
+++ b/core/lib/Drupal/Core/Theme/ThemeHook.php
@@ -0,0 +1,1043 @@
+<?php
+
+namespace Drupal\Core\Theme;
+
+/**
+ * Provides a value object for a theme hook.
+ *
+ * @todo Remove \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->getOverriddenPreprocessFunctionStatus()) {
+      $result->setOverriddenPreprocessFunctionStatus($other->getOverriddenPreprocessFunctionStatus());
+    }
+    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->getOverriddenPreprocessFunctionStatus()) {
+      $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;
+  }
+
+  /**
+   * 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.
+   *
+   * @todo This is a terrible method name. Come up with a better one.
+   *
+   * @return bool
+   *   TRUE if standard preprocess functions should be ignored, FALSE otherwise.
+   */
+  public function getOverriddenPreprocessFunctionStatus() {
+    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.
+   *
+   * @todo This is a terrible method name. Come up with a better one.
+   *
+   * @param bool $status
+   *   TRUE if standard preprocess functions should be ignored, FALSE otherwise.
+   *
+   * @return $this
+   */
+  public function setOverriddenPreprocessFunctionStatus($status) {
+    $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()) {
+      throw new \UnexpectedValueException(sprintf('The "%s" theme hook must have either a render element, variables, or a base hook', $this->getName()));
+    }
+
+    $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');
+    }
+
+    $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|null $existing_theme_hook
+   *   An existing theme hook, if it exists.
+   */
+  protected function handleDefaultValues(ThemeHook $existing_theme_hook = NULL) {
+    if ($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);
+        }
+      }
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Theme/ThemeManager.php b/core/lib/Drupal/Core/Theme/ThemeManager.php
index c58d176..a123896 100644
--- a/core/lib/Drupal/Core/Theme/ThemeManager.php
+++ b/core/lib/Drupal/Core/Theme/ThemeManager.php
@@ -189,26 +189,26 @@ public function render($hook, array $variables) {
     if (isset($variables['#theme']) || isset($variables['#theme_wrappers'])) {
       $element = $variables;
       $variables = [];
-      if (isset($info['variables'])) {
-        foreach (array_keys($info['variables']) as $name) {
+      if ($info->hasVariables()) {
+        foreach (array_keys($info->getVariables()) as $name) {
           if (isset($element["#$name"]) || array_key_exists("#$name", $element)) {
             $variables[$name] = $element["#$name"];
           }
         }
       }
       else {
-        $variables[$info['render element']] = $element;
+        $variables[$info->getRenderElement()] = $element;
         // Give a hint to render engines to prevent infinite recursion.
-        $variables[$info['render element']]['#render_children'] = TRUE;
+        $variables[$info->getRenderElement()]['#render_children'] = TRUE;
       }
     }
 
     // Merge in argument defaults.
-    if (!empty($info['variables'])) {
-      $variables += $info['variables'];
+    if ($info->hasVariables()) {
+      $variables += $info->getVariables();
     }
-    elseif (!empty($info['render element'])) {
-      $variables += [$info['render element'] => []];
+    elseif ($info->getRenderElement()) {
+      $variables += [$info->getRenderElement() => []];
     }
     // Supply original caller info.
     $variables += [
@@ -219,19 +219,14 @@ public function render($hook, array $variables) {
     // is called, we run hook_theme_suggestions_node_alter() rather than
     // hook_theme_suggestions_node__article_alter(), and also pass in the base
     // hook as the last parameter to the suggestions alter hooks.
-    if (isset($info['base hook'])) {
-      $base_theme_hook = $info['base hook'];
-    }
-    else {
-      $base_theme_hook = $hook;
-    }
+    $base_theme_hook = $info->getBaseHook() ?: $hook;
 
     // Invoke hook_theme_suggestions_HOOK().
     $suggestions = $this->moduleHandler->invokeAll('theme_suggestions_' . $base_theme_hook, [$variables]);
     // If the theme implementation was invoked with a direct theme suggestion
     // like '#theme' => 'node__article', add it to the suggestions array before
     // invoking suggestion alter hooks.
-    if (isset($info['base hook'])) {
+    if ($info->getBaseHook()) {
       $suggestions[] = $hook;
     }
 
@@ -258,31 +253,30 @@ public function render($hook, array $variables) {
 
     // Include a file if the theme function or variable preprocessor is held
     // elsewhere.
-    if (!empty($info['includes'])) {
-      foreach ($info['includes'] as $include_file) {
+    if ($info->hasIncludes()) {
+      foreach ($info->getIncludes() as $include_file) {
         include_once $this->root . '/' . $include_file;
       }
     }
 
     // Invoke the variable preprocessors, if any.
-    if (isset($info['base hook'])) {
-      $base_hook = $info['base hook'];
+    if ($base_hook = $info->getBaseHook()) {
       $base_hook_info = $theme_registry->get($base_hook);
       // Include files required by the base hook, since its variable
       // preprocessors might reside there.
-      if (!empty($base_hook_info['includes'])) {
-        foreach ($base_hook_info['includes'] as $include_file) {
+      if ($base_hook_info->hasIncludes()) {
+        foreach ($base_hook_info->getIncludes() as $include_file) {
           include_once $this->root . '/' . $include_file;
         }
       }
-      if (isset($base_hook_info['preprocess functions'])) {
+      if ($base_hook_info->getPreprocessFunctions()) {
         // Set a variable for the 'theme_hook_suggestion'. This is used to
         // maintain backwards compatibility with template engines.
         $theme_hook_suggestion = $hook;
       }
     }
-    if (isset($info['preprocess functions'])) {
-      foreach ($info['preprocess functions'] as $preprocessor_function) {
+    if ($preprocessor_functions = $info->getPreprocessFunctions()) {
+      foreach ($preprocessor_functions as $preprocessor_function) {
         if (function_exists($preprocessor_function)) {
           $preprocessor_function($variables, $hook, $info);
         }
@@ -309,12 +303,12 @@ public function render($hook, array $variables) {
 
     // Generate the output using either a function or a template.
     $output = '';
-    if (isset($info['function'])) {
-      if (function_exists($info['function'])) {
+    if ($function = $info->getFunction()) {
+      if (function_exists($function)) {
         // Theme functions do not render via the theme engine, so the output is
         // not autoescaped. However, we can only presume that the theme function
         // has been written correctly and that the markup is safe.
-        $output = Markup::create($info['function']($variables));
+        $output = Markup::create($function($variables));
       }
     }
     else {
@@ -325,7 +319,7 @@ public function render($hook, array $variables) {
       // renderer.
       $theme_engine = $active_theme->getEngine();
       if (isset($theme_engine)) {
-        if ($info['type'] != 'module') {
+        if ($info->getProviderType() != 'module') {
           if (function_exists($theme_engine . '_render_template')) {
             $render_function = $theme_engine . '_render_template';
           }
@@ -367,9 +361,9 @@ public function render($hook, array $variables) {
       }
 
       // Render the output using the template file.
-      $template_file = $info['template'] . $extension;
-      if (isset($info['path'])) {
-        $template_file = $info['path'] . '/' . $template_file;
+      $template_file = $info->getTemplate() . $extension;
+      if ($path = $info->getPath()) {
+        $template_file = $path . '/' . $template_file;
       }
       // Add the theme suggestions to the variables array just before rendering
       // the template for backwards compatibility with template engines.
diff --git a/core/lib/Drupal/Core/Utility/ThemeRegistry.php b/core/lib/Drupal/Core/Utility/ThemeRegistry.php
index bc2ce5a..3bd985d 100644
--- a/core/lib/Drupal/Core/Utility/ThemeRegistry.php
+++ b/core/lib/Drupal/Core/Utility/ThemeRegistry.php
@@ -101,6 +101,8 @@ public function has($key) {
 
   /**
    * {@inheritdoc}
+   *
+   * @return \Drupal\Core\Theme\ThemeHook|array
    */
   public function get($key) {
     // If the offset is set but empty, it is a registered theme hook that has
diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module
index a814c39..a8c0607 100644
--- a/core/modules/contextual/contextual.module
+++ b/core/modules/contextual/contextual.module
@@ -113,12 +113,13 @@ function contextual_preprocess(&$variables, $hook, $info) {
   }
 
   // Determine the primary theme function argument.
-  if (!empty($info['variables'])) {
-    $keys = array_keys($info['variables']);
+  /** @var \Drupal\Core\Theme\ThemeHook $info */
+  if ($info->hasVariables()) {
+    $keys = array_keys($info->getVariables());
     $key = $keys[0];
   }
-  elseif (!empty($info['render element'])) {
-    $key = $info['render element'];
+  elseif ($info->getRenderElement()) {
+    $key = $info->getRenderElement();
   }
   if (!empty($key) && isset($variables[$key])) {
     $element = $variables[$key];
diff --git a/core/modules/locale/tests/modules/locale_test/locale_test.module b/core/modules/locale/tests/modules/locale_test/locale_test.module
index 89ba2ba..9059e9c 100644
--- a/core/modules/locale/tests/modules/locale_test/locale_test.module
+++ b/core/modules/locale/tests/modules/locale_test/locale_test.module
@@ -154,7 +154,7 @@ function locale_test_theme() {
   $return = [];
 
   $return['locale_test_tokenized'] = [
-    'variable' => ['content' => ''],
+    'variables' => ['content' => ''],
   ];
 
   return $return;
diff --git a/core/modules/system/src/Tests/Theme/ThemeTest.php b/core/modules/system/src/Tests/Theme/ThemeTest.php
index 64f330e..8e5579e 100644
--- a/core/modules/system/src/Tests/Theme/ThemeTest.php
+++ b/core/modules/system/src/Tests/Theme/ThemeTest.php
@@ -261,7 +261,7 @@ public function testClassLoading() {
   public function testFindThemeTemplates() {
     $registry = $this->container->get('theme.registry')->get();
     $templates = drupal_find_theme_templates($registry, '.html.twig', drupal_get_path('theme', 'test_theme'));
-    $this->assertEqual($templates['node__1']['template'], 'node--1', 'Template node--1.tpl.twig was found in test_theme.');
+    $this->assertEqual($templates['node__1']->getTemplate(), 'node--1', 'Template node--1.html.twig was found in test_theme.');
   }
 
   /**
diff --git a/core/modules/system/src/Tests/Theme/TwigDebugMarkupTest.php b/core/modules/system/src/Tests/Theme/TwigDebugMarkupTest.php
index 457fbad..205258a 100644
--- a/core/modules/system/src/Tests/Theme/TwigDebugMarkupTest.php
+++ b/core/modules/system/src/Tests/Theme/TwigDebugMarkupTest.php
@@ -49,7 +49,7 @@ public function testTwigDebugMarkup() {
     $this->assertTrue(strpos($output, "THEME HOOK: 'node'") !== FALSE, 'Theme call information found.');
     $this->assertTrue(strpos($output, '* node--1--full' . $extension . PHP_EOL . '   x node--1' . $extension . PHP_EOL . '   * node--page--full' . $extension . PHP_EOL . '   * node--page' . $extension . PHP_EOL . '   * node--full' . $extension . PHP_EOL . '   * node' . $extension) !== FALSE, 'Suggested template files found in order and node ID specific template shown as current template.');
     $this->assertEscaped('node--<script type="text/javascript">alert(\'yo\');</script>');
-    $template_filename = $templates['node__1']['path'] . '/' . $templates['node__1']['template'] . $extension;
+    $template_filename = $templates['node__1']->getPath() . '/' . $templates['node__1']->getTemplate() . $extension;
     $this->assertTrue(strpos($output, "BEGIN OUTPUT from '$template_filename'") !== FALSE, 'Full path to current template file found.');
 
     // Create another node and make sure the template suggestions shown in the
diff --git a/core/modules/system/system.module b/core/modules/system/system.module
index a1a6b71..d9f0105 100644
--- a/core/modules/system/system.module
+++ b/core/modules/system/system.module
@@ -176,21 +176,10 @@ function system_help($route_name, RouteMatchInterface $route_match) {
  * Implements hook_theme().
  */
 function system_theme() {
-  return array_merge(drupal_common_theme(), [
+  $system_templates = [
     // Normally theme suggestion templates are only picked up when they are in
     // themes. We explicitly define theme suggestions here so that the block
     // templates in core/modules/system/templates are picked up.
-    'block__system_branding_block' => [
-      'render element' => 'elements',
-      'base hook' => 'block',
-    ],
-    'block__system_messages_block' => [
-      'base hook' => 'block',
-    ],
-    'block__system_menu_block' => [
-      'render element' => 'elements',
-      'base hook' => 'block',
-    ],
     'system_themes_page' => [
       'variables' => [
         'theme_groups' => [],
@@ -268,7 +257,23 @@ function system_theme() {
       ],
       'template' => 'entity-add-list',
     ],
-  ]);
+  ];
+  if (\Drupal::moduleHandler()->moduleExists('block')) {
+    $system_templates += [
+      'block__system_branding_block' => [
+        'render element' => 'elements',
+        'base hook' => 'block',
+      ],
+      'block__system_messages_block' => [
+        'base hook' => 'block',
+      ],
+      'block__system_menu_block' => [
+        'render element' => 'elements',
+        'base hook' => 'block',
+      ],
+    ];
+  }
+  return array_merge(drupal_common_theme(), $system_templates);
 }
 
 /**
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..3aa462f 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().
@@ -17,9 +18,11 @@ function theme_test_theme($existing, $type, $theme, $path) {
     'function' => 'theme_theme_test',
   ];
   $items['theme_test_template_test'] = [
+    'variables' => [],
     'template' => 'theme_test.template_test',
   ];
   $items['theme_test_template_test_2'] = [
+    'variables' => [],
     'template' => 'theme_test.template_test',
   ];
   $items['theme_test_suggestion_provided'] = [
@@ -66,6 +69,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/modules/system/tests/modules/twig_theme_test/twig_theme_test.module b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
index a8b4086..5c818ce 100644
--- a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
+++ b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
@@ -14,6 +14,7 @@ function twig_theme_test_theme($existing, $type, $theme, $path) {
     'template' => 'twig_theme_test.filter',
   ];
   $items['twig_theme_test_php_variables'] = [
+    'variables' => [],
     'template' => 'twig_theme_test.php_variables',
   ];
   $items['twig_theme_test_trans'] = [
diff --git a/core/modules/system/tests/src/Functional/Theme/TwigSettingsTest.php b/core/modules/system/tests/src/Functional/Theme/TwigSettingsTest.php
index 0ad88ce..45bba22 100644
--- a/core/modules/system/tests/src/Functional/Theme/TwigSettingsTest.php
+++ b/core/modules/system/tests/src/Functional/Theme/TwigSettingsTest.php
@@ -95,7 +95,7 @@ public function testTwigCacheOverride() {
     // Get the template filename and the cache filename for
     // theme_test.template_test.html.twig.
     $info = $templates->get('theme_test_template_test');
-    $template_filename = $info['path'] . '/' . $info['template'] . $extension;
+    $template_filename = $info->getPath() . '/' . $info->getTemplate() . $extension;
     $cache_filename = $this->container->get('twig')->getCacheFilename($template_filename);
 
     // Navigate to the page and make sure the template gets cached.
diff --git a/core/modules/system/tests/themes/test_theme/test_theme.theme b/core/modules/system/tests/themes/test_theme/test_theme.theme
index aa399b6..a88a3d6 100644
--- a/core/modules/system/tests/themes/test_theme/test_theme.theme
+++ b/core/modules/system/tests/themes/test_theme/test_theme.theme
@@ -108,7 +108,11 @@ function test_theme_theme_test_function_suggestions__module_override($variables)
  * Implements hook_theme_registry_alter().
  */
 function test_theme_theme_registry_alter(&$registry) {
-  $registry['theme_test_template_test']['variables']['additional'] = 'value';
+  if (isset($registry['theme_test_template_test'])) {
+    $variables = $registry['theme_test_template_test']->getVariables();
+    $variables['additional'] = 'value';
+    $registry['theme_test_template_test']->setVariables($variables);
+  }
 }
 
 /**
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php b/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
index a962fec..1cf97c7 100644
--- a/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
@@ -76,7 +76,7 @@ public function testMultipleSubThemes() {
     $registry_base_theme = new Registry(\Drupal::root(), \Drupal::cache(), \Drupal::lock(), \Drupal::moduleHandler(), $theme_handler, \Drupal::service('theme.initialization'), 'test_basetheme');
     $registry_base_theme->setThemeManager(\Drupal::theme());
 
-    $preprocess_functions = $registry_subsub_theme->get()['theme_test_template_test']['preprocess functions'];
+    $preprocess_functions = $registry_subsub_theme->get()['theme_test_template_test']->getPreprocessFunctions();
     $this->assertIdentical([
       'template_preprocess',
       'test_basetheme_preprocess_theme_test_template_test',
@@ -84,20 +84,20 @@ public function testMultipleSubThemes() {
       'test_subsubtheme_preprocess_theme_test_template_test',
     ], $preprocess_functions);
 
-    $preprocess_functions = $registry_sub_theme->get()['theme_test_template_test']['preprocess functions'];
+    $preprocess_functions = $registry_sub_theme->get()['theme_test_template_test']->getPreprocessFunctions();
     $this->assertIdentical([
       'template_preprocess',
       'test_basetheme_preprocess_theme_test_template_test',
       'test_subtheme_preprocess_theme_test_template_test',
     ], $preprocess_functions);
 
-    $preprocess_functions = $registry_base_theme->get()['theme_test_template_test']['preprocess functions'];
+    $preprocess_functions = $registry_base_theme->get()['theme_test_template_test']->getPreprocessFunctions();
     $this->assertIdentical([
       'template_preprocess',
       'test_basetheme_preprocess_theme_test_template_test',
     ], $preprocess_functions);
 
-    $preprocess_functions = $registry_base_theme->get()['theme_test_function_suggestions']['preprocess functions'];
+    $preprocess_functions = $registry_base_theme->get()['theme_test_function_suggestions']->getPreprocessFunctions();
     $this->assertIdentical([
        'template_preprocess_theme_test_function_suggestions',
        'test_basetheme_preprocess_theme_test_function_suggestions',
@@ -124,7 +124,7 @@ public function testSuggestionPreprocessFunctions() {
     do {
       $hook .= "$suggestion";
       $expected_preprocess_functions[] = "test_theme_preprocess_$hook";
-      $preprocess_functions = $registry_theme->get()[$hook]['preprocess functions'];
+      $preprocess_functions = $registry_theme->get()[$hook]->getPreprocessFunctions();
       $this->assertIdentical($expected_preprocess_functions, $preprocess_functions, "$hook has correct preprocess functions.");
     } while ($suggestion = array_shift($suggestions));
 
@@ -135,13 +135,22 @@ public function testSuggestionPreprocessFunctions() {
       'test_theme_preprocess_theme_test_preprocess_suggestions__kitten',
     ];
 
-    $preprocess_functions = $registry_theme->get()['theme_test_preprocess_suggestions__kitten__meerkat']['preprocess functions'];
+    $preprocess_functions = $registry_theme->get()['theme_test_preprocess_suggestions__kitten__meerkat']->getPreprocessFunctions();
     $this->assertIdentical($expected_preprocess_functions, $preprocess_functions, 'Suggestion implemented as a function correctly inherits preprocess functions.');
 
-    $preprocess_functions = $registry_theme->get()['theme_test_preprocess_suggestions__kitten__bearcat']['preprocess functions'];
+    $preprocess_functions = $registry_theme->get()['theme_test_preprocess_suggestions__kitten__bearcat']->getPreprocessFunctions();
     $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->getPreprocessFunctions());
+    $this->assertSame('elements', $info->getRenderElement());
+
     $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->assertSame($name, $info->getName());
+      $this->assertTrue($info->getTemplate() || $info->getFunction());
+    }
   }
 
   /**
@@ -156,7 +165,7 @@ public function testThemeRegistryAlterByTheme() {
 
     $registry = new Registry(\Drupal::root(), \Drupal::cache(), \Drupal::lock(), \Drupal::moduleHandler(), $theme_handler, \Drupal::service('theme.initialization'), 'test_theme');
     $registry->setThemeManager(\Drupal::theme());
-    $this->assertEqual('value', $registry->get()['theme_test_template_test']['variables']['additional']);
+    $this->assertEqual('value', $registry->get()['theme_test_template_test']->getVariables()['additional']);
   }
 
   /**
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
index 40d31e6..b8406ee 100644
--- a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
@@ -89,13 +89,13 @@ public function testStableTemplateOverrides() {
     $registry_full = $registry->get();
 
     foreach ($registry_full as $hook => $info) {
-      if (isset($info['template'])) {
+      if ($info->getTemplate()) {
         // Allow skipping templates.
-        if (in_array($info['template'], $this->templatesToSkip)) {
+        if (in_array($info->getTemplate(), $this->templatesToSkip)) {
           continue;
         }
 
-        $this->assertEquals('core/themes/stable', $info['theme path'], $info['template'] . '.html.twig overridden in Stable.');
+        $this->assertEquals('core/themes/stable', $info->getThemePath(), $info->getTemplate() . '.html.twig overridden in Stable.');
       }
     }
   }
diff --git a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php
index f2cd22e..cb13349 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;
 
 /**
@@ -158,20 +159,20 @@ public function testGetRegistryForModule() {
     $this->assertArrayHasKey('theme_test_function_template_override', $registry);
 
     $this->assertArrayNotHasKey('test_theme_not_existing_function', $registry);
-    $this->assertFalse(in_array('test_stable_preprocess_theme_test_render_element', $registry['theme_test_render_element']['preprocess functions']));
+    $this->assertFalse(in_array('test_stable_preprocess_theme_test_render_element', $registry['theme_test_render_element']->getPreprocessFunctions()));
 
     $info = $registry['theme_test_function_suggestions'];
-    $this->assertEquals('module', $info['type']);
-    $this->assertEquals('core/modules/system/tests/modules/theme_test', $info['theme path']);
-    $this->assertEquals('theme_theme_test_function_suggestions', $info['function']);
-    $this->assertEquals([], $info['variables']);
+    $this->assertEquals('module', $info->getProviderType());
+    $this->assertEquals('core/modules/system/tests/modules/theme_test', $info->getThemePath());
+    $this->assertEquals('theme_theme_test_function_suggestions', $info->getFunction());
+    $this->assertEquals([], $info->getVariables());
 
     // The second call will initialize with the second theme. Ensure that this
     // returns a different object and the discovery for the second theme's
     // preprocess function worked.
     $other_registry = $this->registry->get();
     $this->assertNotSame($registry, $other_registry);
-    $this->assertTrue(in_array('test_stable_preprocess_theme_test_render_element', $other_registry['theme_test_render_element']['preprocess functions']));
+    $this->assertTrue(in_array('test_stable_preprocess_theme_test_render_element', $other_registry['theme_test_render_element']->getPreprocessFunctions()));
   }
 
   /**
@@ -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');
@@ -232,16 +247,19 @@ public function providerTestPostProcessExtension() {
       ],
       'hooks' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => ['explicit_preprocess_test_hook'],
         ],
       ],
       'expected' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
           ],
         ],
         'test_hook__suggestion' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'test_preprocess_test_hook__suggestion',
@@ -249,6 +267,7 @@ public function providerTestPostProcessExtension() {
           'base hook' => 'test_hook',
         ],
         'test_hook__suggestion__another' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'test_preprocess_test_hook__suggestion',
@@ -266,9 +285,11 @@ public function providerTestPostProcessExtension() {
       'defined_functions' => [],
       'hooks' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => ['explicit_preprocess_test_hook'],
         ],
         'test_hook__suggestion__another' => [
+          'render element' => 'elements',
           'base hook' => 'test_hook',
           'preprocess functions' => ['explicit_preprocess_test_hook__suggestion__another'],
           'incomplete preprocess functions' => TRUE,
@@ -276,11 +297,13 @@ public function providerTestPostProcessExtension() {
       ],
       'expected' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
           ],
         ],
         'test_hook__suggestion__another' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'explicit_preprocess_test_hook__suggestion__another',
@@ -298,9 +321,11 @@ public function providerTestPostProcessExtension() {
       ],
       'hooks' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => ['explicit_preprocess_test_hook'],
         ],
         'test_hook__suggestion__another' => [
+          'render element' => 'elements',
           'base hook' => 'test_hook',
           'preprocess functions' => ['explicit_preprocess_test_hook__suggestion__another'],
           'incomplete preprocess functions' => TRUE,
@@ -308,11 +333,13 @@ public function providerTestPostProcessExtension() {
       ],
       'expected' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
           ],
         ],
         'test_hook__suggestion' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'test_preprocess_test_hook__suggestion',
@@ -320,6 +347,7 @@ public function providerTestPostProcessExtension() {
           'base hook' => 'test_hook',
         ],
         'test_hook__suggestion__another' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'test_preprocess_test_hook__suggestion',
@@ -337,6 +365,7 @@ public function providerTestPostProcessExtension() {
       'defined_functions' => [],
       'hooks' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => ['explicit_preprocess_test_hook'],
         ],
         'child_hook' => [
@@ -347,11 +376,13 @@ public function providerTestPostProcessExtension() {
       ],
       'expected' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
           ],
         ],
         'child_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'explicit_preprocess_child_hook',
@@ -370,6 +401,7 @@ public function providerTestPostProcessExtension() {
       ],
       'hooks' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => ['explicit_preprocess_test_hook'],
         ],
         'child_hook' => [
@@ -380,11 +412,13 @@ public function providerTestPostProcessExtension() {
       ],
       'expected' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
           ],
         ],
         'child_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'explicit_preprocess_child_hook',
@@ -392,6 +426,7 @@ public function providerTestPostProcessExtension() {
           'base hook' => 'test_hook',
         ],
         'child_hook__suggestion' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'explicit_preprocess_child_hook',
@@ -400,6 +435,7 @@ public function providerTestPostProcessExtension() {
           'base hook' => 'test_hook',
         ],
         'child_hook__suggestion__another' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'explicit_preprocess_child_hook',
@@ -421,9 +457,11 @@ public function providerTestPostProcessExtension() {
       ],
       'hooks' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => ['explicit_preprocess_test_hook'],
         ],
         'alternate_base_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => ['explicit_preprocess_alternate_base_hook'],
         ],
         'test_hook__suggestion__another' => [
@@ -434,16 +472,19 @@ public function providerTestPostProcessExtension() {
       ],
       'expected' => [
         'test_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
           ],
         ],
         'alternate_base_hook' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_alternate_base_hook',
           ],
         ],
         'test_hook__suggestion' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_test_hook',
             'test_preprocess_test_hook__suggestion',
@@ -451,6 +492,7 @@ public function providerTestPostProcessExtension() {
           'base hook' => 'test_hook',
         ],
         'test_hook__suggestion__another' => [
+          'render element' => 'elements',
           'preprocess functions' => [
             'explicit_preprocess_alternate_base_hook',
             'explicit_preprocess_test_hook',
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..e8167f2
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Theme/ThemeHookTest.php
@@ -0,0 +1,34 @@
+<?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 testProcessUnexpectedValue() {
+    $hook = ThemeHook::create('foo');
+    $this->setExpectedException(\UnexpectedValueException::class, 'The "foo" theme hook must have either a render element, variables, or a base hook');
+    $hook->process('', '', []);
+  }
+
+  /**
+   * @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('', '', []);
+  }
+
+}
