Repository: mkdocs/mkdocs
Pull request #2962 (2022-09-03 16:09:50)

Rework ConfigOption schemas as class-based
This is NOT a breaking change, the old style keeps working.

Now plugin developers can make a subclass of `Config`, declare the schema of the config as fields of the class, and instances of this class will hold the processed config.

This better represents the relationship between what a config definition and a config instance is, now you think of config definitions as classes and parsed configs as instances.

We write these fields as descriptors and enable safe attribute-based access. Static analysis will be able to see when a missing field is accessed and, thanks to the Generic type annotations, even if the variable is of a mismatched type!

MkDocs' own config also becomes a subclass of `Config`, rather than just a sequence of field definitions. Then this `MkDocsConfig` is used as a more precise type restriction in many places like plugin events.

In the new style, fields become required by default, but can be wrapped into `config_options.Optional`. Validation of `SubConfig` is enabled by default as well.

`Config` subclasses can now also be passed to `SubConfig` and `ConfigItems`, rather than just a sequence of field definitions. This also recursively enables type safety.



Patch:
diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md
index b6f03027..8447f5a7 100644
--- a/docs/dev-guide/plugins.md
+++ b/docs/dev-guide/plugins.md
@@ -70,77 +70,177 @@ All `BasePlugin` subclasses contain the following attributes:
 
 #### config_scheme
 
-*   A tuple of configuration validation instances. Each item must consist of a
-    two item tuple in which the first item is the string name of the
-    configuration option and the second item is an instance of
-    `mkdocs.config.config_options.BaseConfigOption` or any of its subclasses.
-
-    For example, the following `config_scheme` defines three configuration options: `foo`, which accepts a string; `bar`, which accepts an integer; and `baz`, which accepts a boolean value.
-
-    ```python
-    class MyPlugin(mkdocs.plugins.BasePlugin):
-        config_scheme = (
-            ('foo', mkdocs.config.config_options.Type(str, default='a default value')),
-            ('bar', mkdocs.config.config_options.Type(int, default=0)),
-            ('baz', mkdocs.config.config_options.Type(bool, default=True))
-        )
-    ```
-
-    When the user's configuration is loaded, the above scheme will be used to
-    validate the configuration and fill in any defaults for settings not
-    provided by the user. The validation classes may be any of the classes
-    provided in `mkdocs.config.config_options` or a third party subclass defined
-    in the plugin.
-
-    Any settings provided by the user which fail validation or are not defined
-    in the `config_scheme` will raise a `mkdocs.config.base.ValidationError`.
+A tuple of configuration validation instances. Each item must consist of a
+two item tuple in which the first item is the string name of the
+configuration option and the second item is an instance of
+`mkdocs.config.config_options.BaseConfigOption` or any of its subclasses.
+
+For example, the following `config_scheme` defines three configuration options: `foo`, which accepts a string; `bar`, which accepts an integer; and `baz`, which accepts a boolean value.
+
+```python
+class MyPlugin(mkdocs.plugins.BasePlugin):
+    config_scheme = (
+        ('foo', mkdocs.config.config_options.Type(str, default='a default value')),
+        ('bar', mkdocs.config.config_options.Type(int, default=0)),
+        ('baz', mkdocs.config.config_options.Type(bool, default=True))
+    )
+```
+
+> NEW: **New in version 1.4.**
+>
+> ##### Subclassing `Config` to specify the config schema
+>
+> To get type safety benefits, if you're targeting only MkDocs 1.4+, define the config schema as a class instead:
+>
+> ```python
+> class MyPluginConfig(mkdocs.config.base.Config):
+>     foo = mkdocs.config.config_options.Type(str, default='a default value')
+>     bar = mkdocs.config.config_options.Type(int, default=0)
+>     baz = mkdocs.config.config_options.Type(bool, default=True)
+>
+> class MyPlugin(mkdocs.plugins.BasePlugin[MyPluginConfig]):
+>     ...
+> ```
+
+##### Examples of config definitions
+
+>! EXAMPLE:
+>
+> ```python
+> from mkdocs.config import base, config_options as c
+>
+> class _ValidationOptions(base.Config):
+>     enable = c.Type(bool, default=True)
+>     verbose = c.Type(bool, default=False)
+>     skip_checks = c.ListOfItems(c.Choice(('foo', 'bar', 'baz')), default=[])
+>
+> class MyPluginConfig(base.Config):
+>     definition_file = c.File(exists=True)  # required
+>     checksum_file = c.Optional(c.File(exists=True))  # can be None but must exist if specified
+>     validation = c.SubConfig(_ValidationOptions)
+> ```
+>
+> From the user's point of view `SubConfig` is similar to `Type(dict)`, it's just that it also retains full ability for validation: you define all valid keys and what each value should adhere to.
+>
+> And `ListOfItems` is similar to `Type(list)`, but again, we define the constraint that each value must adhere to.
+>
+> This accepts a config as follows:
+>
+> ```yaml
+> my_plugin:
+>   definition_file: configs/test.ini  # relative to mkdocs.yml
+>   validation:
+>     enable: !ENV [CI, false]
+>     verbose: true
+>     skip_checks:
+>       - foo
+>       - baz
+> ```
+<!-- -->
+>? EXAMPLE:
+>
+> ```python
+> import numbers
+> from mkdocs.config import base, config_options as c
+>
+> class _Rectangle(base.Config):
+>     width = c.Type(numbers.Real)  # required
+>     height = c.Type(numbers.Real)  # required
+>
+> class MyPluginConfig(base.Config):
+>     add_rectangles = c.ListOfItems(c.SubConfig(_Rectangle))  # required
+> ```
+>
+> In this example we define a list of complex items, and that's achieved by passing a concrete `SubConfig` to `ListOfItems`.
+>
+> This accepts a config as follows:
+>
+> ```yaml
+> my_plugin:
+>   add_rectangles:
+>     - width: 5
+>       height: 7
+>     - width: 12
+>       height: 2
+> ```
+
+When the user's configuration is loaded, the above scheme will be used to
+validate the configuration and fill in any defaults for settings not
+provided by the user. The validation classes may be any of the classes
+provided in `mkdocs.config.config_options` or a third party subclass defined
+in the plugin.
+
+Any settings provided by the user which fail validation or are not defined
+in the `config_scheme` will raise a `mkdocs.config.base.ValidationError`.
 
 #### config
 
-*   A dictionary of configuration options for the plugin, which is populated by
-    the `load_config` method after configuration validation has completed. Use
-    this attribute to access options provided by the user.
+A dictionary of configuration options for the plugin, which is populated by
+the `load_config` method after configuration validation has completed. Use
+this attribute to access options provided by the user.
+
+```python
+def on_pre_build(self, config, **kwargs):
+    if self.config['baz']:
+        # implement "baz" functionality here...
+```
 
-    ```python
-    def on_pre_build(self, config, **kwargs):
-        if self.config['bool_option']:
-            # implement "bool_option" functionality here...
-    ```
+> NEW: **New in version 1.4.**
+>
+> ##### Safe attribute-based access
+>
+> To get type safety benefits, if you're targeting only MkDocs 1.4+, access options as attributes instead:
+>
+> ```python
+> def on_pre_build(self, config, **kwargs):
+>     if self.config.baz:
+>         print(self.config.bar ** 2)  # OK, `int ** 2` is valid.
+> ```
 
 All `BasePlugin` subclasses contain the following method(s):
 
 #### load_config(options)
 
-*   Loads configuration from a dictionary of options. Returns a tuple of
-    `(errors, warnings)`. This method is called by MkDocs during configuration
-    validation and should not need to be called by the plugin.
+Loads configuration from a dictionary of options. Returns a tuple of
+`(errors, warnings)`. This method is called by MkDocs during configuration
+validation and should not need to be called by the plugin.
 
 #### on_&lt;event_name&gt;()
 
-*   Optional methods which define the behavior for specific [events]. The plugin
-    should define its behavior within these methods. Replace `<event_name>` with
-    the actual name of the event. For example, the `pre_build` event would be
-    defined in the `on_pre_build` method.
-
-    Most events accept one positional argument and various keyword arguments. It
-    is generally expected that the positional argument would be modified (or
-    replaced) by the plugin and returned. If nothing is returned (the method
-    returns `None`), then the original, unmodified object is used. The keyword
-    arguments are simply provided to give context and/or supply data which may
-    be used to determine how the positional argument should be modified. It is
-    good practice to accept keyword arguments as `**kwargs`. In the event that
-    additional keywords are provided to an event in a future version of MkDocs,
-    there will be no need to alter your plugin.
-
-    For example, the following event would add an additional static_template to
-    the theme config:
-
-    ```python
-    class MyPlugin(BasePlugin):
-        def on_config(self, config, **kwargs):
-            config['theme'].static_templates.add('my_template.html')
-            return config
-    ```
+Optional methods which define the behavior for specific [events]. The plugin
+should define its behavior within these methods. Replace `<event_name>` with
+the actual name of the event. For example, the `pre_build` event would be
+defined in the `on_pre_build` method.
+
+Most events accept one positional argument and various keyword arguments. It
+is generally expected that the positional argument would be modified (or
+replaced) by the plugin and returned. If nothing is returned (the method
+returns `None`), then the original, unmodified object is used. The keyword
+arguments are simply provided to give context and/or supply data which may
+be used to determine how the positional argument should be modified. It is
+good practice to accept keyword arguments as `**kwargs`. In the event that
+additional keywords are provided to an event in a future version of MkDocs,
+there will be no need to alter your plugin.
+
+For example, the following event would add an additional static_template to
+the theme config:
+
+```python
+class MyPlugin(BasePlugin):
+    def on_config(self, config, **kwargs):
+        config['theme'].static_templates.add('my_template.html')
+        return config
+```
+
+> NEW: **New in version 1.4.**
+>
+> To get type safety benefits, if you're targeting only MkDocs 1.4+, access config options as attributes instead:
+>
+> ```python
+> def on_config(self, config: MkDocsConfig):
+>     config.theme.static_templates.add('my_template.html')
+>         return config
+> ```
 
 ### Events
 
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index 940624c3..60716656 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -12,7 +12,7 @@ from jinja2.exceptions import TemplateNotFound
 
 import mkdocs
 from mkdocs import utils
-from mkdocs.config.base import Config
+from mkdocs.config.defaults import MkDocsConfig
 from mkdocs.exceptions import Abort, BuildError
 from mkdocs.structure.files import File, Files, get_files
 from mkdocs.structure.nav import Navigation, get_navigation
@@ -38,7 +38,7 @@ log.addFilter(DuplicateFilter())
 def get_context(
     nav: Navigation,
     files: Union[Sequence[File], Files],
-    config: Config,
+    config: MkDocsConfig,
     page: Optional[Page] = None,
     base_url: str = '',
 ) -> Dict[str, Any]:
@@ -48,9 +48,9 @@ def get_context(
     if page is not None:
         base_url = utils.get_relative_url('.', page.url)
 
-    extra_javascript = utils.create_media_urls(config['extra_javascript'], page, base_url)
+    extra_javascript = utils.create_media_urls(config.extra_javascript, page, base_url)
 
-    extra_css = utils.create_media_urls(config['extra_css'], page, base_url)
+    extra_css = utils.create_media_urls(config.extra_css, page, base_url)
 
     if isinstance(files, Files):
         files = files.documentation_pages()
@@ -69,15 +69,13 @@ def get_context(
 
 
 def _build_template(
-    name: str, template: jinja2.Template, files: Files, config: Config, nav: Navigation
+    name: str, template: jinja2.Template, files: Files, config: MkDocsConfig, nav: Navigation
 ) -> str:
     """
     Return rendered output for given template as a string.
     """
     # Run `pre_template` plugin events.
-    template = config['plugins'].run_event(
-        'pre_template', template, template_name=name, config=config
-    )
+    template = config.plugins.run_event('pre_template', template, template_name=name, config=config)
 
     if utils.is_error_template(name):
         # Force absolute URLs in the nav of error pages and account for the
@@ -85,27 +83,27 @@ def _build_template(
         # See https://github.com/mkdocs/mkdocs/issues/77.
         # However, if site_url is not set, assume the docs root and server root
         # are the same. See https://github.com/mkdocs/mkdocs/issues/1598.
-        base_url = urlsplit(config['site_url'] or '/').path
+        base_url = urlsplit(config.site_url or '/').path
     else:
         base_url = utils.get_relative_url('.', name)
 
     context = get_context(nav, files, config, base_url=base_url)
 
     # Run `template_context` plugin events.
-    context = config['plugins'].run_event(
+    context = config.plugins.run_event(
         'template_context', context, template_name=name, config=config
     )
 
     output = template.render(context)
 
     # Run `post_template` plugin events.
-    output = config['plugins'].run_event('post_template', output, template_name=name, config=config)
+    output = config.plugins.run_event('post_template', output, template_name=name, config=config)
 
     return output
 
 
 def _build_theme_template(
-    template_name: str, env: jinja2.Environment, files: Files, config: Config, nav: Navigation
+    template_name: str, env: jinja2.Environment, files: Files, config: MkDocsConfig, nav: Navigation
 ) -> None:
     """Build a template using the theme environment."""
 
@@ -120,7 +118,7 @@ def _build_theme_template(
     output = _build_template(template_name, template, files, config, nav)
 
     if output.strip():
-        output_path = os.path.join(config['site_dir'], template_name)
+        output_path = os.path.join(config.site_dir, template_name)
         utils.write_file(output.encode('utf-8'), output_path)
 
         if template_name == 'sitemap.xml':
@@ -136,7 +134,7 @@ def _build_theme_template(
         log.info(f"Template skipped: '{template_name}' generated empty output.")
 
 
-def _build_extra_template(template_name: str, files: Files, config: Config, nav: Navigation):
+def _build_extra_template(template_name: str, files: Files, config: MkDocsConfig, nav: Navigation):
     """Build user templates which are not part of the theme."""
 
     log.debug(f"Building extra template: {template_name}")
@@ -161,7 +159,7 @@ def _build_extra_template(template_name: str, files: Files, config: Config, nav:
         log.info(f"Template skipped: '{template_name}' generated empty output.")
 
 
-def _populate_page(page: Page, config: Config, files: Files, dirty: bool = False) -> None:
+def _populate_page(page: Page, config: MkDocsConfig, files: Files, dirty: bool = False) -> None:
     """Read page content from docs_dir and render Markdown."""
 
     try:
@@ -171,19 +169,19 @@ def _populate_page(page: Page, config: Config, files: Files, dirty: bool = False
             return
 
         # Run the `pre_page` plugin event
-        page = config['plugins'].run_event('pre_page', page, config=config, files=files)
+        page = config.plugins.run_event('pre_page', page, config=config, files=files)
 
         page.read_source(config)
 
         # Run `page_markdown` plugin events.
-        page.markdown = config['plugins'].run_event(
+        page.markdown = config.plugins.run_event(
             'page_markdown', page.markdown, page=page, config=config, files=files
         )
 
         page.render(config, files)
 
         # Run `page_content` plugin events.
-        page.content = config['plugins'].run_event(
+        page.content = config.plugins.run_event(
             'page_content', page.content, page=page, config=config, files=files
         )
     except Exception as e:
@@ -197,7 +195,7 @@ def _populate_page(page: Page, config: Config, files: Files, dirty: bool = False
 
 def _build_page(
     page: Page,
-    config: Config,
+    config: MkDocsConfig,
     doc_files: Sequence[File],
     nav: Navigation,
     env: jinja2.Environment,
@@ -225,7 +223,7 @@ def _build_page(
             template = env.get_template('main.html')
 
         # Run `page_context` plugin events.
-        context = config['plugins'].run_event(
+        context = config.plugins.run_event(
             'page_context', context, page=page, config=config, nav=nav
         )
 
@@ -233,7 +231,7 @@ def _build_page(
         output = template.render(context)
 
         # Run `post_page` plugin events.
-        output = config['plugins'].run_event('post_page', output, page=page, config=config)
+        output = config.plugins.run_event('post_page', output, page=page, config=config)
 
         # Write the output file.
         if output.strip():
@@ -254,7 +252,7 @@ def _build_page(
         raise
 
 
-def build(config: Config, live_server: bool = False, dirty: bool = False) -> None:
+def build(config: MkDocsConfig, live_server: bool = False, dirty: bool = False) -> None:
     """Perform a full site build."""
 
     logger = logging.getLogger('mkdocs')
@@ -262,21 +260,21 @@ def build(config: Config, live_server: bool = False, dirty: bool = False) -> Non
     # Add CountHandler for strict mode
     warning_counter = utils.CountHandler()
     warning_counter.setLevel(logging.WARNING)
-    if config['strict']:
+    if config.strict:
         logging.getLogger('mkdocs').addHandler(warning_counter)
 
     try:
         start = time.monotonic()
 
         # Run `config` plugin events.
-        config = config['plugins'].run_event('config', config)
+        config = config.plugins.run_event('config', config)
 
         # Run `pre_build` plugin events.
-        config['plugins'].run_event('pre_build', config=config)
+        config.plugins.run_event('pre_build', config=config)
 
         if not dirty:
             log.info("Cleaning site directory")
-            utils.clean_directory(config['site_dir'])
+            utils.clean_directory(config.site_dir)
         else:  # pragma: no cover
             # Warn user about problems that may occur with --dirty option
             log.warning(
@@ -285,23 +283,23 @@ def build(config: Config, live_server: bool = False, dirty: bool = False) -> Non
             )
 
         if not live_server:  # pragma: no cover
-            log.info(f"Building documentation to directory: {config['site_dir']}")
-            if dirty and site_directory_contains_stale_files(config['site_dir']):
+            log.info(f"Building documentation to directory: {config.site_dir}")
+            if dirty and site_directory_contains_stale_files(config.site_dir):
                 log.info("The directory contains stale files. Use --clean to remove them.")
 
         # First gather all data from all files/pages to ensure all data is consistent across all pages.
 
         files = get_files(config)
-        env = config['theme'].get_env()
+        env = config.theme.get_env()
         files.add_files_from_theme(env, config)
 
         # Run `files` plugin events.
-        files = config['plugins'].run_event('files', files, config=config)
+        files = config.plugins.run_event('files', files, config=config)
 
         nav = get_navigation(files, config)
 
         # Run `nav` plugin events.
-        nav = config['plugins'].run_event('nav', nav, config=config, files=files)
+        nav = config.plugins.run_event('nav', nav, config=config, files=files)
 
         log.debug("Reading markdown pages.")
         for file in files.documentation_pages():
@@ -310,7 +308,7 @@ def build(config: Config, live_server: bool = False, dirty: bool = False) -> Non
             _populate_page(file.page, config, files, dirty)
 
         # Run `env` plugin events.
-        env = config['plugins'].run_event('env', env, config=config, files=files)
+        env = config.plugins.run_event('env', env, config=config, files=files)
 
         # Start writing files to site_dir now that all data is gathered. Note that order matters. Files
         # with lower precedence get written first so that files with higher precedence can overwrite them.
@@ -318,10 +316,10 @@ def build(config: Config, live_server: bool = False, dirty: bool = False) -> Non
         log.debug("Copying static assets.")
         files.copy_static_files(dirty=dirty)
 
-        for template in config['theme'].static_templates:
+        for template in config.theme.static_templates:
             _build_theme_template(template, env, files, config, nav)
 
-        for template in config['extra_templates']:
+        for template in config.extra_templates:
             _build_extra_template(template, files, config, nav)
 
         log.debug("Building markdown pages.")
@@ -331,7 +329,7 @@ def build(config: Config, live_server: bool = False, dirty: bool = False) -> Non
             _build_page(file.page, config, doc_files, nav, env, dirty)
 
         # Run `post_build` plugin events.
-        config['plugins'].run_event('post_build', config=config)
+        config.plugins.run_event('post_build', config=config)
 
         counts = warning_counter.get_counts()
         if counts:
@@ -342,7 +340,7 @@ def build(config: Config, live_server: bool = False, dirty: bool = False) -> Non
 
     except Exception as e:
         # Run `build_error` plugin events.
-        config['plugins'].run_event('build_error', error=e)
+        config.plugins.run_event('build_error', error=e)
         if isinstance(e, BuildError):
             log.error(str(e))
             raise Abort('\nAborted with a BuildError!')
diff --git a/mkdocs/commands/gh_deploy.py b/mkdocs/commands/gh_deploy.py
index bf5d8845..5707d82a 100644
--- a/mkdocs/commands/gh_deploy.py
+++ b/mkdocs/commands/gh_deploy.py
@@ -9,6 +9,7 @@ import ghp_import
 from packaging import version
 
 import mkdocs
+from mkdocs.config.defaults import MkDocsConfig
 from mkdocs.exceptions import Abort
 
 log = logging.getLogger(__name__)
@@ -95,13 +96,18 @@ def _check_version(branch):
 
 
 def gh_deploy(
-    config, message=None, force=False, no_history=False, ignore_version=False, shell=False
+    config: MkDocsConfig,
+    message=None,
+    force=False,
+    no_history=False,
+    ignore_version=False,
+    shell=False,
 ):
     if not _is_cwd_git_repo():
         log.error('Cannot deploy - this directory does not appear to be a git repository')
 
-    remote_branch = config['remote_branch']
-    remote_name = config['remote_name']
+    remote_branch = config.remote_branch
+    remote_name = config.remote_name
 
     if not ignore_version:
         _check_version(remote_branch)
@@ -113,13 +119,13 @@ def gh_deploy(
 
     log.info(
         "Copying '%s' to '%s' branch and pushing to GitHub.",
-        config['site_dir'],
-        config['remote_branch'],
+        config.site_dir,
+        config.remote_branch,
     )
 
     try:
         ghp_import.ghp_import(
-            config['site_dir'],
+            config.site_dir,
             mesg=message,
             remote=remote_name,
             branch=remote_branch,
@@ -133,7 +139,7 @@ def gh_deploy(
         log.error(f"Failed to deploy to GitHub with error: \n{e.message}")
         raise Abort('Deployment Aborted!')
 
-    cname_file = os.path.join(config['site_dir'], 'CNAME')
+    cname_file = os.path.join(config.site_dir, 'CNAME')
     # Does this repository have a CNAME set for GitHub pages?
     if os.path.isfile(cname_file):
         # This GitHub pages repository has a CNAME configured.
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
index 75f72e54..1d3935bc 100644
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -11,6 +11,7 @@ import jinja2.exceptions
 
 from mkdocs.commands.build import build
 from mkdocs.config import load_config
+from mkdocs.config.defaults import MkDocsConfig
 from mkdocs.exceptions import Abort
 from mkdocs.livereload import LiveReloadServer
 
@@ -40,8 +41,8 @@ def serve(
     # string is returned. And it makes MkDocs temp dirs easier to identify.
     site_dir = tempfile.mkdtemp(prefix='mkdocs_')
 
-    def mount_path(config):
-        return urlsplit(config['site_url'] or '/').path
+    def mount_path(config: MkDocsConfig):
+        return urlsplit(config.site_url or '/').path
 
     get_config = functools.partial(
         load_config,
@@ -63,13 +64,13 @@ def serve(
             config = get_config()
 
         # combine CLI watch arguments with config file values
-        if config["watch"] is None:
-            config["watch"] = watch
+        if config.watch is None:
+            config.watch = watch
         else:
-            config["watch"].extend(watch)
+            config.watch.extend(watch)
 
         # Override a few config settings after validation
-        config['site_url'] = 'http://{}{}'.format(config['dev_addr'], mount_path(config))
+        config.site_url = f'http://{config.dev_addr}{mount_path(config)}'
 
         build(config, live_server=live_server, dirty=dirty)
 
@@ -80,7 +81,7 @@ def serve(
         # Perform the initial build
         builder(config)
 
-        host, port = config['dev_addr']
+        host, port = config.dev_addr
         server = LiveReloadServer(
             builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path(config)
         )
@@ -96,17 +97,17 @@ def serve(
 
         if live_server:
             # Watch the documentation files, the config file and the theme files.
-            server.watch(config['docs_dir'])
-            server.watch(config['config_file_path'])
+            server.watch(config.docs_dir)
+            server.watch(config.config_file_path)
 
             if watch_theme:
-                for d in config['theme'].dirs:
+                for d in config.theme.dirs:
                     server.watch(d)
 
             # Run `serve` plugin events.
-            server = config['plugins'].run_event('serve', server, config=config, builder=builder)
+            server = config.plugins.run_event('serve', server, config=config, builder=builder)
 
-            for item in config['watch']:
+            for item in config.watch:
                 server.watch(item)
 
         try:
diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py
index b5d4b1fc..f1cafd41 100644
--- a/mkdocs/config/base.py
+++ b/mkdocs/config/base.py
@@ -6,17 +6,36 @@ import os
 import sys
 from collections import UserDict
 from contextlib import contextmanager
-from typing import IO, Iterator, List, Optional, Sequence, Tuple, Union
+from typing import (
+    IO,
+    TYPE_CHECKING,
+    Generic,
+    Iterator,
+    List,
+    Optional,
+    Sequence,
+    Tuple,
+    TypeVar,
+    Union,
+    overload,
+)
 
 from yaml import YAMLError
 
 from mkdocs import exceptions, utils
 
+if TYPE_CHECKING:
+    from mkdocs.config.defaults import MkDocsConfig
+
+
 log = logging.getLogger('mkdocs.config')
 
 
-class BaseConfigOption:
-    def __init__(self) -> None:
+T = TypeVar('T')
+
+
+class BaseConfigOption(Generic[T]):
+    def __init__(self):
         self.warnings: List[str] = []
         self.default = None
 
@@ -32,7 +51,7 @@ class BaseConfigOption:
     def default(self, value):
         self._default = value
 
-    def validate(self, value):
+    def validate(self, value) -> T:
         return self.run_validation(value)
 
     def reset_warnings(self) -> None:
@@ -61,6 +80,29 @@ class BaseConfigOption:
         The post-validation process method should be implemented by subclasses.
         """
 
+    def __set_name__(self, owner, name):
+        self._name = name
+
+    @overload
+    def __get__(self, obj: Config, type=None) -> T:
+        ...
+
+    @overload
+    def __get__(self, obj, type=None) -> BaseConfigOption:
+        ...
+
+    def __get__(self, obj, type=None):
+        if not isinstance(obj, Config):
+            return self
+        return obj[self._name]
+
+    def __set__(self, obj, value: T):
+        if not isinstance(obj, Config):
+            raise AttributeError(
+                f"can't set attribute ({self._name}) because the parent is a {type(obj)} not a {Config}"
+            )
+        obj[self._name] = value
+
 
 class ValidationError(Exception):
     """Raised during the validation process of the config on errors."""
@@ -78,20 +120,45 @@ ConfigWarnings = List[Tuple[str, str]]
 
 class Config(UserDict):
     """
-    MkDocs Configuration dict
+    Base class for MkDocs configuration, plugin configuration (and sub-configuration) objects.
 
-    This is a fairly simple extension of a standard dictionary. It adds methods
-    for running validation on the structure and contents.
+    It should be subclassed and have `ConfigOption`s defined as attributes.
+    For examples, see mkdocs/contrib/search/__init__.py and mkdocs/config/defaults.py.
+
+    Behavior as it was prior to MkDocs 1.4 is now handled by LegacyConfig.
     """
 
-    def __init__(
-        self, schema: PlainConfigSchema, config_file_path: Optional[Union[str, bytes]] = None
-    ) -> None:
-        """
-        The schema is a Python dict which maps the config name to a validator.
-        """
-        self._schema = schema
-        self._schema_keys = set(dict(schema).keys())
+    _schema: PlainConfigSchema
+    config_file_path: Optional[str]
+
+    def __init_subclass__(cls):
+        schema = dict(getattr(cls, '_schema', ()))
+        for attr_name, attr in cls.__dict__.items():
+            if isinstance(attr, BaseConfigOption):
+                schema[attr_name] = attr
+        cls._schema = tuple(schema.items())
+
+        for attr_name, attr in cls._schema:
+            attr.required = True
+            if getattr(attr, '_legacy_required', None) is not None:
+                raise TypeError(
+                    f"{cls.__name__}.{attr_name}: "
+                    "Setting 'required' is unsupported in class-based configs. "
+                    "All values are required, or can be wrapped into config_options.Optional"
+                )
+
+    def __new__(cls, *args, **kwargs) -> Config:
+        """Compatibility: allow referring to `LegacyConfig(...)` constructor as `Config(...)`."""
+        if cls is Config:
+            return LegacyConfig(*args, **kwargs)
+        return super().__new__(cls)
+
+    def __init__(self, config_file_path: Optional[Union[str, bytes]] = None):
+        super().__init__()
+        self.user_configs: List[dict] = []
+        self.set_defaults()
+
+        self._schema_keys = {k for k, v in self._schema}
         # Ensure config_file_path is a Unicode string
         if config_file_path is not None and not isinstance(config_file_path, str):
             try:
@@ -100,10 +167,6 @@ class Config(UserDict):
             except UnicodeDecodeError:
                 raise ValidationError("config_file_path is not a Unicode string.")
         self.config_file_path = config_file_path
-        self.data = {}
-
-        self.user_configs: List[dict] = []
-        self.set_defaults()
 
     def set_defaults(self) -> None:
         """
@@ -187,7 +250,7 @@ class Config(UserDict):
             )
 
         self.user_configs.append(patch)
-        self.data.update(patch)
+        self.update(patch)
 
     def load_file(self, config_file: IO) -> None:
         """Load config options from the open file descriptor of a YAML file."""
@@ -204,12 +267,22 @@ class Config(UserDict):
 def get_schema(cls: type) -> PlainConfigSchema:
     """
     Extract ConfigOptions defined in a class (used just as a container) and put them into a schema tuple.
-
-    See mkdocs/config/defaults.py for an example.
     """
+    if issubclass(cls, Config):
+        return cls._schema
     return tuple((k, v) for k, v in cls.__dict__.items() if isinstance(v, BaseConfigOption))
 
 
+class LegacyConfig(Config):
+    """
+    A configuration object for plugins, as just a dict without type-safe attribute access.
+    """
+
+    def __init__(self, schema: PlainConfigSchema, config_file_path: Optional[str] = None):
+        self._schema = tuple((k, v) for k, v in schema)  # Re-create just for validation
+        super().__init__(config_file_path)
+
+
 @contextmanager
 def _open_config_file(config_file: Optional[Union[str, IO]]) -> Iterator[IO]:
     """
@@ -258,7 +331,7 @@ def _open_config_file(config_file: Optional[Union[str, IO]]) -> Iterator[IO]:
             result_config_file.close()
 
 
-def load_config(config_file: Optional[Union[str, IO]] = None, **kwargs) -> Config:
+def load_config(config_file: Optional[Union[str, IO]] = None, **kwargs) -> MkDocsConfig:
     """
     Load the configuration for a given file object or name
 
@@ -277,12 +350,10 @@ def load_config(config_file: Optional[Union[str, IO]] = None, **kwargs) -> Confi
             options.pop(key)
 
     with _open_config_file(config_file) as fd:
-        options['config_file_path'] = getattr(fd, 'name', '')
-
         # Initialize the config with the default schema.
-        from mkdocs.config import defaults
+        from mkdocs.config.defaults import MkDocsConfig
 
-        cfg = Config(schema=defaults.get_schema(), config_file_path=options['config_file_path'])
+        cfg = MkDocsConfig(config_file_path=getattr(fd, 'name', ''))
         # load the config file
         cfg.load_file(fd)
 
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 181f8888..20f7e8c2 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -9,35 +9,71 @@ import traceback
 import typing as t
 import warnings
 from collections import UserString
-from typing import Dict, NamedTuple
+from typing import Collection, Dict, Generic, List, NamedTuple, Tuple, TypeVar, Union, overload
 from urllib.parse import quote as urlquote
 from urllib.parse import urlsplit, urlunsplit
 
 import markdown
 
 from mkdocs import plugins, theme, utils
-from mkdocs.config.base import BaseConfigOption, Config, PlainConfigSchemaItem, ValidationError
+from mkdocs.config.base import (
+    BaseConfigOption,
+    Config,
+    LegacyConfig,
+    PlainConfigSchemaItem,
+    ValidationError,
+)
 from mkdocs.exceptions import ConfigurationError
 
+T = TypeVar('T')
+SomeConfig = TypeVar('SomeConfig', bound=Config)
 
-class SubConfig(BaseConfigOption):
+
+class SubConfig(Generic[SomeConfig], BaseConfigOption[SomeConfig]):
     """
     Subconfig Config Option
 
+    New: If targeting MkDocs 1.4+, please pass a subclass of Config to the
+    constructor, instead of the old style of a sequence of ConfigOption instances.
+    Validation is then enabled by default.
+
     A set of `config_options` grouped under a single config option.
     By default, validation errors and warnings resulting from validating
     `config_options` are ignored (`validate=False`). Users should typically
     enable validation with `validate=True`.
     """
 
-    def __init__(self, *config_options: PlainConfigSchemaItem, validate: bool = False):
+    @overload
+    def __init__(
+        self: SubConfig[SomeConfig], config_class: t.Type[SomeConfig], *, validate: bool = True
+    ):
+        """Create a sub-config in a type-safe way, using fields defined in a Config subclass."""
+
+    @overload
+    def __init__(
+        self: SubConfig[LegacyConfig],
+        *config_options: PlainConfigSchemaItem,
+        validate: bool = False,
+    ):
+        """Create an untyped sub-config, using directly passed fields."""
+
+    def __init__(self, *config_options, validate=None):
         super().__init__()
         self.default = {}
-        self.config_options = config_options
-        self._do_validation = validate
+        if (
+            len(config_options) == 1
+            and isinstance(config_options[0], type)
+            and issubclass(config_options[0], Config)
+        ):
+            if validate is None:
+                validate = True
+            (self._make_config,) = config_options
+        else:
+            self._make_config = functools.partial(LegacyConfig, config_options)
+        self._do_validation = bool(validate)
 
     def run_validation(self, value):
-        config = Config(self.config_options)
+        config = self._make_config()
         try:
             config.load_dict(value)
             failed, warnings = config.validate()
@@ -55,16 +91,27 @@ class SubConfig(BaseConfigOption):
         return config
 
 
-class OptionallyRequired(BaseConfigOption):
+class OptionallyRequired(Generic[T], BaseConfigOption[T]):
     """
+    Soft-deprecated, do not use.
+
     A subclass of BaseConfigOption that adds support for default values and
     required values. It is a base class for config options.
     """
 
-    def __init__(self, default=None, required: bool = False):
+    @overload
+    def __init__(self, default=None):
+        ...
+
+    @overload
+    def __init__(self, default=None, *, required: bool):
+        ...
+
+    def __init__(self, default=None, required=None):
         super().__init__()
         self.default = default
-        self.required = required
+        self._legacy_required = required
+        self.required = bool(required)
 
     def validate(self, value):
         """
@@ -85,16 +132,16 @@ class OptionallyRequired(BaseConfigOption):
         return self.run_validation(value)
 
 
-class ListOfItems(BaseConfigOption):
+class ListOfItems(Generic[T], BaseConfigOption[List[T]]):
     """
     Validates a homogeneous list of items.
 
     E.g. for `config_options.ListOfItems(config_options.Type(int))` a valid item is `[1, 2, 3]`.
     """
 
-    required = False
+    required: Union[bool, None] = None  # Only for subclasses to set.
 
-    def __init__(self, option_type: BaseConfigOption, default=[]):
+    def __init__(self, option_type: BaseConfigOption[T], default=None):
         super().__init__()
         self.default = default
         self.option_type = option_type
@@ -108,11 +155,14 @@ class ListOfItems(BaseConfigOption):
         self._key_name = key_name
 
     def run_validation(self, value):
-        if value is None and not self.required:
-            return self.default
-
+        if value is None:
+            if self.required or self.default is None:
+                raise ValidationError("Required configuration not provided.")
+            value = self.default
         if not isinstance(value, list):
             raise ValidationError(f'Expected a list of items, but a {type(value)} was given.')
+        if not value:  # Optimization for empty list
+            return value
 
         fake_config = Config(())
         try:
@@ -136,7 +186,7 @@ class ListOfItems(BaseConfigOption):
         return [fake_config[k] for k in fake_keys]
 
 
-class ConfigItems(ListOfItems):
+class ConfigItems(ListOfItems[LegacyConfig]):
     """
     Deprecated: Use `ListOfItems(SubConfig(...))` instead of `ConfigItems(...)`.
 
@@ -144,19 +194,36 @@ class ConfigItems(ListOfItems):
     options.
     """
 
-    def __init__(self, *config_options: PlainConfigSchemaItem, required: bool = False):
-        super().__init__(SubConfig(*config_options))
-        self.required = required
+    @overload
+    def __init__(self, *config_options: PlainConfigSchemaItem):
+        ...
+
+    @overload
+    def __init__(self, *config_options: PlainConfigSchemaItem, required: bool):
+        ...
+
+    def __init__(self, *config_options: PlainConfigSchemaItem, required=None):
+        super().__init__(SubConfig(*config_options), default=[])
+        self._legacy_required = required
+        self.required = bool(required)
 
 
-class Type(OptionallyRequired):
+class Type(Generic[T], OptionallyRequired[T]):
     """
     Type Config Option
 
     Validate the type of a config option against a given Python type.
     """
 
-    def __init__(self, type_, length: t.Optional[int] = None, **kwargs):
+    @overload
+    def __init__(self, type_: t.Type[T], length: t.Optional[int] = None, **kwargs):
+        ...
+
+    @overload
+    def __init__(self, type_: Tuple[t.Type[T], ...], length: t.Optional[int] = None, **kwargs):
+        ...
+
+    def __init__(self, type_, length=None, **kwargs):
         super().__init__(**kwargs)
         self._type = type_
         self.length = length
@@ -175,14 +242,14 @@ class Type(OptionallyRequired):
         raise ValidationError(msg)
 
 
-class Choice(OptionallyRequired):
+class Choice(Generic[T], OptionallyRequired[T]):
     """
     Choice Config Option
 
     Validate the config option against a strict set of values.
     """
 
-    def __init__(self, choices, default=None, **kwargs):
+    def __init__(self, choices: Collection[T], default: t.Optional[T] = None, **kwargs):
         super().__init__(default=default, **kwargs)
         try:
             length = len(choices)
@@ -281,7 +348,7 @@ class _IpAddressValue(NamedTuple):
         return f'{self.host}:{self.port}'
 
 
-class IpAddress(OptionallyRequired):
+class IpAddress(OptionallyRequired[_IpAddressValue]):
     """
     IpAddress Config Option
 
@@ -321,16 +388,24 @@ class IpAddress(OptionallyRequired):
             )
 
 
-class URL(OptionallyRequired):
+class URL(OptionallyRequired[str]):
     """
     URL Config Option
 
     Validate a URL by requiring a scheme is present.
     """
 
-    def __init__(self, default='', required: bool = False, is_dir: bool = False):
+    @overload
+    def __init__(self, default=None, *, is_dir: bool = False):
+        ...
+
+    @overload
+    def __init__(self, default=None, *, required: bool, is_dir: bool = False):
+        ...
+
+    def __init__(self, default=None, required=None, is_dir: bool = False):
         self.is_dir = is_dir
-        super().__init__(default, required)
+        super().__init__(default, required=required)
 
     def run_validation(self, value):
         if value == '':
@@ -349,6 +424,45 @@ class URL(OptionallyRequired):
         raise ValidationError("The URL isn't valid, it should include the http:// (scheme)")
 
 
+class Optional(Generic[T], BaseConfigOption[Union[T, None]]):
+    """Wraps a field and makes a None value possible for it when no value is set.
+
+    E.g. `my_field = config_options.Optional(config_options.Type(str))`
+    """
+
+    def __init__(self, config_option: BaseConfigOption[T]):
+        if config_option.default is not None:
+            raise ValueError(
+                f"This option already has a default ({config_option.default!r}) "
+                f"and doesn't need to be wrapped into Optional"
+            )
+        super().__init__()
+        self.option = config_option
+        self.warnings = config_option.warnings
+
+    def __getattr__(self, key):
+        if key in ('option', 'warnings'):
+            raise AttributeError
+        return getattr(self.option, key)
+
+    def pre_validation(self, config, key_name):
+        return self.option.pre_validation(config, key_name)
+
+    def run_validation(self, value):
+        if value is None:
+            return None
+        return self.option.validate(value)
+
+    def post_validation(self, config, key_name):
+        result = self.option.post_validation(config, key_name)
+        self.warnings = self.option.warnings
+        return result
+
+    def reset_warnings(self):
+        self.option.reset_warnings()
+        self.warnings = self.option.warnings
+
+
 class RepoURL(URL):
     def __init__(self, *args, **kwargs):
         warnings.warn(
@@ -387,8 +501,8 @@ class RepoURL(URL):
         config['edit_uri'] = edit_uri
 
 
-class EditURI(Type):
-    def __init__(self, repo_url_key):
+class EditURI(Type[str]):
+    def __init__(self, repo_url_key: str):
         super().__init__(str)
         self.repo_url_key = repo_url_key
 
@@ -410,7 +524,7 @@ class EditURI(Type):
         config[key_name] = edit_uri
 
 
-class EditURITemplate(OptionallyRequired):
+class EditURITemplate(BaseConfigOption[str]):
     class Formatter(string.Formatter):
         def convert_field(self, value, conversion):
             if conversion == 'q':
@@ -446,8 +560,8 @@ class EditURITemplate(OptionallyRequired):
             )
 
 
-class RepoName(Type):
-    def __init__(self, repo_url_key):
+class RepoName(Type[str]):
+    def __init__(self, repo_url_key: str):
         super().__init__(str)
         self.repo_url_key = repo_url_key
 
@@ -469,7 +583,7 @@ class RepoName(Type):
             config[key_name] = repo_name
 
 
-class FilesystemObject(Type):
+class FilesystemObject(Type[str]):
     """
     Base class for options that point to filesystem objects.
     """
@@ -532,7 +646,7 @@ class File(FilesystemObject):
     name = 'file'
 
 
-class ListOfPaths(ListOfItems):
+class ListOfPaths(ListOfItems[str]):
     """
     List of Paths Config Option
 
@@ -543,7 +657,15 @@ class ListOfPaths(ListOfItems):
         config_options.ListOfItems(config_options.File(exists=True))
     """
 
-    def __init__(self, default=[], required: bool = False):
+    @overload
+    def __init__(self, default=[]):
+        ...
+
+    @overload
+    def __init__(self, default=[], *, required: bool):
+        ...
+
+    def __init__(self, default=[], required=None):
         super().__init__(FilesystemObject(exists=True), default)
         self.required = required
 
@@ -579,7 +701,7 @@ class SiteDir(Dir):
             )
 
 
-class Theme(BaseConfigOption):
+class Theme(BaseConfigOption[theme.Theme]):
     """
     Theme Config Option
 
@@ -688,7 +810,7 @@ class Nav(OptionallyRequired):
             return f"a {type(value).__name__}: {value!r}"
 
 
-class Private(OptionallyRequired):
+class Private(BaseConfigOption):
     """
     Private Config Option
 
@@ -696,10 +818,11 @@ class Private(OptionallyRequired):
     """
 
     def run_validation(self, value):
-        raise ValidationError('For internal use only.')
+        if value is not None:
+            raise ValidationError('For internal use only.')
 
 
-class MarkdownExtensions(OptionallyRequired):
+class MarkdownExtensions(OptionallyRequired[List[str]]):
     """
     Markdown Extensions Config Option
 
@@ -712,9 +835,9 @@ class MarkdownExtensions(OptionallyRequired):
 
     def __init__(
         self,
-        builtins: t.Optional[list] = None,
+        builtins: t.Optional[List[str]] = None,
         configkey: str = 'mdx_configs',
-        default=[],
+        default: List[str] = [],
         **kwargs,
     ):
         super().__init__(default=default, **kwargs)
@@ -777,7 +900,7 @@ class MarkdownExtensions(OptionallyRequired):
         config[self.configkey] = self.configdata
 
 
-class Plugins(OptionallyRequired):
+class Plugins(OptionallyRequired[plugins.PluginCollection]):
     """
     Plugins config option.
 
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index a7529d97..2a731795 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -5,30 +5,32 @@ from mkdocs.config import config_options as c
 
 
 def get_schema() -> base.PlainConfigSchema:
-    return base.get_schema(_MkDocsConfig)
+    return MkDocsConfig._schema
 
 
 # NOTE: The order here is important. During validation some config options
 # depend on others. So, if config option A depends on B, then A should be
 # listed higher in the schema.
-class _MkDocsConfig:
-    config_file_path = c.Type(str)
+class MkDocsConfig(base.Config):
+    """The configuration of MkDocs itself (the root object of mkdocs.yml)."""
+
+    config_file_path: str = c.Optional(c.Type(str))  # type: ignore[assignment]
     """Reserved for internal use, stores the mkdocs.yml config file."""
 
-    site_name = c.Type(str, required=True)
+    site_name = c.Type(str)
     """The title to use for the documentation."""
 
-    nav = c.Nav()
+    nav = c.Optional(c.Nav())
     """Defines the structure of the navigation."""
     pages = c.Deprecated(removed=True, moved_to='nav')
 
-    site_url = c.URL(is_dir=True)
+    site_url = c.Optional(c.URL(is_dir=True))
     """The full URL to where the documentation will be hosted."""
 
-    site_description = c.Type(str)
+    site_description = c.Optional(c.Type(str))
     """A description for the documentation project that will be added to the
     HTML meta tags."""
-    site_author = c.Type(str)
+    site_author = c.Optional(c.Type(str))
     """The name of the author to add to the HTML meta tags."""
 
     theme = c.Theme(default='mkdocs')
@@ -40,7 +42,7 @@ class _MkDocsConfig:
     site_dir = c.SiteDir(default='site')
     """The directory where the site will be built to"""
 
-    copyright = c.Type(str)
+    copyright = c.Optional(c.Type(str))
     """A copyright notice to add to the footer of documentation."""
 
     google_analytics = c.Deprecated(
@@ -64,18 +66,18 @@ class _MkDocsConfig:
     True generates nicer URLs, but False is useful if browsing the output on
     a filesystem."""
 
-    repo_url = c.URL()
+    repo_url = c.Optional(c.URL())
     """Specify a link to the project source repo to be included
     in the documentation pages."""
 
-    repo_name = c.RepoName('repo_url')
+    repo_name = c.Optional(c.RepoName('repo_url'))
     """A name to use for the link to the project source repo.
     Default, If repo_url is unset then None, otherwise
     "GitHub", "Bitbucket" or "GitLab" for known url or Hostname
     for unknown urls."""
 
-    edit_uri_template = c.EditURITemplate('edit_uri')
-    edit_uri = c.EditURI('repo_url')
+    edit_uri_template = c.Optional(c.EditURITemplate('edit_uri'))
+    edit_uri = c.Optional(c.EditURI('repo_url'))
     """Specify a URI to the docs dir in the project source repo, relative to the
     repo_url. When set, a link directly to the page in the source repo will
     be added to the generated HTML. If repo_url is not set also, this option
diff --git a/mkdocs/contrib/search/__init__.py b/mkdocs/contrib/search/__init__.py
index 6dd7ba07..eb8c705c 100644
--- a/mkdocs/contrib/search/__init__.py
+++ b/mkdocs/contrib/search/__init__.py
@@ -2,12 +2,12 @@ from __future__ import annotations
 
 import logging
 import os
-from typing import Any, Dict
+from typing import Any, Dict, List
 
 from mkdocs import utils
 from mkdocs.config import base
 from mkdocs.config import config_options as c
-from mkdocs.config.base import Config
+from mkdocs.config.defaults import MkDocsConfig
 from mkdocs.contrib.search.search_index import SearchIndex
 from mkdocs.plugins import BasePlugin
 
@@ -15,7 +15,7 @@ log = logging.getLogger(__name__)
 base_path = os.path.dirname(os.path.abspath(__file__))
 
 
-class LangOption(c.OptionallyRequired):
+class LangOption(c.OptionallyRequired[List[str]]):
     """Validate Language(s) provided in config are known languages."""
 
     def get_lunr_supported_lang(self, lang):
@@ -46,42 +46,40 @@ class LangOption(c.OptionallyRequired):
         return value
 
 
-class _PluginConfig:
-    lang = LangOption()
+class _PluginConfig(base.Config):
+    lang = c.Optional(LangOption())
     separator = c.Type(str, default=r'[\s\-]+')
     min_search_length = c.Type(int, default=3)
     prebuild_index = c.Choice((False, True, 'node', 'python'), default=False)
     indexing = c.Choice(('full', 'sections', 'titles'), default='full')
 
 
-class SearchPlugin(BasePlugin):
+class SearchPlugin(BasePlugin[_PluginConfig]):
     """Add a search feature to MkDocs."""
 
-    config_scheme = base.get_schema(_PluginConfig)
-
-    def on_config(self, config: Config, **kwargs) -> Config:
+    def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig:
         "Add plugin templates and scripts to config."
-        if 'include_search_page' in config['theme'] and config['theme']['include_search_page']:
-            config['theme'].static_templates.add('search.html')
-        if not ('search_index_only' in config['theme'] and config['theme']['search_index_only']):
+        if 'include_search_page' in config.theme and config.theme['include_search_page']:
+            config.theme.static_templates.add('search.html')
+        if not ('search_index_only' in config.theme and config.theme['search_index_only']):
             path = os.path.join(base_path, 'templates')
-            config['theme'].dirs.append(path)
-            if 'search/main.js' not in config['extra_javascript']:
-                config['extra_javascript'].append('search/main.js')
-        if self.config['lang'] is None:
+            config.theme.dirs.append(path)
+            if 'search/main.js' not in config.extra_javascript:
+                config.extra_javascript.append('search/main.js')
+        if self.config.lang is None:
             # lang setting undefined. Set default based on theme locale
             validate = _PluginConfig.lang.run_validation
-            self.config['lang'] = validate(config['theme']['locale'].language)
+            self.config.lang = validate(config.theme['locale'].language)
         # The `python` method of `prebuild_index` is pending deprecation as of version 1.2.
         # TODO: Raise a deprecation warning in a future release (1.3?).
-        if self.config['prebuild_index'] == 'python':
+        if self.config.prebuild_index == 'python':
             log.info(
                 "The 'python' method of the search plugin's 'prebuild_index' config option "
                 "is pending deprecation and will not be supported in a future release."
             )
         return config
 
-    def on_pre_build(self, config: Config, **kwargs) -> None:
+    def on_pre_build(self, config: MkDocsConfig, **kwargs) -> None:
         "Create search index instance for later use."
         self.search_index = SearchIndex(**self.config)
 
@@ -89,24 +87,25 @@ class SearchPlugin(BasePlugin):
         "Add page to search index."
         self.search_index.add_entry_from_context(context['page'])
 
-    def on_post_build(self, config: Config, **kwargs) -> None:
+    def on_post_build(self, config: MkDocsConfig, **kwargs) -> None:
         "Build search index."
-        output_base_path = os.path.join(config['site_dir'], 'search')
+        output_base_path = os.path.join(config.site_dir, 'search')
         search_index = self.search_index.generate_search_index()
         json_output_path = os.path.join(output_base_path, 'search_index.json')
         utils.write_file(search_index.encode('utf-8'), json_output_path)
 
-        if not ('search_index_only' in config['theme'] and config['theme']['search_index_only']):
+        assert self.config.lang is not None
+        if not ('search_index_only' in config.theme and config.theme['search_index_only']):
             # Include language support files in output. Copy them directly
             # so that only the needed files are included.
             files = []
-            if len(self.config['lang']) > 1 or 'en' not in self.config['lang']:
+            if len(self.config.lang) > 1 or 'en' not in self.config.lang:
                 files.append('lunr.stemmer.support.js')
-            if len(self.config['lang']) > 1:
+            if len(self.config.lang) > 1:
                 files.append('lunr.multi.js')
-            if 'ja' in self.config['lang'] or 'jp' in self.config['lang']:
+            if 'ja' in self.config.lang or 'jp' in self.config.lang:
                 files.append('tinyseg.js')
-            for lang in self.config['lang']:
+            for lang in self.config.lang:
                 if lang != 'en':
                     files.append(f'lunr.{lang}.js')
 
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index 25d63f7a..49db0444 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -7,7 +7,19 @@ from __future__ import annotations
 import logging
 import sys
 from collections import OrderedDict
-from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, overload
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    Generic,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    TypeVar,
+    overload,
+)
 
 if sys.version_info >= (3, 10):
     from importlib.metadata import EntryPoint, entry_points
@@ -22,12 +34,16 @@ else:
 import jinja2.environment
 
 from mkdocs import utils
-from mkdocs.config.base import Config, ConfigErrors, ConfigWarnings, PlainConfigSchema
+from mkdocs.config.base import Config, ConfigErrors, ConfigWarnings, LegacyConfig, PlainConfigSchema
 from mkdocs.livereload import LiveReloadServer
 from mkdocs.structure.files import Files
 from mkdocs.structure.nav import Navigation
 from mkdocs.structure.pages import Page
 
+if TYPE_CHECKING:
+    from mkdocs.config.defaults import MkDocsConfig
+
+
 log = logging.getLogger('mkdocs.plugins')
 
 
@@ -47,22 +63,43 @@ def get_plugins() -> Dict[str, EntryPoint]:
     return pluginmap
 
 
-class BasePlugin:
+SomeConfig = TypeVar('SomeConfig', bound=Config)
+
+
+class BasePlugin(Generic[SomeConfig]):
     """
     Plugin base class.
 
     All plugins should subclass this class.
     """
 
+    config_class: Type[SomeConfig] = LegacyConfig  # type: ignore[assignment]
     config_scheme: PlainConfigSchema = ()
-    config: Config = {}  # type: ignore[assignment]
+    config: SomeConfig = {}  # type: ignore[assignment]
+
+    def __class_getitem__(cls, config_class: Type[Config]):
+        """Eliminates the need to write `config_class = FooConfig` when subclassing BasePlugin[FooConfig]"""
+        name = f'{cls.__name__}[{config_class.__name__}]'
+        return type(name, (cls,), dict(config_class=config_class))
+
+    def __init_subclass__(cls):
+        if not issubclass(cls.config_class, Config):
+            raise TypeError(
+                f"config_class {cls.config_class} must be a subclass of `mkdocs.config.base.Config`"
+            )
+        if cls.config_class is not LegacyConfig:
+            cls.config_scheme = cls.config_class._schema  # For compatibility.
 
     def load_config(
         self, options: Dict[str, Any], config_file_path: Optional[str] = None
     ) -> Tuple[ConfigErrors, ConfigWarnings]:
         """Load config from a dict of options. Returns a tuple of (errors, warnings)."""
 
-        self.config = Config(schema=self.config_scheme, config_file_path=config_file_path)
+        if self.config_class is LegacyConfig:
+            self.config = LegacyConfig(self.config_scheme, config_file_path=config_file_path)  # type: ignore
+        else:
+            self.config = self.config_class(config_file_path=config_file_path)
+
         self.config.load_dict(options)
 
         return self.config.validate()
@@ -104,7 +141,7 @@ class BasePlugin:
         """
 
     def on_serve(
-        self, server: LiveReloadServer, *, config: Config, builder: Callable
+        self, server: LiveReloadServer, *, config: MkDocsConfig, builder: Callable
     ) -> Optional[LiveReloadServer]:
         """
         The `serve` event is only called when the `serve` command is used during
@@ -125,7 +162,7 @@ class BasePlugin:
 
     # Global events
 
-    def on_config(self, config: Config) -> Optional[Config]:
+    def on_config(self, config: MkDocsConfig) -> Optional[Config]:
         """
         The `config` event is the first event called on build and is run immediately
         after the user configuration is loaded and validated. Any alterations to the
@@ -139,7 +176,7 @@ class BasePlugin:
         """
         return config
 
-    def on_pre_build(self, *, config: Config) -> None:
+    def on_pre_build(self, *, config: MkDocsConfig) -> None:
         """
         The `pre_build` event does not alter any variables. Use this event to call
         pre-build scripts.
@@ -148,7 +185,7 @@ class BasePlugin:
             config: global configuration object
         """
 
-    def on_files(self, files: Files, *, config: Config) -> Optional[Files]:
+    def on_files(self, files: Files, *, config: MkDocsConfig) -> Optional[Files]:
         """
         The `files` event is called after the files collection is populated from the
         `docs_dir`. Use this event to add, remove, or alter files in the
@@ -165,7 +202,9 @@ class BasePlugin:
         """
         return files
 
-    def on_nav(self, nav: Navigation, *, config: Config, files: Files) -> Optional[Navigation]:
+    def on_nav(
+        self, nav: Navigation, *, config: MkDocsConfig, files: Files
+    ) -> Optional[Navigation]:
         """
         The `nav` event is called after the site navigation is created and can
         be used to alter the site navigation.
@@ -181,7 +220,7 @@ class BasePlugin:
         return nav
 
     def on_env(
-        self, env: jinja2.Environment, *, config: Config, files: Files
+        self, env: jinja2.Environment, *, config: MkDocsConfig, files: Files
     ) -> Optional[jinja2.Environment]:
         """
         The `env` event is called after the Jinja template environment is created
@@ -198,7 +237,7 @@ class BasePlugin:
         """
         return env
 
-    def on_post_build(self, *, config: Config) -> None:
+    def on_post_build(self, *, config: MkDocsConfig) -> None:
         """
         The `post_build` event does not alter any variables. Use this event to call
         post-build scripts.
@@ -222,7 +261,7 @@ class BasePlugin:
     # Template events
 
     def on_pre_template(
-        self, template: jinja2.Template, *, template_name: str, config: Config
+        self, template: jinja2.Template, *, template_name: str, config: MkDocsConfig
     ) -> Optional[jinja2.Template]:
         """
         The `pre_template` event is called immediately after the subject template is
@@ -239,7 +278,7 @@ class BasePlugin:
         return template
 
     def on_template_context(
-        self, context: Dict[str, Any], *, template_name: str, config: Config
+        self, context: Dict[str, Any], *, template_name: str, config: MkDocsConfig
     ) -> Optional[Dict[str, Any]]:
         """
         The `template_context` event is called immediately after the context is created
@@ -257,7 +296,7 @@ class BasePlugin:
         return context
 
     def on_post_template(
-        self, output_content: str, *, template_name: str, config: Config
+        self, output_content: str, *, template_name: str, config: MkDocsConfig
     ) -> Optional[str]:
         """
         The `post_template` event is called after the template is rendered, but before
@@ -277,7 +316,7 @@ class BasePlugin:
 
     # Page events
 
-    def on_pre_page(self, page: Page, *, config: Config, files: Files) -> Optional[Page]:
+    def on_pre_page(self, page: Page, *, config: MkDocsConfig, files: Files) -> Optional[Page]:
         """
         The `pre_page` event is called before any actions are taken on the subject
         page and can be used to alter the `Page` instance.
@@ -292,7 +331,7 @@ class BasePlugin:
         """
         return page
 
-    def on_page_read_source(self, *, page: Page, config: Config) -> Optional[str]:
+    def on_page_read_source(self, *, page: Page, config: MkDocsConfig) -> Optional[str]:
         """
         The `on_page_read_source` event can replace the default mechanism to read
         the contents of a page's source from the filesystem.
@@ -308,7 +347,7 @@ class BasePlugin:
         return None
 
     def on_page_markdown(
-        self, markdown: str, *, page: Page, config: Config, files: Files
+        self, markdown: str, *, page: Page, config: MkDocsConfig, files: Files
     ) -> Optional[str]:
         """
         The `page_markdown` event is called after the page's markdown is loaded
@@ -327,7 +366,7 @@ class BasePlugin:
         return markdown
 
     def on_page_content(
-        self, html: str, *, page: Page, config: Config, files: Files
+        self, html: str, *, page: Page, config: MkDocsConfig, files: Files
     ) -> Optional[str]:
         """
         The `page_content` event is called after the Markdown text is rendered to
@@ -346,7 +385,7 @@ class BasePlugin:
         return html
 
     def on_page_context(
-        self, context: Dict[str, Any], *, page: Page, config: Config, nav: Navigation
+        self, context: Dict[str, Any], *, page: Page, config: MkDocsConfig, nav: Navigation
     ) -> Optional[Dict[str, Any]]:
         """
         The `page_context` event is called after the context for a page is created
@@ -363,7 +402,7 @@ class BasePlugin:
         """
         return context
 
-    def on_post_page(self, output: str, *, page: Page, config: Config) -> Optional[str]:
+    def on_post_page(self, output: str, *, page: Page, config: MkDocsConfig) -> Optional[str]:
         """
         The `post_page` event is called after the template is rendered, but
         before it is written to disc and can be used to alter the output of the
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index b9331376..e469c9e4 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -23,11 +23,12 @@ from urllib.parse import quote as urlquote
 import jinja2.environment
 
 from mkdocs import utils
-from mkdocs.config.base import Config
 
 if TYPE_CHECKING:
+    from mkdocs.config.defaults import MkDocsConfig
     from mkdocs.structure.pages import Page
 
+
 log = logging.getLogger(__name__)
 
 
@@ -103,7 +104,7 @@ class Files:
         """Return iterable of all CSS file objects."""
         return [file for file in self if file.is_css()]
 
-    def add_files_from_theme(self, env: jinja2.Environment, config: Config) -> None:
+    def add_files_from_theme(self, env: jinja2.Environment, config: MkDocsConfig) -> None:
         """Retrieve static files from Jinja environment and add to collection."""
 
         def filter(name):
@@ -112,7 +113,7 @@ class Files:
             # Exclude translation files
             patterns.append("locales/*")
             patterns.extend(f'*{x}' for x in utils.markdown_extensions)
-            patterns.extend(config['theme'].static_templates)
+            patterns.extend(config.theme.static_templates)
             for pattern in patterns:
                 if fnmatch.fnmatch(name.lower(), pattern):
                     return False
@@ -122,12 +123,10 @@ class Files:
             # Theme files do not override docs_dir files
             path = PurePath(path).as_posix()
             if path not in self.src_uris:
-                for dir in config['theme'].dirs:
+                for dir in config.theme.dirs:
                     # Find the first theme dir which contains path
                     if os.path.isfile(os.path.join(dir, path)):
-                        self.append(
-                            File(path, dir, config['site_dir'], config['use_directory_urls'])
-                        )
+                        self.append(File(path, dir, config.site_dir, config.use_directory_urls))
                         break
 
 
@@ -278,7 +277,7 @@ class File:
         return self.src_uri.endswith('.css')
 
 
-def get_files(config: Union[Config, Mapping[str, Any]]) -> Files:
+def get_files(config: Union[MkDocsConfig, Mapping[str, Any]]) -> Files:
     """Walk the `docs_dir` and return a Files collection."""
     files = []
     exclude = ['.*', '/templates']
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index 489ce395..1fff69c8 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -1,14 +1,17 @@
 from __future__ import annotations
 
 import logging
-from typing import Any, Iterator, List, Mapping, Optional, Type, TypeVar, Union
+from typing import TYPE_CHECKING, Any, Iterator, List, Mapping, Optional, Type, TypeVar, Union
 from urllib.parse import urlsplit
 
-from mkdocs.config.base import Config
 from mkdocs.structure.files import Files
 from mkdocs.structure.pages import Page
 from mkdocs.utils import nest_paths
 
+if TYPE_CHECKING:
+    from mkdocs.config.defaults import MkDocsConfig
+
+
 log = logging.getLogger(__name__)
 
 
@@ -142,7 +145,7 @@ class Link:
         return '{}{}'.format('    ' * depth, repr(self))
 
 
-def get_navigation(files: Files, config: Union[Config, Mapping[str, Any]]) -> Navigation:
+def get_navigation(files: Files, config: Union[MkDocsConfig, Mapping[str, Any]]) -> Navigation:
     """Build site navigation from config and files."""
     nav_config = config['nav'] or nest_paths(f.src_uri for f in files.documentation_pages())
     items = _data_to_navigation(nav_config, files, config)
@@ -189,7 +192,7 @@ def get_navigation(files: Files, config: Union[Config, Mapping[str, Any]]) -> Na
     return Navigation(items, pages)
 
 
-def _data_to_navigation(data, files: Files, config: Union[Config, Mapping[str, Any]]):
+def _data_to_navigation(data, files: Files, config: Union[MkDocsConfig, Mapping[str, Any]]):
     if isinstance(data, dict):
         return [
             _data_to_navigation((key, value), files, config)
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index ffe74d98..f24ad967 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -13,21 +13,22 @@ from markdown.extensions import Extension
 from markdown.treeprocessors import Treeprocessor
 from markdown.util import AMP_SUBSTITUTE
 
-from mkdocs.config.base import Config
 from mkdocs.structure.files import File, Files
 from mkdocs.structure.toc import get_toc
 from mkdocs.utils import get_build_date, get_markdown_title, meta
 
 if TYPE_CHECKING:
+    from mkdocs.config.defaults import MkDocsConfig
     from mkdocs.structure.nav import Section
     from mkdocs.structure.toc import TableOfContents
 
+
 log = logging.getLogger(__name__)
 
 
 class Page:
     def __init__(
-        self, title: Optional[str], file: File, config: Union[Config, Mapping[str, Any]]
+        self, title: Optional[str], file: File, config: Union[MkDocsConfig, Mapping[str, Any]]
     ) -> None:
         file.page = self
         self.file = file
@@ -208,7 +209,7 @@ class Page:
         else:
             self.edit_url = None
 
-    def read_source(self, config: Config) -> None:
+    def read_source(self, config: MkDocsConfig) -> None:
         source = config['plugins'].run_event('page_read_source', page=self, config=config)
         if source is None:
             try:
@@ -255,7 +256,7 @@ class Page:
 
         self.title = title
 
-    def render(self, config: Config, files: Files) -> None:
+    def render(self, config: MkDocsConfig, files: Files) -> None:
         """
         Convert the Markdown source file to HTML as per the config.
         """
diff --git a/requirements/lint.txt b/requirements/lint.txt
index 31c29a4b..c9704506 100644
--- a/requirements/lint.txt
+++ b/requirements/lint.txt
@@ -2,6 +2,7 @@ black
 isort
 flake8
 mypy
+typing-extensions
 types-setuptools
 types-PyYAML
 types-Markdown
diff --git a/tox.ini b/tox.ini
index 5a82e792..bac7c3fb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -34,6 +34,7 @@ commands={envbindir}/flake8 mkdocs
 [testenv:mypy]
 deps=
     mypy
+    typing-extensions
     types-setuptools
     types-PyYAML
     types-Markdown


Test patch:
diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py
index b15de7f5..0c838ebd 100644
--- a/mkdocs/tests/base.py
+++ b/mkdocs/tests/base.py
@@ -6,8 +6,8 @@ from tempfile import TemporaryDirectory
 
 import markdown
 
-from mkdocs import config, utils
-from mkdocs.config import defaults as config_defaults
+from mkdocs import utils
+from mkdocs.config.defaults import MkDocsConfig
 
 
 def dedent(text):
@@ -21,7 +21,7 @@ def get_markdown_toc(markdown_source):
     return md.toc_tokens
 
 
-def load_config(**cfg):
+def load_config(**cfg) -> MkDocsConfig:
     """Helper to build a simple config for testing."""
     path_base = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'integration', 'minimal')
     cfg = cfg or {}
@@ -32,9 +32,7 @@ def load_config(**cfg):
     if 'docs_dir' not in cfg:
         # Point to an actual dir to avoid a 'does not exist' error on validation.
         cfg['docs_dir'] = os.path.join(path_base, 'docs')
-    conf = config.Config(
-        schema=config_defaults.get_schema(), config_file_path=cfg['config_file_path']
-    )
+    conf = MkDocsConfig(config_file_path=cfg['config_file_path'])
     conf.load_dict(cfg)
 
     errors_warnings = conf.validate()
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index 3e649ef5..1861ba3b 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -15,9 +15,7 @@ from mkdocs.utils import meta
 def build_page(title, path, config, md_src=''):
     """Helper which returns a Page object."""
 
-    files = Files(
-        [File(path, config['docs_dir'], config['site_dir'], config['use_directory_urls'])]
-    )
+    files = Files([File(path, config.docs_dir, config.site_dir, config.use_directory_urls)])
     page = Page(title, list(files)[0], config)
     # Fake page.read_source()
     page.markdown, page.meta = meta.get_data(md_src)
@@ -26,7 +24,7 @@ def build_page(title, path, config, md_src=''):
 
 class BuildTests(PathAssertionMixin, unittest.TestCase):
     def _get_env_with_null_translations(self, config):
-        env = config['theme'].get_env()
+        env = config.theme.get_env()
         env.add_extension('jinja2.ext.i18n')
         env.install_null_translations()
         return env
diff --git a/mkdocs/tests/config/base_tests.py b/mkdocs/tests/config/base_tests.py
index b87dc420..70f96787 100644
--- a/mkdocs/tests/config/base_tests.py
+++ b/mkdocs/tests/config/base_tests.py
@@ -11,7 +11,7 @@ from mkdocs.tests.base import change_dir, tempdir
 
 class ConfigBaseTests(unittest.TestCase):
     def test_unrecognised_keys(self):
-        conf = base.Config(schema=defaults.get_schema())
+        conf = defaults.MkDocsConfig()
         conf.load_dict(
             {
                 'not_a_valid_config_option': "test",
@@ -31,7 +31,7 @@ class ConfigBaseTests(unittest.TestCase):
         )
 
     def test_missing_required(self):
-        conf = base.Config(schema=defaults.get_schema())
+        conf = defaults.MkDocsConfig()
 
         errors, warnings = conf.validate()
 
@@ -51,7 +51,7 @@ class ConfigBaseTests(unittest.TestCase):
         os.mkdir(os.path.join(temp_dir, 'docs'))
 
         cfg = base.load_config(config_file=config_file.name)
-        self.assertTrue(isinstance(cfg, base.Config))
+        self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
         self.assertEqual(cfg['site_name'], 'MkDocs Test')
 
     @tempdir()
@@ -64,7 +64,7 @@ class ConfigBaseTests(unittest.TestCase):
         os.mkdir(os.path.join(temp_dir, 'docs'))
         with change_dir(temp_dir):
             cfg = base.load_config(config_file=None)
-            self.assertTrue(isinstance(cfg, base.Config))
+            self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
             self.assertEqual(cfg['site_name'], 'MkDocs Test')
 
     @tempdir
@@ -77,7 +77,7 @@ class ConfigBaseTests(unittest.TestCase):
         os.mkdir(os.path.join(temp_dir, 'docs'))
         with change_dir(temp_dir):
             cfg = base.load_config(config_file=None)
-            self.assertTrue(isinstance(cfg, base.Config))
+            self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
             self.assertEqual(cfg['site_name'], 'MkDocs Test')
 
     @tempdir()
@@ -93,7 +93,7 @@ class ConfigBaseTests(unittest.TestCase):
         os.mkdir(os.path.join(temp_dir, 'docs'))
         with change_dir(temp_dir):
             cfg = base.load_config(config_file=None)
-            self.assertTrue(isinstance(cfg, base.Config))
+            self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
             self.assertEqual(cfg['site_name'], 'MkDocs Test1')
 
     def test_load_from_missing_file(self):
@@ -114,7 +114,7 @@ class ConfigBaseTests(unittest.TestCase):
         os.mkdir(os.path.join(temp_path, 'docs'))
 
         cfg = base.load_config(config_file=config_file)
-        self.assertTrue(isinstance(cfg, base.Config))
+        self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
         self.assertEqual(cfg['site_name'], 'MkDocs Test')
         # load_config will always close the file
         self.assertTrue(config_file.closed)
@@ -130,7 +130,7 @@ class ConfigBaseTests(unittest.TestCase):
         os.mkdir(os.path.join(temp_dir, 'docs'))
 
         cfg = base.load_config(config_file=config_file)
-        self.assertTrue(isinstance(cfg, base.Config))
+        self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
         self.assertEqual(cfg['site_name'], 'MkDocs Test')
 
     @tempdir
@@ -264,7 +264,7 @@ class ConfigBaseTests(unittest.TestCase):
         os.mkdir(docs_dir)
 
         cfg = base.load_config(config_file=config_file)
-        self.assertTrue(isinstance(cfg, base.Config))
+        self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
         self.assertEqual(cfg['site_name'], 'MkDocs Test')
         self.assertEqual(cfg['docs_dir'], docs_dir)
         self.assertEqual(cfg.config_file_path, config_fname)
diff --git a/mkdocs/tests/config/config_options_legacy_tests.py b/mkdocs/tests/config/config_options_legacy_tests.py
new file mode 100644
index 00000000..8a3d545b
--- /dev/null
+++ b/mkdocs/tests/config/config_options_legacy_tests.py
@@ -0,0 +1,1646 @@
+import contextlib
+import copy
+import io
+import os
+import re
+import sys
+import textwrap
+import unittest
+from typing import Any, Dict
+from unittest.mock import patch
+
+import mkdocs
+from mkdocs.config import base
+from mkdocs.config import config_options as c
+from mkdocs.tests.base import tempdir
+from mkdocs.utils import write_file, yaml_load
+
+
+class UnexpectedError(Exception):
+    pass
+
+
+class TestCase(unittest.TestCase):
+    @contextlib.contextmanager
+    def expect_error(self, **kwargs):
+        [(key, msg)] = kwargs.items()
+        with self.assertRaises(UnexpectedError) as cm:
+            yield
+        if isinstance(msg, re.Pattern):
+            self.assertRegex(str(cm.exception), f'^{key}="{msg.pattern}"$')
+        else:
+            self.assertEqual(f'{key}="{msg}"', str(cm.exception))
+
+    def get_config(
+        self,
+        schema: type,
+        cfg: Dict[str, Any],
+        warnings={},
+        config_file_path=None,
+    ):
+        config = base.LegacyConfig(base.get_schema(schema), config_file_path=config_file_path)
+        config.load_dict(cfg)
+        actual_errors, actual_warnings = config.validate()
+        if actual_errors:
+            raise UnexpectedError(', '.join(f'{key}="{msg}"' for key, msg in actual_errors))
+        self.assertEqual(warnings, dict(actual_warnings))
+        return config
+
+
+class OptionallyRequiredTest(TestCase):
+    def test_empty(self):
+        class Schema:
+            option = c.OptionallyRequired()
+
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(conf['option'], None)
+
+        self.assertEqual(Schema.option.required, False)
+
+    def test_required(self):
+        class Schema:
+            option = c.OptionallyRequired(required=True)
+
+        with self.expect_error(option="Required configuration not provided."):
+            self.get_config(Schema, {'option': None})
+
+        self.assertEqual(Schema.option.required, True)
+
+    def test_required_no_default(self):
+        class Schema:
+            option = c.OptionallyRequired(required=True)
+
+        conf = self.get_config(Schema, {'option': 2})
+        self.assertEqual(conf['option'], 2)
+
+    def test_default(self):
+        class Schema:
+            option = c.OptionallyRequired(default=1)
+
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(conf['option'], 1)
+
+    def test_replace_default(self):
+        class Schema:
+            option = c.OptionallyRequired(default=1)
+
+        conf = self.get_config(Schema, {'option': 2})
+        self.assertEqual(conf['option'], 2)
+
+
+class TypeTest(TestCase):
+    def test_single_type(self):
+        class Schema:
+            option = c.Type(str)
+
+        conf = self.get_config(Schema, {'option': "Testing"})
+        self.assertEqual(conf['option'], "Testing")
+
+    def test_multiple_types(self):
+        class Schema:
+            option = c.Type((list, tuple))
+
+        conf = self.get_config(Schema, {'option': [1, 2, 3]})
+        self.assertEqual(conf['option'], [1, 2, 3])
+
+        conf = self.get_config(Schema, {'option': (1, 2, 3)})
+        self.assertEqual(conf['option'], (1, 2, 3))
+
+        with self.expect_error(
+            option="Expected type: (<class 'list'>, <class 'tuple'>) but received: <class 'dict'>"
+        ):
+            self.get_config(Schema, {'option': {'a': 1}})
+
+    def test_length(self):
+        class Schema:
+            option = c.Type(str, length=7)
+
+        conf = self.get_config(Schema, {'option': "Testing"})
+        self.assertEqual(conf['option'], "Testing")
+
+        with self.expect_error(
+            option="Expected type: <class 'str'> with length 7 but received: 'Testing Long' with length 12"
+        ):
+            self.get_config(Schema, {'option': "Testing Long"})
+
+
+class ChoiceTest(TestCase):
+    def test_required(self):
+        class Schema:
+            option = c.Choice(('python', 'node'), required=True)
+
+        conf = self.get_config(Schema, {'option': 'python'})
+        self.assertEqual(conf['option'], 'python')
+
+    def test_optional(self):
+        class Schema:
+            option = c.Choice(('python', 'node'))
+
+        conf = self.get_config(Schema, {'option': 'python'})
+        self.assertEqual(conf['option'], 'python')
+
+        conf = self.get_config(Schema, {})
+        self.assertEqual(conf['option'], None)
+
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(conf['option'], None)
+
+    def test_default(self):
+        class Schema:
+            option = c.Choice(('a', 'b', 'c'), default='b')
+
+        conf = self.get_config(Schema, {})
+        self.assertEqual(conf['option'], 'b')
+
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(conf['option'], 'b')
+
+        with self.expect_error(option="Expected one of: ('a', 'b', 'c') but received: 'go'"):
+            self.get_config(Schema, {'option': 'go'})
+
+    def test_invalid_default(self):
+        with self.assertRaises(ValueError):
+            c.Choice(('a', 'b'), default='c')
+        with self.assertRaises(ValueError):
+            c.Choice(('a', 'b'), default='c', required=True)
+
+    def test_invalid_choice(self):
+        class Schema:
+            option = c.Choice(('python', 'node'))
+
+        with self.expect_error(option="Expected one of: ('python', 'node') but received: 'go'"):
+            self.get_config(Schema, {'option': 'go'})
+
+    def test_invalid_choices(self):
+        with self.assertRaises(ValueError):
+            c.Choice('')
+        with self.assertRaises(ValueError):
+            c.Choice([])
+        with self.assertRaises(ValueError):
+            c.Choice(5)
+
+
+class DeprecatedTest(TestCase):
+    def test_deprecated_option_simple(self):
+        class Schema:
+            d = c.Deprecated()
+
+        self.get_config(
+            Schema,
+            {'d': 'value'},
+            warnings=dict(
+                d="The configuration option 'd' has been deprecated and will be removed in a "
+                "future release of MkDocs."
+            ),
+        )
+
+    def test_deprecated_option_message(self):
+        class Schema:
+            d = c.Deprecated(message='custom message for {} key')
+
+        self.get_config(Schema, {'d': 'value'}, warnings={'d': 'custom message for d key'})
+
+    def test_deprecated_option_with_type(self):
+        class Schema:
+            d = c.Deprecated(option_type=c.Type(str))
+
+        self.get_config(
+            Schema,
+            {'d': 'value'},
+            warnings=dict(
+                d="The configuration option 'd' has been deprecated and will be removed in a "
+                "future release of MkDocs."
+            ),
+        )
+
+    def test_deprecated_option_with_invalid_type(self):
+        class Schema:
+            d = c.Deprecated(option_type=c.Type(list))
+
+        with self.expect_error(d="Expected type: <class 'list'> but received: <class 'str'>"):
+            self.get_config(
+                Schema,
+                {'d': 'value'},
+                warnings=dict(
+                    d="The configuration option 'd' has been deprecated and will be removed in a "
+                    "future release of MkDocs."
+                ),
+            )
+
+    def test_removed_option(self):
+        class Schema:
+            d = c.Deprecated(removed=True, moved_to='foo')
+
+        with self.expect_error(
+            d="The configuration option 'd' was removed from MkDocs. Use 'foo' instead.",
+        ):
+            self.get_config(Schema, {'d': 'value'})
+
+    def test_deprecated_option_with_type_undefined(self):
+        class Schema:
+            option = c.Deprecated(option_type=c.Type(str))
+
+        self.get_config(Schema, {'option': None})
+
+    def test_deprecated_option_move(self):
+        class Schema:
+            new = c.Type(str)
+            old = c.Deprecated(moved_to='new')
+
+        conf = self.get_config(
+            Schema,
+            {'old': 'value'},
+            warnings=dict(
+                old="The configuration option 'old' has been deprecated and will be removed in a "
+                "future release of MkDocs. Use 'new' instead."
+            ),
+        )
+        self.assertEqual(conf, {'new': 'value', 'old': None})
+
+    def test_deprecated_option_move_complex(self):
+        class Schema:
+            foo = c.Type(dict)
+            old = c.Deprecated(moved_to='foo.bar')
+
+        conf = self.get_config(
+            Schema,
+            {'old': 'value'},
+            warnings=dict(
+                old="The configuration option 'old' has been deprecated and will be removed in a "
+                "future release of MkDocs. Use 'foo.bar' instead."
+            ),
+        )
+        self.assertEqual(conf, {'foo': {'bar': 'value'}, 'old': None})
+
+    def test_deprecated_option_move_existing(self):
+        class Schema:
+            foo = c.Type(dict)
+            old = c.Deprecated(moved_to='foo.bar')
+
+        conf = self.get_config(
+            Schema,
+            {'old': 'value', 'foo': {'existing': 'existing'}},
+            warnings=dict(
+                old="The configuration option 'old' has been deprecated and will be removed in a "
+                "future release of MkDocs. Use 'foo.bar' instead."
+            ),
+        )
+        self.assertEqual(conf, {'foo': {'existing': 'existing', 'bar': 'value'}, 'old': None})
+
+    def test_deprecated_option_move_invalid(self):
+        class Schema:
+            foo = c.Type(dict)
+            old = c.Deprecated(moved_to='foo.bar')
+
+        with self.expect_error(foo="Expected type: <class 'dict'> but received: <class 'str'>"):
+            self.get_config(
+                Schema,
+                {'old': 'value', 'foo': 'wrong type'},
+                warnings=dict(
+                    old="The configuration option 'old' has been deprecated and will be removed in a "
+                    "future release of MkDocs. Use 'foo.bar' instead."
+                ),
+            )
+
+
+class IpAddressTest(TestCase):
+    class Schema:
+        option = c.IpAddress()
+
+    def test_valid_address(self):
+        addr = '127.0.0.1:8000'
+
+        conf = self.get_config(self.Schema, {'option': addr})
+        self.assertEqual(str(conf['option']), addr)
+        self.assertEqual(conf['option'].host, '127.0.0.1')
+        self.assertEqual(conf['option'].port, 8000)
+
+    def test_valid_IPv6_address(self):
+        addr = '::1:8000'
+
+        conf = self.get_config(self.Schema, {'option': addr})
+        self.assertEqual(str(conf['option']), addr)
+        self.assertEqual(conf['option'].host, '::1')
+        self.assertEqual(conf['option'].port, 8000)
+
+    def test_valid_full_IPv6_address(self):
+        addr = '[2001:db8:85a3::8a2e:370:7334]:123'
+
+        conf = self.get_config(self.Schema, {'option': addr})
+        self.assertEqual(conf['option'].host, '2001:db8:85a3::8a2e:370:7334')
+        self.assertEqual(conf['option'].port, 123)
+
+    def test_named_address(self):
+        addr = 'localhost:8000'
+
+        conf = self.get_config(self.Schema, {'option': addr})
+        self.assertEqual(str(conf['option']), addr)
+        self.assertEqual(conf['option'].host, 'localhost')
+        self.assertEqual(conf['option'].port, 8000)
+
+    def test_default_address(self):
+        addr = '127.0.0.1:8000'
+
+        class Schema:
+            option = c.IpAddress(default=addr)
+
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(str(conf['option']), addr)
+        self.assertEqual(conf['option'].host, '127.0.0.1')
+        self.assertEqual(conf['option'].port, 8000)
+
+    @unittest.skipIf(
+        sys.version_info < (3, 9, 5),
+        "Leading zeros allowed in IP addresses before Python3.9.5",
+    )
+    def test_invalid_leading_zeros(self):
+        with self.expect_error(
+            option="'127.000.000.001' does not appear to be an IPv4 or IPv6 address"
+        ):
+            self.get_config(self.Schema, {'option': '127.000.000.001:8000'})
+
+    def test_invalid_address_range(self):
+        with self.expect_error(option="'277.0.0.1' does not appear to be an IPv4 or IPv6 address"):
+            self.get_config(self.Schema, {'option': '277.0.0.1:8000'})
+
+    def test_invalid_address_format(self):
+        with self.expect_error(option="Must be a string of format 'IP:PORT'"):
+            self.get_config(self.Schema, {'option': '127.0.0.18000'})
+
+    def test_invalid_address_type(self):
+        with self.expect_error(option="Must be a string of format 'IP:PORT'"):
+            self.get_config(self.Schema, {'option': 123})
+
+    def test_invalid_address_port(self):
+        with self.expect_error(option="'foo' is not a valid port"):
+            self.get_config(self.Schema, {'option': '127.0.0.1:foo'})
+
+    def test_invalid_address_missing_port(self):
+        with self.expect_error(option="Must be a string of format 'IP:PORT'"):
+            self.get_config(self.Schema, {'option': '127.0.0.1'})
+
+    def test_unsupported_address(self):
+        class Schema:
+            dev_addr = c.IpAddress()
+
+        self.get_config(
+            Schema,
+            {'dev_addr': '0.0.0.0:8000'},
+            warnings=dict(
+                dev_addr="The use of the IP address '0.0.0.0' suggests a production "
+                "environment or the use of a proxy to connect to the MkDocs "
+                "server. However, the MkDocs' server is intended for local "
+                "development purposes only. Please use a third party "
+                "production-ready server instead."
+            ),
+        )
+
+    def test_unsupported_IPv6_address(self):
+        class Schema:
+            dev_addr = c.IpAddress()
+
+        self.get_config(
+            Schema,
+            {'dev_addr': ':::8000'},
+            warnings=dict(
+                dev_addr="The use of the IP address '::' suggests a production environment "
+                "or the use of a proxy to connect to the MkDocs server. However, "
+                "the MkDocs' server is intended for local development purposes "
+                "only. Please use a third party production-ready server instead."
+            ),
+        )
+
+
+class URLTest(TestCase):
+    def test_valid_url(self):
+        class Schema:
+            option = c.URL()
+
+        conf = self.get_config(Schema, {'option': "https://mkdocs.org"})
+        self.assertEqual(conf['option'], "https://mkdocs.org")
+
+        conf = self.get_config(Schema, {'option': ""})
+        self.assertEqual(conf['option'], "")
+
+    def test_valid_url_is_dir(self):
+        class Schema:
+            option = c.URL(is_dir=True)
+
+        conf = self.get_config(Schema, {'option': "http://mkdocs.org/"})
+        self.assertEqual(conf['option'], "http://mkdocs.org/")
+
+        conf = self.get_config(Schema, {'option': "https://mkdocs.org"})
+        self.assertEqual(conf['option'], "https://mkdocs.org/")
+
+    def test_optional(self):
+        class Schema:
+            option = c.URL(is_dir=True)
+
+        conf = self.get_config(Schema, {'option': ''})
+        self.assertEqual(conf['option'], '')
+
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(conf['option'], None)
+
+    def test_invalid_url(self):
+        class Schema:
+            option = c.URL(required=True)
+
+        with self.expect_error(option="Required configuration not provided."):
+            self.get_config(Schema, {'option': None})
+
+        for url in "www.mkdocs.org", "//mkdocs.org/test", "http:/mkdocs.org/", "/hello/":
+            with self.subTest(url=url):
+                with self.expect_error(
+                    option="The URL isn't valid, it should include the http:// (scheme)"
+                ):
+                    self.get_config(Schema, {'option': url})
+
+    def test_invalid_type(self):
+        class Schema:
+            option = c.URL()
+
+        with self.expect_error(option="Unable to parse the URL."):
+            self.get_config(Schema, {'option': 1})
+
+
+class EditURITest(TestCase):
+    class Schema:
+        repo_url = c.URL()
+        repo_name = c.RepoName('repo_url')
+        edit_uri_template = c.EditURITemplate('edit_uri')
+        edit_uri = c.EditURI('repo_url')
+
+    def test_repo_name_github(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://github.com/mkdocs/mkdocs"},
+        )
+        self.assertEqual(conf['repo_name'], "GitHub")
+
+    def test_repo_name_bitbucket(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://bitbucket.org/gutworth/six/"},
+        )
+        self.assertEqual(conf['repo_name'], "Bitbucket")
+
+    def test_repo_name_gitlab(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://gitlab.com/gitlab-org/gitlab-ce/"},
+        )
+        self.assertEqual(conf['repo_name'], "GitLab")
+
+    def test_repo_name_custom(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://launchpad.net/python-tuskarclient"},
+        )
+        self.assertEqual(conf['repo_name'], "Launchpad")
+
+    def test_edit_uri_github(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://github.com/mkdocs/mkdocs"},
+        )
+        self.assertEqual(conf['edit_uri'], 'edit/master/docs/')
+        self.assertEqual(conf['repo_url'], "https://github.com/mkdocs/mkdocs")
+
+    def test_edit_uri_bitbucket(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://bitbucket.org/gutworth/six/"},
+        )
+        self.assertEqual(conf['edit_uri'], 'src/default/docs/')
+        self.assertEqual(conf['repo_url'], "https://bitbucket.org/gutworth/six/")
+
+    def test_edit_uri_gitlab(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://gitlab.com/gitlab-org/gitlab-ce/"},
+        )
+        self.assertEqual(conf['edit_uri'], 'edit/master/docs/')
+
+    def test_edit_uri_custom(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://launchpad.net/python-tuskarclient"},
+        )
+        self.assertEqual(conf['edit_uri'], None)
+        self.assertEqual(conf['repo_url'], "https://launchpad.net/python-tuskarclient")
+
+    def test_repo_name_custom_and_empty_edit_uri(self):
+        conf = self.get_config(
+            self.Schema,
+            {'repo_url': "https://github.com/mkdocs/mkdocs", 'repo_name': 'mkdocs'},
+        )
+        self.assertEqual(conf['edit_uri'], 'edit/master/docs/')
+
+    def test_edit_uri_template_ok(self):
+        conf = self.get_config(
+            self.Schema,
+            {
+                'repo_url': "https://github.com/mkdocs/mkdocs",
+                'edit_uri_template': 'edit/foo/docs/{path}',
+            },
+        )
+        self.assertEqual(conf['edit_uri_template'], 'edit/foo/docs/{path}')
+
+    def test_edit_uri_template_errors(self):
+        with self.expect_error(
+            edit_uri_template=re.compile(r'.*[{}].*')  # Complains about unclosed '{' or missing '}'
+        ):
+            self.get_config(
+                self.Schema,
+                {
+                    'repo_url': "https://github.com/mkdocs/mkdocs",
+                    'edit_uri_template': 'edit/master/{path',
+                },
+            )
+
+        with self.expect_error(edit_uri_template=re.compile(r'.*\bz\b.*')):
+            self.get_config(
+                self.Schema,
+                {
+                    'repo_url': "https://github.com/mkdocs/mkdocs",
+                    'edit_uri_template': 'edit/master/{path!z}',
+                },
+            )
+
+        with self.expect_error(edit_uri_template="Unknown template substitute: 'foo'"):
+            self.get_config(
+                self.Schema,
+                {
+                    'repo_url': "https://github.com/mkdocs/mkdocs",
+                    'edit_uri_template': 'edit/master/{foo}',
+                },
+            )
+
+    def test_edit_uri_template_warning(self):
+        conf = self.get_config(
+            self.Schema,
+            {
+                'repo_url': "https://github.com/mkdocs/mkdocs",
+                'edit_uri': 'edit',
+                'edit_uri_template': 'edit/master/{path}',
+            },
+            warnings=dict(
+                edit_uri_template="The option 'edit_uri' has no effect when 'edit_uri_template' is set."
+            ),
+        )
+        self.assertEqual(conf['edit_uri_template'], 'edit/master/{path}')
+
+
+class ListOfItemsTest(TestCase):
+    def test_int_type(self):
+        class Schema:
+            option = c.ListOfItems(c.Type(int))
+
+        conf = self.get_config(Schema, {'option': [1, 2, 3]})
+        self.assertEqual(conf['option'], [1, 2, 3])
+
+        with self.expect_error(
+            option="Expected type: <class 'int'> but received: <class 'NoneType'>"
+        ):
+            conf = self.get_config(Schema, {'option': [1, None, 3]})
+
+    def test_combined_float_type(self):
+        class Schema:
+            option = c.ListOfItems(c.Type((int, float)))
+
+        conf = self.get_config(Schema, {'option': [1.4, 2, 3]})
+        self.assertEqual(conf['option'], [1.4, 2, 3])
+
+        with self.expect_error(
+            option="Expected type: (<class 'int'>, <class 'float'>) but received: <class 'str'>"
+        ):
+            self.get_config(Schema, {'option': ['a']})
+
+    def test_list_default(self):
+        class Schema:
+            option = c.ListOfItems(c.Type(int), default=[])
+
+        conf = self.get_config(Schema, {})
+        self.assertEqual(conf['option'], [])
+
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(conf['option'], [])
+
+    def test_none_without_default(self):
+        class Schema:
+            option = c.ListOfItems(c.Type(str))
+
+        with self.expect_error(option="Required configuration not provided."):
+            conf = self.get_config(Schema, {})
+
+        with self.expect_error(option="Required configuration not provided."):
+            conf = self.get_config(Schema, {'option': None})
+
+        conf = self.get_config(Schema, {'option': ['foo']})
+        self.assertEqual(conf['option'], ['foo'])
+
+    def test_string_not_a_list_of_strings(self):
+        class Schema:
+            option = c.ListOfItems(c.Type(str))
+
+        with self.expect_error(option="Expected a list of items, but a <class 'str'> was given."):
+            self.get_config(Schema, {'option': 'foo'})
+
+    def test_post_validation_error(self):
+        class Schema:
+            option = c.ListOfItems(c.IpAddress())
+
+        with self.expect_error(option="'asdf' is not a valid port"):
+            self.get_config(Schema, {'option': ["localhost:8000", "1.2.3.4:asdf"]})
+
+
+class FilesystemObjectTest(TestCase):
+    def test_valid_dir(self):
+        for cls in c.Dir, c.FilesystemObject:
+            with self.subTest(cls):
+                d = os.path.dirname(__file__)
+
+                class Schema:
+                    option = cls(exists=True)
+
+                conf = self.get_config(Schema, {'option': d})
+                self.assertEqual(conf['option'], d)
+
+    def test_valid_file(self):
+        for cls in c.File, c.FilesystemObject:
+            with self.subTest(cls):
+                f = __file__
+
+                class Schema:
+                    option = cls(exists=True)
+
+                conf = self.get_config(Schema, {'option': f})
+                self.assertEqual(conf['option'], f)
+
+    def test_missing_without_exists(self):
+        for cls in c.Dir, c.File, c.FilesystemObject:
+            with self.subTest(cls):
+                d = os.path.join("not", "a", "real", "path", "I", "hope")
+
+                class Schema:
+                    option = cls()
+
+                conf = self.get_config(Schema, {'option': d})
+                self.assertEqual(conf['option'], os.path.abspath(d))
+
+    def test_missing_but_required(self):
+        for cls in c.Dir, c.File, c.FilesystemObject:
+            with self.subTest(cls):
+                d = os.path.join("not", "a", "real", "path", "I", "hope")
+
+                class Schema:
+                    option = cls(exists=True)
+
+                with self.expect_error(option=re.compile(r"The path '.+' isn't an existing .+")):
+                    self.get_config(Schema, {'option': d})
+
+    def test_not_a_dir(self):
+        d = __file__
+
+        class Schema:
+            option = c.Dir(exists=True)
+
+        with self.expect_error(option=re.compile(r"The path '.+' isn't an existing directory.")):
+            self.get_config(Schema, {'option': d})
+
+    def test_not_a_file(self):
+        d = os.path.dirname(__file__)
+
+        class Schema:
+            option = c.File(exists=True)
+
+        with self.expect_error(option=re.compile(r"The path '.+' isn't an existing file.")):
+            self.get_config(Schema, {'option': d})
+
+    def test_incorrect_type_error(self):
+        for cls in c.Dir, c.File, c.FilesystemObject:
+            with self.subTest(cls):
+
+                class Schema:
+                    option = cls()
+
+                with self.expect_error(
+                    option="Expected type: <class 'str'> but received: <class 'int'>"
+                ):
+                    self.get_config(Schema, {'option': 1})
+                with self.expect_error(
+                    option="Expected type: <class 'str'> but received: <class 'list'>"
+                ):
+                    self.get_config(Schema, {'option': []})
+
+    def test_with_unicode(self):
+        for cls in c.Dir, c.File, c.FilesystemObject:
+            with self.subTest(cls):
+
+                class Schema:
+                    dir = cls()
+
+                conf = self.get_config(Schema, {'dir': 'юникод'})
+                self.assertIsInstance(conf['dir'], str)
+
+    def test_dir_bytes(self):
+        class Schema:
+            dir = c.Dir()
+
+        with self.expect_error(dir="Expected type: <class 'str'> but received: <class 'bytes'>"):
+            self.get_config(Schema, {'dir': b'foo'})
+
+    def test_config_dir_prepended(self):
+        for cls in c.Dir, c.File, c.FilesystemObject:
+            with self.subTest(cls):
+                base_path = os.path.dirname(os.path.abspath(__file__))
+
+                class Schema:
+                    dir = cls()
+
+                conf = self.get_config(
+                    Schema,
+                    {'dir': 'foo'},
+                    config_file_path=os.path.join(base_path, 'mkdocs.yml'),
+                )
+                self.assertEqual(conf['dir'], os.path.join(base_path, 'foo'))
+
+    def test_site_dir_is_config_dir_fails(self):
+        class Schema:
+            dir = c.DocsDir()
+
+        with self.expect_error(
+            dir="The 'dir' should not be the parent directory of the config file. "
+            "Use a child directory instead so that the 'dir' is a sibling of the config file."
+        ):
+            self.get_config(
+                Schema,
+                {'dir': '.'},
+                config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
+            )
+
+
+class ListOfPathsTest(TestCase):
+    def test_valid_path(self):
+        paths = [os.path.dirname(__file__)]
+
+        class Schema:
+            option = c.ListOfPaths()
+
+        self.get_config(Schema, {'option': paths})
+
+    def test_missing_path(self):
+        paths = [os.path.join("does", "not", "exist", "i", "hope")]
+
+        class Schema:
+            option = c.ListOfPaths()
+
+        with self.expect_error(
+            option=f"The path '{paths[0]}' isn't an existing file or directory."
+        ):
+            self.get_config(Schema, {'option': paths})
+
+    def test_non_path(self):
+        paths = [os.path.dirname(__file__), None]
+
+        class Schema:
+            option = c.ListOfPaths()
+
+        with self.expect_error(
+            option="Expected type: <class 'str'> but received: <class 'NoneType'>"
+        ):
+            self.get_config(Schema, {'option': paths})
+
+    def test_empty_list(self):
+        class Schema:
+            option = c.ListOfPaths()
+
+        conf = self.get_config(Schema, {'option': []})
+        self.assertEqual(conf['option'], [])
+
+    def test_non_list(self):
+        paths = os.path.dirname(__file__)
+
+        class Schema:
+            option = c.ListOfPaths()
+
+        with self.expect_error(option="Expected a list of items, but a <class 'str'> was given."):
+            self.get_config(Schema, {'option': paths})
+
+    def test_file(self):
+        paths = [__file__]
+
+        class Schema:
+            option = c.ListOfPaths()
+
+        self.get_config(Schema, {'option': paths})
+
+    @tempdir()
+    def test_paths_localized_to_config(self, base_path):
+        with open(os.path.join(base_path, 'foo'), 'w') as f:
+            f.write('hi')
+
+        class Schema:
+            watch = c.ListOfPaths()
+
+        conf = self.get_config(
+            Schema,
+            {'watch': ['foo']},
+            config_file_path=os.path.join(base_path, 'mkdocs.yml'),
+        )
+
+        self.assertEqual(conf['watch'], [os.path.join(base_path, 'foo')])
+
+
+class SiteDirTest(TestCase):
+    class Schema:
+        site_dir = c.SiteDir()
+        docs_dir = c.Dir()
+
+    def test_doc_dir_in_site_dir(self):
+        j = os.path.join
+        # The parent dir is not the same on every system, so use the actual dir name
+        parent_dir = mkdocs.__file__.split(os.sep)[-3]
+
+        test_configs = (
+            {'docs_dir': j('site', 'docs'), 'site_dir': 'site'},
+            {'docs_dir': 'docs', 'site_dir': '.'},
+            {'docs_dir': '.', 'site_dir': '.'},
+            {'docs_dir': 'docs', 'site_dir': ''},
+            {'docs_dir': '', 'site_dir': ''},
+            {'docs_dir': j('..', parent_dir, 'docs'), 'site_dir': 'docs'},
+            {'docs_dir': 'docs', 'site_dir': '/'},
+        )
+
+        for test_config in test_configs:
+            with self.subTest(test_config):
+                with self.expect_error(
+                    site_dir=re.compile(r"The 'docs_dir' should not be within the 'site_dir'.*")
+                ):
+                    self.get_config(self.Schema, test_config)
+
+    def test_site_dir_in_docs_dir(self):
+        j = os.path.join
+
+        test_configs = (
+            {'docs_dir': 'docs', 'site_dir': j('docs', 'site')},
+            {'docs_dir': '.', 'site_dir': 'site'},
+            {'docs_dir': '', 'site_dir': 'site'},
+            {'docs_dir': '/', 'site_dir': 'site'},
+        )
+
+        for test_config in test_configs:
+            with self.subTest(test_config):
+                with self.expect_error(
+                    site_dir=re.compile(r"The 'site_dir' should not be within the 'docs_dir'.*")
+                ):
+                    self.get_config(self.Schema, test_config)
+
+    def test_common_prefix(self):
+        """Legitimate settings with common prefixes should not fail validation."""
+
+        test_configs = (
+            {'docs_dir': 'docs', 'site_dir': 'docs-site'},
+            {'docs_dir': 'site-docs', 'site_dir': 'site'},
+        )
+
+        for test_config in test_configs:
+            with self.subTest(test_config):
+                self.get_config(self.Schema, test_config)
+
+
+class ThemeTest(TestCase):
+    def test_theme_as_string(self):
+        class Schema:
+            option = c.Theme()
+
+        conf = self.get_config(Schema, {'option': "mkdocs"})
+        self.assertEqual(conf['option'].name, 'mkdocs')
+
+    def test_uninstalled_theme_as_string(self):
+        class Schema:
+            option = c.Theme()
+
+        with self.expect_error(
+            option=re.compile(
+                r"Unrecognised theme name: 'mkdocs2'. The available installed themes are: .+"
+            )
+        ):
+            self.get_config(Schema, {'option': "mkdocs2"})
+
+    def test_theme_default(self):
+        class Schema:
+            option = c.Theme(default='mkdocs')
+
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(conf['option'].name, 'mkdocs')
+
+    def test_theme_as_simple_config(self):
+        config = {
+            'name': 'mkdocs',
+        }
+
+        class Schema:
+            option = c.Theme()
+
+        conf = self.get_config(Schema, {'option': config})
+        self.assertEqual(conf['option'].name, 'mkdocs')
+
+    @tempdir()
+    def test_theme_as_complex_config(self, custom_dir):
+        config = {
+            'name': 'mkdocs',
+            'custom_dir': custom_dir,
+            'static_templates': ['sitemap.html'],
+            'show_sidebar': False,
+        }
+
+        class Schema:
+            option = c.Theme()
+
+        conf = self.get_config(Schema, {'option': config})
+        self.assertEqual(conf['option'].name, 'mkdocs')
+        self.assertIn(custom_dir, conf['option'].dirs)
+        self.assertEqual(
+            conf['option'].static_templates,
+            {'404.html', 'sitemap.xml', 'sitemap.html'},
+        )
+        self.assertEqual(conf['option']['show_sidebar'], False)
+
+    def test_theme_name_is_none(self):
+        config = {
+            'name': None,
+        }
+
+        class Schema:
+            option = c.Theme()
+
+        with self.expect_error(
+            option="At least one of 'option.name' or 'option.custom_dir' must be defined."
+        ):
+            self.get_config(Schema, {'option': config})
+
+    def test_theme_config_missing_name(self):
+        config = {
+            'custom_dir': 'custom',
+        }
+
+        class Schema:
+            option = c.Theme()
+
+        with self.expect_error(option="No theme name set."):
+            self.get_config(Schema, {'option': config})
+
+    def test_uninstalled_theme_as_config(self):
+        config = {
+            'name': 'mkdocs2',
+        }
+
+        class Schema:
+            option = c.Theme()
+
+        with self.expect_error(
+            option=re.compile(
+                r"Unrecognised theme name: 'mkdocs2'. The available installed themes are: .+"
+            )
+        ):
+            self.get_config(Schema, {'option': config})
+
+    def test_theme_invalid_type(self):
+        config = ['mkdocs2']
+
+        class Schema:
+            option = c.Theme()
+
+        with self.expect_error(
+            option="Invalid type <class 'list'>. Expected a string or key/value pairs."
+        ):
+            self.get_config(Schema, {'option': config})
+
+    def test_post_validation_none_theme_name_and_missing_custom_dir(self):
+        config = {
+            'theme': {
+                'name': None,
+            },
+        }
+
+        class Schema:
+            theme = c.Theme()
+
+        with self.expect_error(
+            theme="At least one of 'theme.name' or 'theme.custom_dir' must be defined."
+        ):
+            self.get_config(Schema, config)
+
+    @tempdir()
+    def test_post_validation_inexisting_custom_dir(self, abs_base_path):
+        path = os.path.join(abs_base_path, 'inexisting_custom_dir')
+        config = {
+            'theme': {
+                'name': None,
+                'custom_dir': path,
+            },
+        }
+
+        class Schema:
+            theme = c.Theme()
+
+        with self.expect_error(
+            theme=f"The path set in theme.custom_dir ('{path}') does not exist."
+        ):
+            self.get_config(Schema, config)
+
+    def test_post_validation_locale_none(self):
+        config = {
+            'theme': {
+                'name': 'mkdocs',
+                'locale': None,
+            },
+        }
+
+        class Schema:
+            theme = c.Theme()
+
+        with self.expect_error(theme="'theme.locale' must be a string."):
+            self.get_config(Schema, config)
+
+    def test_post_validation_locale_invalid_type(self):
+        config = {
+            'theme': {
+                'name': 'mkdocs',
+                'locale': 0,
+            },
+        }
+
+        class Schema:
+            theme = c.Theme()
+
+        with self.expect_error(theme="'theme.locale' must be a string."):
+            self.get_config(Schema, config)
+
+    def test_post_validation_locale(self):
+        config = {
+            'theme': {
+                'name': 'mkdocs',
+                'locale': 'fr',
+            },
+        }
+
+        class Schema:
+            theme = c.Theme()
+
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['theme']['locale'].language, 'fr')
+
+
+class NavTest(TestCase):
+    class Schema:
+        option = c.Nav()
+
+    def test_old_format(self):
+        with self.expect_error(
+            option="Expected nav item to be a string or dict, got a list: ['index.md']"
+        ):
+            self.get_config(self.Schema, {'option': [['index.md']]})
+
+    def test_provided_dict(self):
+        conf = self.get_config(self.Schema, {'option': ['index.md', {"Page": "page.md"}]})
+        self.assertEqual(conf['option'], ['index.md', {'Page': 'page.md'}])
+
+    def test_provided_empty(self):
+        conf = self.get_config(self.Schema, {'option': []})
+        self.assertEqual(conf['option'], None)
+
+    def test_normal_nav(self):
+        nav_yaml = textwrap.dedent(
+            '''\
+            - Home: index.md
+            - getting-started.md
+            - User Guide:
+                - Overview: user-guide/index.md
+                - Installation: user-guide/installation.md
+            '''
+        )
+        nav = yaml_load(io.StringIO(nav_yaml))
+
+        conf = self.get_config(self.Schema, {'option': nav})
+        self.assertEqual(conf['option'], nav)
+
+    def test_invalid_type_dict(self):
+        with self.expect_error(option="Expected nav to be a list, got a dict: {}"):
+            self.get_config(self.Schema, {'option': {}})
+
+    def test_invalid_type_int(self):
+        with self.expect_error(option="Expected nav to be a list, got a int: 5"):
+            self.get_config(self.Schema, {'option': 5})
+
+    def test_invalid_item_int(self):
+        with self.expect_error(option="Expected nav item to be a string or dict, got a int: 1"):
+            self.get_config(self.Schema, {'option': [1]})
+
+    def test_invalid_item_none(self):
+        with self.expect_error(option="Expected nav item to be a string or dict, got None"):
+            self.get_config(self.Schema, {'option': [None]})
+
+    def test_invalid_children_config_int(self):
+        with self.expect_error(option="Expected nav to be a list, got a int: 1"):
+            self.get_config(self.Schema, {'option': [{"foo.md": [{"bar.md": 1}]}]})
+
+    def test_invalid_children_config_none(self):
+        with self.expect_error(option="Expected nav to be a list, got None"):
+            self.get_config(self.Schema, {'option': [{"foo.md": None}]})
+
+    def test_invalid_children_empty_dict(self):
+        nav = ['foo', {}]
+        with self.expect_error(option="Expected nav item to be a dict of size 1, got a dict: {}"):
+            self.get_config(self.Schema, {'option': nav})
+
+    def test_invalid_nested_list(self):
+        nav = [{'aaa': [[{"bbb": "user-guide/index.md"}]]}]
+        with self.expect_error(
+            option="Expected nav item to be a string or dict, got a list: [{'bbb': 'user-guide/index.md'}]"
+        ):
+            self.get_config(self.Schema, {'option': nav})
+
+    def test_invalid_children_oversized_dict(self):
+        nav = [{"aaa": [{"bbb": "user-guide/index.md", "ccc": "user-guide/installation.md"}]}]
+        with self.expect_error(
+            option="Expected nav item to be a dict of size 1, got dict with keys ('bbb', 'ccc')"
+        ):
+            self.get_config(self.Schema, {'option': nav})
+
+    def test_warns_for_dict(self):
+        self.get_config(
+            self.Schema,
+            {'option': [{"a": {"b": "c.md", "d": "e.md"}}]},
+            warnings=dict(option="Expected nav to be a list, got dict with keys ('b', 'd')"),
+        )
+
+
+class PrivateTest(TestCase):
+    def test_defined(self):
+        class Schema:
+            option = c.Private()
+
+        with self.expect_error(option="For internal use only."):
+            self.get_config(Schema, {'option': 'somevalue'})
+
+
+class SubConfigTest(TestCase):
+    def test_subconfig_wrong_type(self):
+        # Test that an error is raised if subconfig does not receive a dict
+        class Schema:
+            option = c.SubConfig()
+
+        for val in "not_a_dict", ("not_a_dict",), ["not_a_dict"]:
+            with self.subTest(val):
+                with self.expect_error(
+                    option=re.compile(
+                        r"The configuration is invalid. The expected type was a key value mapping "
+                        r"\(a python dict\) but we got an object of type: .+"
+                    )
+                ):
+                    self.get_config(Schema, {'option': val})
+
+    def test_subconfig_ignored(self):
+        """Default behaviour of subconfig: validation is ignored"""
+
+        # Nominal
+        class Schema:
+            option = c.SubConfig(('cc', c.Choice(('foo', 'bar'))))
+
+        conf = self.get_config(Schema, {'option': {'cc': 'foo'}})
+        self.assertEqual(conf, {'option': {'cc': 'foo'}})
+
+        # Invalid option: No error
+        class Schema:
+            option = c.SubConfig(('cc', c.Choice(('foo', 'bar'))))
+
+        conf = self.get_config(Schema, {'option': {'cc': True}})
+        self.assertEqual(conf, {'option': {'cc': True}})
+
+        # Missing option: Will be considered optional with default None
+        class Schema:
+            option = c.SubConfig(('cc', c.Choice(('foo', 'bar'))))
+
+        conf = self.get_config(Schema, {'option': {}})
+        self.assertEqual(conf, {'option': {'cc': None}})
+
+        # Unknown option: No warning
+        class Schema:
+            option = c.SubConfig(('cc', c.Choice(('foo', 'bar'))))
+
+        conf = self.get_config(Schema, {'option': {'unknown_key_is_ok': 0}})
+        self.assertEqual(conf, {'option': {'cc': None, 'unknown_key_is_ok': 0}})
+
+    def test_subconfig_unknown_option(self):
+        class Schema:
+            option = c.SubConfig(validate=True)
+
+        conf = self.get_config(
+            Schema,
+            {'option': {'unknown': 0}},
+            warnings=dict(option=('unknown', 'Unrecognised configuration name: unknown')),
+        )
+        self.assertEqual(conf['option'], {"unknown": 0})
+
+    def test_subconfig_invalid_option(self):
+        class Schema:
+            option = c.SubConfig(
+                ('cc', c.Choice(('foo', 'bar'))),
+                validate=True,
+            )
+
+        with self.expect_error(
+            option="Sub-option 'cc' configuration error: Expected one of: ('foo', 'bar') but received: True"
+        ):
+            self.get_config(Schema, {'option': {'cc': True}})
+
+    def test_subconfig_normal(self):
+        class Schema:
+            option = c.SubConfig(
+                ('cc', c.Choice(('foo', 'bar'))),
+            )
+
+        conf = self.get_config(Schema, {'option': {'cc': 'foo'}})
+        self.assertEqual(conf['option'], {'cc': 'foo'})
+
+
+class ConfigItemsTest(TestCase):
+    def test_subconfig_with_multiple_items(self):
+        # This had a bug where subsequent items would get merged into the same dict.
+        class Schema:
+            the_items = c.ConfigItems(
+                ("value", c.Type(str)),
+            )
+
+        conf = self.get_config(
+            Schema,
+            {
+                'the_items': [
+                    {'value': 'a'},
+                    {'value': 'b'},
+                ]
+            },
+        )
+        self.assertEqual(conf['the_items'], [{'value': 'a'}, {'value': 'b'}])
+
+    def test_optional(self):
+        class Schema:
+            sub = c.ListOfItems(
+                c.SubConfig(
+                    ('opt', c.Type(int)),
+                    validate=True,
+                ),
+                default=[],
+            )
+
+        conf = self.get_config(Schema, {})
+        self.assertEqual(conf['sub'], [])
+
+        conf = self.get_config(Schema, {'sub': None})
+        self.assertEqual(conf['sub'], [])
+
+        conf = self.get_config(Schema, {'sub': [{'opt': 1}, {}]})
+        self.assertEqual(conf['sub'], [{'opt': 1}, {'opt': None}])
+
+        conf = self.get_config(Schema, {'sub': []})
+
+        conf = self.get_config(Schema, {'sub': [{'opt': 1}, {'opt': 2}]})
+        self.assertEqual(conf['sub'], [{'opt': 1}, {'opt': 2}])
+
+    def test_required(self):
+        class Schema:
+            sub = c.ListOfItems(
+                c.SubConfig(
+                    ('opt', c.Type(int, required=True)),
+                    validate=True,
+                )
+            )
+
+        with self.expect_error(sub="Required configuration not provided."):
+            conf = self.get_config(Schema, {})
+
+        with self.expect_error(sub="Required configuration not provided."):
+            conf = self.get_config(Schema, {'sub': None})
+
+        with self.expect_error(
+            sub="Sub-option 'opt' configuration error: Expected type: <class 'int'> but received: <class 'str'>"
+        ):
+            conf = self.get_config(Schema, {'sub': [{'opt': 'asdf'}, {}]})
+
+        conf = self.get_config(Schema, {'sub': []})
+
+        conf = self.get_config(Schema, {'sub': [{'opt': 1}, {'opt': 2}]})
+        self.assertEqual(conf['sub'], [{'opt': 1}, {'opt': 2}])
+
+        with self.expect_error(
+            sub="Sub-option 'opt' configuration error: Expected type: <class 'int'> but "
+            "received: <class 'str'>"
+        ):
+            self.get_config(Schema, {'sub': [{'opt': 'z'}, {'opt': 2}]})
+
+        with self.expect_error(
+            sub="Sub-option 'opt' configuration error: "
+            "Expected type: <class 'int'> but received: <class 'str'>"
+        ):
+            conf = self.get_config(Schema, {'sub': [{'opt': 'z'}, {'opt': 2}]})
+
+        with self.expect_error(
+            sub="The configuration is invalid. The expected type was a key value mapping "
+            "(a python dict) but we got an object of type: <class 'int'>"
+        ):
+            conf = self.get_config(Schema, {'sub': [1, 2]})
+
+
+class MarkdownExtensionsTest(TestCase):
+    @patch('markdown.Markdown')
+    def test_simple_list(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+            mdx_configs = c.Private()
+
+        config = {
+            'markdown_extensions': ['foo', 'bar'],
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], ['foo', 'bar'])
+        self.assertEqual(conf['mdx_configs'], {})
+
+    @patch('markdown.Markdown')
+    def test_list_dicts(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+            mdx_configs = c.Private()
+
+        config = {
+            'markdown_extensions': [
+                {'foo': {'foo_option': 'foo value'}},
+                {'bar': {'bar_option': 'bar value'}},
+                {'baz': None},
+            ]
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], ['foo', 'bar', 'baz'])
+        self.assertEqual(
+            conf['mdx_configs'],
+            {
+                'foo': {'foo_option': 'foo value'},
+                'bar': {'bar_option': 'bar value'},
+            },
+        )
+
+    @patch('markdown.Markdown')
+    def test_mixed_list(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+            mdx_configs = c.Private()
+
+        config = {
+            'markdown_extensions': [
+                'foo',
+                {'bar': {'bar_option': 'bar value'}},
+            ]
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], ['foo', 'bar'])
+        self.assertEqual(
+            conf['mdx_configs'],
+            {
+                'bar': {'bar_option': 'bar value'},
+            },
+        )
+
+    @patch('markdown.Markdown')
+    def test_dict_of_dicts(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+            mdx_configs = c.Private()
+
+        config = {
+            'markdown_extensions': {
+                'foo': {'foo_option': 'foo value'},
+                'bar': {'bar_option': 'bar value'},
+                'baz': {},
+            }
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], ['foo', 'bar', 'baz'])
+        self.assertEqual(
+            conf['mdx_configs'],
+            {
+                'foo': {'foo_option': 'foo value'},
+                'bar': {'bar_option': 'bar value'},
+            },
+        )
+
+    @patch('markdown.Markdown')
+    def test_builtins(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
+            mdx_configs = c.Private()
+
+        config = {
+            'markdown_extensions': ['foo', 'bar'],
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], ['meta', 'toc', 'foo', 'bar'])
+        self.assertEqual(conf['mdx_configs'], {})
+
+    def test_duplicates(self):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
+            mdx_configs = c.Private()
+
+        config = {
+            'markdown_extensions': ['meta', 'toc'],
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], ['meta', 'toc'])
+        self.assertEqual(conf['mdx_configs'], {})
+
+    def test_builtins_config(self):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
+            mdx_configs = c.Private()
+
+        config = {
+            'markdown_extensions': [
+                {'toc': {'permalink': True}},
+            ],
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], ['meta', 'toc'])
+        self.assertEqual(conf['mdx_configs'], {'toc': {'permalink': True}})
+
+    @patch('markdown.Markdown')
+    def test_configkey(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions(configkey='bar')
+            bar = c.Private()
+
+        config = {
+            'markdown_extensions': [
+                {'foo': {'foo_option': 'foo value'}},
+            ]
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], ['foo'])
+        self.assertEqual(
+            conf['bar'],
+            {
+                'foo': {'foo_option': 'foo value'},
+            },
+        )
+
+    def test_missing_default(self):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+            mdx_configs = c.Private()
+
+        conf = self.get_config(Schema, {})
+        self.assertEqual(conf['markdown_extensions'], [])
+        self.assertEqual(conf['mdx_configs'], {})
+
+    def test_none(self):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions(default=[])
+            mdx_configs = c.Private()
+
+        config = {
+            'markdown_extensions': None,
+        }
+        conf = self.get_config(Schema, config)
+        self.assertEqual(conf['markdown_extensions'], [])
+        self.assertEqual(conf['mdx_configs'], {})
+
+    @patch('markdown.Markdown')
+    def test_not_list(self, mock_md):
+        class Schema:
+            option = c.MarkdownExtensions()
+
+        with self.expect_error(option="Invalid Markdown Extensions configuration"):
+            self.get_config(Schema, {'option': 'not a list'})
+
+    @patch('markdown.Markdown')
+    def test_invalid_config_option(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+
+        config = {
+            'markdown_extensions': [
+                {'foo': 'not a dict'},
+            ],
+        }
+        with self.expect_error(
+            markdown_extensions="Invalid config options for Markdown Extension 'foo'."
+        ):
+            self.get_config(Schema, config)
+
+    @patch('markdown.Markdown')
+    def test_invalid_config_item(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+
+        config = {
+            'markdown_extensions': [
+                ['not a dict'],
+            ],
+        }
+        with self.expect_error(markdown_extensions="Invalid Markdown Extensions configuration"):
+            self.get_config(Schema, config)
+
+    @patch('markdown.Markdown')
+    def test_invalid_dict_item(self, mock_md):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+
+        config = {
+            'markdown_extensions': [
+                {'key1': 'value', 'key2': 'too many keys'},
+            ],
+        }
+        with self.expect_error(markdown_extensions="Invalid Markdown Extensions configuration"):
+            self.get_config(Schema, config)
+
+    def test_unknown_extension(self):
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+
+        config = {
+            'markdown_extensions': ['unknown'],
+        }
+        with self.expect_error(
+            markdown_extensions=re.compile(r"Failed to load extension 'unknown'.\n.+")
+        ):
+            self.get_config(Schema, config)
+
+    def test_multiple_markdown_config_instances(self):
+        # This had a bug where an extension config would persist to separate
+        # config instances that didn't specify extensions.
+        class Schema:
+            markdown_extensions = c.MarkdownExtensions()
+            mdx_configs = c.Private()
+
+        conf = self.get_config(
+            Schema,
+            {
+                'markdown_extensions': [{'toc': {'permalink': '##'}}],
+            },
+        )
+        self.assertEqual(conf['mdx_configs']['toc'], {'permalink': '##'})
+
+        conf = self.get_config(
+            Schema,
+            {},
+        )
+        self.assertIsNone(conf['mdx_configs'].get('toc'))
+
+
+class TestHooks(TestCase):
+    class Schema:
+        plugins = c.Plugins(default=[])
+        hooks = c.Hooks('plugins')
+
+    @tempdir()
+    def test_hooks(self, src_dir):
+        write_file(
+            b'def on_page_markdown(markdown, **kwargs): return markdown.replace("f", "z")',
+            os.path.join(src_dir, 'hooks', 'my_hook.py'),
+        )
+        write_file(
+            b'foo foo',
+            os.path.join(src_dir, 'docs', 'index.md'),
+        )
+        conf = self.get_config(
+            self.Schema,
+            {'hooks': ['hooks/my_hook.py']},
+            config_file_path=os.path.join(src_dir, 'mkdocs.yml'),
+        )
+        self.assertIn('hooks/my_hook.py', conf['plugins'])
+        hook = conf['plugins']['hooks/my_hook.py']
+        self.assertTrue(hasattr(hook, 'on_page_markdown'))
+        self.assertEqual(
+            {**conf['plugins'].events, 'page_markdown': [hook.on_page_markdown]},
+            conf['plugins'].events,
+        )
+        self.assertEqual(hook.on_page_markdown('foo foo'), 'zoo zoo')
+        self.assertFalse(hasattr(hook, 'on_nav'))
+
+
+class SchemaTest(TestCase):
+    def test_copy(self):
+        copy.deepcopy(
+            base.LegacyConfig(
+                (('foo', c.MarkdownExtensions()),),
+            ),
+        )
+
+        copy.deepcopy(self.get_config(IpAddressTest.Schema, {'option': '1.2.3.4:5678'}))
+        copy.deepcopy(IpAddressTest.Schema)
+        copy.deepcopy(base.get_schema(IpAddressTest.Schema))
+
+        copy.deepcopy(self.get_config(EditURITest.Schema, {}))
+        copy.deepcopy(EditURITest.Schema)
+        copy.deepcopy(base.get_schema(EditURITest.Schema))
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index 1fb70156..71b3b14e 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -1,19 +1,31 @@
 import contextlib
+import copy
 import io
 import os
 import re
 import sys
 import textwrap
 import unittest
-from typing import Any, Dict
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, TypeVar
 from unittest.mock import patch
 
+if TYPE_CHECKING:
+    from typing_extensions import assert_type
+else:
+
+    def assert_type(val, typ):
+        return None
+
+
 import mkdocs
-from mkdocs.config import base
 from mkdocs.config import config_options as c
+from mkdocs.config.base import Config
 from mkdocs.tests.base import tempdir
+from mkdocs.theme import Theme
 from mkdocs.utils import write_file, yaml_load
 
+SomeConfig = TypeVar('SomeConfig', bound=Config)
+
 
 class UnexpectedError(Exception):
     pass
@@ -32,12 +44,12 @@ class TestCase(unittest.TestCase):
 
     def get_config(
         self,
-        schema: type,
+        config_class: Type[SomeConfig],
         cfg: Dict[str, Any],
         warnings={},
         config_file_path=None,
-    ):
-        config = base.Config(base.get_schema(schema), config_file_path=config_file_path)
+    ) -> SomeConfig:
+        config = config_class(config_file_path=config_file_path)
         config.load_dict(cfg)
         actual_errors, actual_warnings = config.validate()
         if actual_errors:
@@ -46,142 +58,111 @@ class TestCase(unittest.TestCase):
         return config
 
 
-class OptionallyRequiredTest(TestCase):
-    def test_empty(self):
-        class Schema:
-            option = c.OptionallyRequired()
-
-        conf = self.get_config(Schema, {'option': None})
-        self.assertEqual(conf['option'], None)
-
-        self.assertEqual(Schema.option.required, False)
-
-    def test_required(self):
-        class Schema:
-            option = c.OptionallyRequired(required=True)
-
-        with self.expect_error(option="Required configuration not provided."):
-            self.get_config(Schema, {'option': None})
-
-        self.assertEqual(Schema.option.required, True)
-
-    def test_required_no_default(self):
-        class Schema:
-            option = c.OptionallyRequired(required=True)
-
-        conf = self.get_config(Schema, {'option': 2})
-        self.assertEqual(conf['option'], 2)
-
-    def test_default(self):
-        class Schema:
-            option = c.OptionallyRequired(default=1)
-
-        conf = self.get_config(Schema, {'option': None})
-        self.assertEqual(conf['option'], 1)
-
-    def test_replace_default(self):
-        class Schema:
-            option = c.OptionallyRequired(default=1)
-
-        conf = self.get_config(Schema, {'option': 2})
-        self.assertEqual(conf['option'], 2)
-
-
 class TypeTest(TestCase):
-    def test_single_type(self):
-        class Schema:
+    def test_single_type(self) -> None:
+        class Schema(Config):
             option = c.Type(str)
 
         conf = self.get_config(Schema, {'option': "Testing"})
-        self.assertEqual(conf['option'], "Testing")
+        assert_type(conf.option, str)
+        self.assertEqual(conf.option, "Testing")
 
-    def test_multiple_types(self):
-        class Schema:
+    def test_multiple_types(self) -> None:
+        class Schema(Config):
             option = c.Type((list, tuple))
 
         conf = self.get_config(Schema, {'option': [1, 2, 3]})
-        self.assertEqual(conf['option'], [1, 2, 3])
+        self.assertEqual(conf.option, [1, 2, 3])
 
         conf = self.get_config(Schema, {'option': (1, 2, 3)})
-        self.assertEqual(conf['option'], (1, 2, 3))
+        self.assertEqual(conf.option, (1, 2, 3))
 
         with self.expect_error(
             option="Expected type: (<class 'list'>, <class 'tuple'>) but received: <class 'dict'>"
         ):
             self.get_config(Schema, {'option': {'a': 1}})
 
-    def test_length(self):
-        class Schema:
+    def test_length(self) -> None:
+        class Schema(Config):
             option = c.Type(str, length=7)
 
         conf = self.get_config(Schema, {'option': "Testing"})
-        self.assertEqual(conf['option'], "Testing")
+        assert_type(conf.option, str)
+        self.assertEqual(conf.option, "Testing")
 
         with self.expect_error(
             option="Expected type: <class 'str'> with length 7 but received: 'Testing Long' with length 12"
         ):
             self.get_config(Schema, {'option': "Testing Long"})
 
+    def test_optional_with_default(self) -> None:
+        with self.assertRaisesRegex(ValueError, "doesn't need to be wrapped into Optional"):
+            c.Optional(c.Type(int, default=5))
+
 
 class ChoiceTest(TestCase):
-    def test_required(self):
-        class Schema:
-            option = c.Choice(('python', 'node'), required=True)
+    def test_required(self) -> None:
+        class Schema(Config):
+            option = c.Choice(('python', 'node'))
 
         conf = self.get_config(Schema, {'option': 'python'})
-        self.assertEqual(conf['option'], 'python')
+        assert_type(conf.option, str)
+        self.assertEqual(conf.option, 'python')
 
-    def test_optional(self):
-        class Schema:
-            option = c.Choice(('python', 'node'))
+    def test_optional(self) -> None:
+        class Schema(Config):
+            option = c.Optional(c.Choice(('python', 'node')))
 
         conf = self.get_config(Schema, {'option': 'python'})
-        self.assertEqual(conf['option'], 'python')
+        assert_type(conf.option, Optional[str])
+        self.assertEqual(conf.option, 'python')
 
         conf = self.get_config(Schema, {})
-        self.assertEqual(conf['option'], None)
+        self.assertEqual(conf.option, None)
 
         conf = self.get_config(Schema, {'option': None})
-        self.assertEqual(conf['option'], None)
+        self.assertEqual(conf.option, None)
 
-    def test_default(self):
-        class Schema:
+    def test_default(self) -> None:
+        class Schema(Config):
             option = c.Choice(('a', 'b', 'c'), default='b')
 
         conf = self.get_config(Schema, {})
-        self.assertEqual(conf['option'], 'b')
+        assert_type(conf.option, str)
+        self.assertEqual(conf.option, 'b')
+
+        conf = self.get_config(Schema, {})
+        self.assertEqual(conf.option, 'b')
 
         conf = self.get_config(Schema, {'option': None})
-        self.assertEqual(conf['option'], 'b')
+        self.assertEqual(conf.option, 'b')
 
         with self.expect_error(option="Expected one of: ('a', 'b', 'c') but received: 'go'"):
             self.get_config(Schema, {'option': 'go'})
 
-    def test_invalid_default(self):
+    def test_invalid_default(self) -> None:
         with self.assertRaises(ValueError):
             c.Choice(('a', 'b'), default='c')
         with self.assertRaises(ValueError):
             c.Choice(('a', 'b'), default='c', required=True)
 
-    def test_invalid_choice(self):
-        class Schema:
+    def test_invalid_choice(self) -> None:
+        class Schema(Config):
             option = c.Choice(('python', 'node'))
 
         with self.expect_error(option="Expected one of: ('python', 'node') but received: 'go'"):
             self.get_config(Schema, {'option': 'go'})
 
-    def test_invalid_choices(self):
+    def test_invalid_choices(self) -> None:
         with self.assertRaises(ValueError):
             c.Choice('')
         with self.assertRaises(ValueError):
             c.Choice([])
-        with self.assertRaises(ValueError):
-            c.Choice(5)
 
 
 class DeprecatedTest(TestCase):
-    def test_deprecated_option_simple(self):
-        class Schema:
+    def test_deprecated_option_simple(self) -> None:
+        class Schema(Config):
             d = c.Deprecated()
 
         self.get_config(
@@ -193,14 +174,14 @@ class DeprecatedTest(TestCase):
             ),
         )
 
-    def test_deprecated_option_message(self):
-        class Schema:
+    def test_deprecated_option_message(self) -> None:
+        class Schema(Config):
             d = c.Deprecated(message='custom message for {} key')
 
         self.get_config(Schema, {'d': 'value'}, warnings={'d': 'custom message for d key'})
 
-    def test_deprecated_option_with_type(self):
-        class Schema:
+    def test_deprecated_option_with_type(self) -> None:
+        class Schema(Config):
             d = c.Deprecated(option_type=c.Type(str))
 
         self.get_config(
@@ -212,8 +193,8 @@ class DeprecatedTest(TestCase):
             ),
         )
 
-    def test_deprecated_option_with_invalid_type(self):
-        class Schema:
+    def test_deprecated_option_with_invalid_type(self) -> None:
+        class Schema(Config):
             d = c.Deprecated(option_type=c.Type(list))
 
         with self.expect_error(d="Expected type: <class 'list'> but received: <class 'str'>"):
@@ -226,8 +207,8 @@ class DeprecatedTest(TestCase):
                 ),
             )
 
-    def test_removed_option(self):
-        class Schema:
+    def test_removed_option(self) -> None:
+        class Schema(Config):
             d = c.Deprecated(removed=True, moved_to='foo')
 
         with self.expect_error(
@@ -235,14 +216,14 @@ class DeprecatedTest(TestCase):
         ):
             self.get_config(Schema, {'d': 'value'})
 
-    def test_deprecated_option_with_type_undefined(self):
-        class Schema:
+    def test_deprecated_option_with_type_undefined(self) -> None:
+        class Schema(Config):
             option = c.Deprecated(option_type=c.Type(str))
 
         self.get_config(Schema, {'option': None})
 
-    def test_deprecated_option_move(self):
-        class Schema:
+    def test_deprecated_option_move(self) -> None:
+        class Schema(Config):
             new = c.Type(str)
             old = c.Deprecated(moved_to='new')
 
@@ -256,8 +237,8 @@ class DeprecatedTest(TestCase):
         )
         self.assertEqual(conf, {'new': 'value', 'old': None})
 
-    def test_deprecated_option_move_complex(self):
-        class Schema:
+    def test_deprecated_option_move_complex(self) -> None:
+        class Schema(Config):
             foo = c.Type(dict)
             old = c.Deprecated(moved_to='foo.bar')
 
@@ -271,8 +252,8 @@ class DeprecatedTest(TestCase):
         )
         self.assertEqual(conf, {'foo': {'bar': 'value'}, 'old': None})
 
-    def test_deprecated_option_move_existing(self):
-        class Schema:
+    def test_deprecated_option_move_existing(self) -> None:
+        class Schema(Config):
             foo = c.Type(dict)
             old = c.Deprecated(moved_to='foo.bar')
 
@@ -286,8 +267,8 @@ class DeprecatedTest(TestCase):
         )
         self.assertEqual(conf, {'foo': {'existing': 'existing', 'bar': 'value'}, 'old': None})
 
-    def test_deprecated_option_move_invalid(self):
-        class Schema:
+    def test_deprecated_option_move_invalid(self) -> None:
+        class Schema(Config):
             foo = c.Type(dict)
             old = c.Deprecated(moved_to='foo.bar')
 
@@ -303,83 +284,88 @@ class DeprecatedTest(TestCase):
 
 
 class IpAddressTest(TestCase):
-    class Schema:
+    class Schema(Config):
         option = c.IpAddress()
 
-    def test_valid_address(self):
+    def test_valid_address(self) -> None:
         addr = '127.0.0.1:8000'
 
         conf = self.get_config(self.Schema, {'option': addr})
-        self.assertEqual(str(conf['option']), addr)
-        self.assertEqual(conf['option'].host, '127.0.0.1')
-        self.assertEqual(conf['option'].port, 8000)
 
-    def test_valid_IPv6_address(self):
+        assert_type(conf.option, c._IpAddressValue)
+        assert_type(conf.option.host, str)
+        assert_type(conf.option.port, int)
+
+        self.assertEqual(str(conf.option), addr)
+        self.assertEqual(conf.option.host, '127.0.0.1')
+        self.assertEqual(conf.option.port, 8000)
+
+    def test_valid_IPv6_address(self) -> None:
         addr = '::1:8000'
 
         conf = self.get_config(self.Schema, {'option': addr})
-        self.assertEqual(str(conf['option']), addr)
-        self.assertEqual(conf['option'].host, '::1')
-        self.assertEqual(conf['option'].port, 8000)
+        self.assertEqual(str(conf.option), addr)
+        self.assertEqual(conf.option.host, '::1')
+        self.assertEqual(conf.option.port, 8000)
 
-    def test_valid_full_IPv6_address(self):
+    def test_valid_full_IPv6_address(self) -> None:
         addr = '[2001:db8:85a3::8a2e:370:7334]:123'
 
         conf = self.get_config(self.Schema, {'option': addr})
-        self.assertEqual(conf['option'].host, '2001:db8:85a3::8a2e:370:7334')
-        self.assertEqual(conf['option'].port, 123)
+        self.assertEqual(conf.option.host, '2001:db8:85a3::8a2e:370:7334')
+        self.assertEqual(conf.option.port, 123)
 
-    def test_named_address(self):
+    def test_named_address(self) -> None:
         addr = 'localhost:8000'
 
         conf = self.get_config(self.Schema, {'option': addr})
-        self.assertEqual(str(conf['option']), addr)
-        self.assertEqual(conf['option'].host, 'localhost')
-        self.assertEqual(conf['option'].port, 8000)
+        self.assertEqual(str(conf.option), addr)
+        self.assertEqual(conf.option.host, 'localhost')
+        self.assertEqual(conf.option.port, 8000)
 
-    def test_default_address(self):
+    def test_default_address(self) -> None:
         addr = '127.0.0.1:8000'
 
-        class Schema:
+        class Schema(Config):
             option = c.IpAddress(default=addr)
 
         conf = self.get_config(Schema, {'option': None})
-        self.assertEqual(str(conf['option']), addr)
-        self.assertEqual(conf['option'].host, '127.0.0.1')
-        self.assertEqual(conf['option'].port, 8000)
+        self.assertEqual(str(conf.option), addr)
+        self.assertEqual(conf.option.host, '127.0.0.1')
+        self.assertEqual(conf.option.port, 8000)
 
     @unittest.skipIf(
         sys.version_info < (3, 9, 5),
         "Leading zeros allowed in IP addresses before Python3.9.5",
     )
-    def test_invalid_leading_zeros(self):
+    def test_invalid_leading_zeros(self) -> None:
         with self.expect_error(
             option="'127.000.000.001' does not appear to be an IPv4 or IPv6 address"
         ):
             self.get_config(self.Schema, {'option': '127.000.000.001:8000'})
 
-    def test_invalid_address_range(self):
+    def test_invalid_address_range(self) -> None:
         with self.expect_error(option="'277.0.0.1' does not appear to be an IPv4 or IPv6 address"):
             self.get_config(self.Schema, {'option': '277.0.0.1:8000'})
 
-    def test_invalid_address_format(self):
+    def test_invalid_address_format(self) -> None:
         with self.expect_error(option="Must be a string of format 'IP:PORT'"):
             self.get_config(self.Schema, {'option': '127.0.0.18000'})
 
-    def test_invalid_address_type(self):
+    def test_invalid_address_type(self) -> None:
         with self.expect_error(option="Must be a string of format 'IP:PORT'"):
             self.get_config(self.Schema, {'option': 123})
 
-    def test_invalid_address_port(self):
+    def test_invalid_address_port(self) -> None:
         with self.expect_error(option="'foo' is not a valid port"):
             self.get_config(self.Schema, {'option': '127.0.0.1:foo'})
 
-    def test_invalid_address_missing_port(self):
+    def test_invalid_address_missing_port(self) -> None:
         with self.expect_error(option="Must be a string of format 'IP:PORT'"):
             self.get_config(self.Schema, {'option': '127.0.0.1'})
 
-    def test_unsupported_address(self):
-        class Schema:
+    def test_unsupported_address(self) -> None:
+        class Schema(Config):
             dev_addr = c.IpAddress()
 
         self.get_config(
@@ -394,8 +380,8 @@ class IpAddressTest(TestCase):
             ),
         )
 
-    def test_unsupported_IPv6_address(self):
-        class Schema:
+    def test_unsupported_IPv6_address(self) -> None:
+        class Schema(Config):
             dev_addr = c.IpAddress()
 
         self.get_config(
@@ -411,30 +397,44 @@ class IpAddressTest(TestCase):
 
 
 class URLTest(TestCase):
-    def test_valid_url(self):
-        class Schema:
+    def test_valid_url(self) -> None:
+        class Schema(Config):
             option = c.URL()
 
         conf = self.get_config(Schema, {'option': "https://mkdocs.org"})
-        self.assertEqual(conf['option'], "https://mkdocs.org")
+        assert_type(conf.option, str)
+        self.assertEqual(conf.option, "https://mkdocs.org")
 
         conf = self.get_config(Schema, {'option': ""})
-        self.assertEqual(conf['option'], "")
+        self.assertEqual(conf.option, "")
 
-    def test_valid_url_is_dir(self):
-        class Schema:
+    def test_valid_url_is_dir(self) -> None:
+        class Schema(Config):
             option = c.URL(is_dir=True)
 
         conf = self.get_config(Schema, {'option': "http://mkdocs.org/"})
-        self.assertEqual(conf['option'], "http://mkdocs.org/")
+        self.assertEqual(conf.option, "http://mkdocs.org/")
 
         conf = self.get_config(Schema, {'option': "https://mkdocs.org"})
-        self.assertEqual(conf['option'], "https://mkdocs.org/")
+        self.assertEqual(conf.option, "https://mkdocs.org/")
+
+    def test_optional(self):
+        class Schema(Config):
+            option = c.Optional(c.URL(is_dir=True))
+
+        conf = self.get_config(Schema, {'option': ''})
+        self.assertEqual(conf.option, '')
 
-    def test_invalid_url(self):
-        class Schema:
+        conf = self.get_config(Schema, {'option': None})
+        self.assertEqual(conf.option, None)
+
+    def test_invalid_url(self) -> None:
+        class Schema(Config):
             option = c.URL()
 
+        with self.expect_error(option="Required configuration not provided."):
+            self.get_config(Schema, {'option': None})
+
         for url in "www.mkdocs.org", "//mkdocs.org/test", "http:/mkdocs.org/", "/hello/":
             with self.subTest(url=url):
                 with self.expect_error(
@@ -442,8 +442,8 @@ class URLTest(TestCase):
                 ):
                     self.get_config(Schema, {'option': url})
 
-    def test_invalid_type(self):
-        class Schema:
+    def test_invalid_type(self) -> None:
+        class Schema(Config):
             option = c.URL()
 
         with self.expect_error(option="Unable to parse the URL."):
@@ -451,79 +451,82 @@ class URLTest(TestCase):
 
 
 class EditURITest(TestCase):
-    class Schema:
-        repo_url = c.URL()
-        repo_name = c.RepoName('repo_url')
-        edit_uri_template = c.EditURITemplate('edit_uri')
-        edit_uri = c.EditURI('repo_url')
+    class Schema(Config):
+        repo_url = c.Optional(c.URL())
+        repo_name = c.Optional(c.RepoName('repo_url'))
+        edit_uri_template = c.Optional(c.EditURITemplate('edit_uri'))
+        edit_uri = c.Optional(c.EditURI('repo_url'))
 
-    def test_repo_name_github(self):
+    def test_repo_name_github(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://github.com/mkdocs/mkdocs"},
         )
-        self.assertEqual(conf['repo_name'], "GitHub")
+        assert_type(conf.repo_name, Optional[str])
+        self.assertEqual(conf.repo_name, "GitHub")
 
-    def test_repo_name_bitbucket(self):
+    def test_repo_name_bitbucket(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://bitbucket.org/gutworth/six/"},
         )
-        self.assertEqual(conf['repo_name'], "Bitbucket")
+        self.assertEqual(conf.repo_name, "Bitbucket")
 
-    def test_repo_name_gitlab(self):
+    def test_repo_name_gitlab(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://gitlab.com/gitlab-org/gitlab-ce/"},
         )
-        self.assertEqual(conf['repo_name'], "GitLab")
+        self.assertEqual(conf.repo_name, "GitLab")
 
-    def test_repo_name_custom(self):
+    def test_repo_name_custom(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://launchpad.net/python-tuskarclient"},
         )
-        self.assertEqual(conf['repo_name'], "Launchpad")
+        self.assertEqual(conf.repo_name, "Launchpad")
 
-    def test_edit_uri_github(self):
+    def test_edit_uri_github(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://github.com/mkdocs/mkdocs"},
         )
-        self.assertEqual(conf['edit_uri'], 'edit/master/docs/')
-        self.assertEqual(conf['repo_url'], "https://github.com/mkdocs/mkdocs")
+        assert_type(conf.edit_uri, Optional[str])
+        assert_type(conf.repo_url, Optional[str])
+        self.assertEqual(conf.edit_uri, 'edit/master/docs/')
+        self.assertEqual(conf.repo_url, "https://github.com/mkdocs/mkdocs")
 
-    def test_edit_uri_bitbucket(self):
+    def test_edit_uri_bitbucket(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://bitbucket.org/gutworth/six/"},
         )
-        self.assertEqual(conf['edit_uri'], 'src/default/docs/')
-        self.assertEqual(conf['repo_url'], "https://bitbucket.org/gutworth/six/")
+        self.assertEqual(conf.edit_uri, 'src/default/docs/')
+        self.assertEqual(conf.repo_url, "https://bitbucket.org/gutworth/six/")
 
-    def test_edit_uri_gitlab(self):
+    def test_edit_uri_gitlab(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://gitlab.com/gitlab-org/gitlab-ce/"},
         )
-        self.assertEqual(conf['edit_uri'], 'edit/master/docs/')
+        self.assertEqual(conf.edit_uri, 'edit/master/docs/')
 
-    def test_edit_uri_custom(self):
+    def test_edit_uri_custom(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://launchpad.net/python-tuskarclient"},
         )
-        self.assertEqual(conf['edit_uri'], None)
-        self.assertEqual(conf['repo_url'], "https://launchpad.net/python-tuskarclient")
+        self.assertEqual(conf.edit_uri, None)
+        self.assertEqual(conf.repo_url, "https://launchpad.net/python-tuskarclient")
 
-    def test_repo_name_custom_and_empty_edit_uri(self):
+    def test_repo_name_custom_and_empty_edit_uri(self) -> None:
         conf = self.get_config(
             self.Schema,
             {'repo_url': "https://github.com/mkdocs/mkdocs", 'repo_name': 'mkdocs'},
         )
-        self.assertEqual(conf['edit_uri'], 'edit/master/docs/')
+        self.assertEqual(conf.edit_uri, 'edit/master/docs/')
 
-    def test_edit_uri_template_ok(self):
+    def test_edit_uri_template_ok(self) -> None:
         conf = self.get_config(
             self.Schema,
             {
@@ -531,9 +534,10 @@ class EditURITest(TestCase):
                 'edit_uri_template': 'edit/foo/docs/{path}',
             },
         )
-        self.assertEqual(conf['edit_uri_template'], 'edit/foo/docs/{path}')
+        assert_type(conf.edit_uri_template, Optional[str])
+        self.assertEqual(conf.edit_uri_template, 'edit/foo/docs/{path}')
 
-    def test_edit_uri_template_errors(self):
+    def test_edit_uri_template_errors(self) -> None:
         with self.expect_error(
             edit_uri_template=re.compile(r'.*[{}].*')  # Complains about unclosed '{' or missing '}'
         ):
@@ -563,7 +567,7 @@ class EditURITest(TestCase):
                 },
             )
 
-    def test_edit_uri_template_warning(self):
+    def test_edit_uri_template_warning(self) -> None:
         conf = self.get_config(
             self.Schema,
             {
@@ -575,66 +579,97 @@ class EditURITest(TestCase):
                 edit_uri_template="The option 'edit_uri' has no effect when 'edit_uri_template' is set."
             ),
         )
-        self.assertEqual(conf['edit_uri_template'], 'edit/master/{path}')
+        self.assertEqual(conf.edit_uri_template, 'edit/master/{path}')
 
 
 class ListOfItemsTest(TestCase):
-    def test_int_type(self):
-        class Schema:
+    def test_int_type(self) -> None:
+        class Schema(Config):
             option = c.ListOfItems(c.Type(int))
 
         conf = self.get_config(Schema, {'option': [1, 2, 3]})
-        self.assertEqual(conf['option'], [1, 2, 3])
+        assert_type(conf.option, List[int])
+        self.assertEqual(conf.option, [1, 2, 3])
 
         with self.expect_error(
             option="Expected type: <class 'int'> but received: <class 'NoneType'>"
         ):
             conf = self.get_config(Schema, {'option': [1, None, 3]})
 
-    def test_combined_float_type(self):
-        class Schema:
+    def test_combined_float_type(self) -> None:
+        class Schema(Config):
             option = c.ListOfItems(c.Type((int, float)))
 
         conf = self.get_config(Schema, {'option': [1.4, 2, 3]})
-        self.assertEqual(conf['option'], [1.4, 2, 3])
+        self.assertEqual(conf.option, [1.4, 2, 3])
 
         with self.expect_error(
             option="Expected type: (<class 'int'>, <class 'float'>) but received: <class 'str'>"
         ):
             self.get_config(Schema, {'option': ['a']})
 
-    def test_list_default(self):
-        class Schema:
-            option = c.ListOfItems(c.Type(int))
+    def test_list_default(self) -> None:
+        class Schema(Config):
+            option = c.ListOfItems(c.Type(int), default=[])
 
         conf = self.get_config(Schema, {})
-        self.assertEqual(conf['option'], [])
+        assert_type(conf.option, List[int])
+        self.assertEqual(conf.option, [])
 
-        conf = self.get_config(Schema, {'option': None})
-        self.assertEqual(conf['option'], [])
+        with self.expect_error(option="Required configuration not provided."):
+            conf = self.get_config(Schema, {'option': None})
+
+    def test_none_without_default(self) -> None:
+        class Schema(Config):
+            option = c.ListOfItems(c.Type(str))
+
+        with self.expect_error(option="Required configuration not provided."):
+            conf = self.get_config(Schema, {})
 
-    def test_none_default(self):
-        class Schema:
-            option = c.ListOfItems(c.Type(str), default=None)
+        with self.expect_error(option="Required configuration not provided."):
+            conf = self.get_config(Schema, {'option': None})
+
+        conf = self.get_config(Schema, {'option': ['foo']})
+        self.assertEqual(conf.option, ['foo'])
+
+    def test_optional(self) -> None:
+        class Schema(Config):
+            option = c.Optional(c.ListOfItems(c.Type(str)))
 
         conf = self.get_config(Schema, {})
-        self.assertEqual(conf['option'], None)
+        assert_type(conf.option, Optional[List[str]])
+        self.assertEqual(conf.option, None)
 
         conf = self.get_config(Schema, {'option': None})
-        self.assertEqual(conf['option'], None)
+        self.assertEqual(conf.option, None)
 
         conf = self.get_config(Schema, {'option': ['foo']})
-        self.assertEqual(conf['option'], ['foo'])
+        self.assertEqual(conf.option, ['foo'])
+
+    def test_list_of_optional(self) -> None:
+        class Schema(Config):
+            option = c.ListOfItems(c.Optional(c.Type(int)), default=[])
+
+        conf = self.get_config(Schema, {})
+        assert_type(conf.option, List[Optional[int]])
+        self.assertEqual(conf.option, [])
+
+        conf = self.get_config(Schema, {'option': [4, None]})
+        self.assertEqual(conf.option, [4, None])
 
-    def test_string_not_a_list_of_strings(self):
-        class Schema:
+        with self.expect_error(option="Expected type: <class 'int'> but received: <class 'str'>"):
+            conf = self.get_config(Schema, {'option': ['foo']})
+            self.assertEqual(conf.option, ['foo'])
+
+    def test_string_not_a_list_of_strings(self) -> None:
+        class Schema(Config):
             option = c.ListOfItems(c.Type(str))
 
         with self.expect_error(option="Expected a list of items, but a <class 'str'> was given."):
             self.get_config(Schema, {'option': 'foo'})
 
-    def test_post_validation_error(self):
-        class Schema:
+    def test_post_validation_error(self) -> None:
+        class Schema(Config):
             option = c.ListOfItems(c.IpAddress())
 
         with self.expect_error(option="'asdf' is not a valid port"):
@@ -642,73 +677,76 @@ class ListOfItemsTest(TestCase):
 
 
 class FilesystemObjectTest(TestCase):
-    def test_valid_dir(self):
+    def test_valid_dir(self) -> None:
         for cls in c.Dir, c.FilesystemObject:
             with self.subTest(cls):
                 d = os.path.dirname(__file__)
 
-                class Schema:
+                class Schema(Config):
                     option = cls(exists=True)
 
                 conf = self.get_config(Schema, {'option': d})
-                self.assertEqual(conf['option'], d)
+                assert_type(conf.option, str)
+                self.assertEqual(conf.option, d)
 
-    def test_valid_file(self):
+    def test_valid_file(self) -> None:
         for cls in c.File, c.FilesystemObject:
             with self.subTest(cls):
                 f = __file__
 
-                class Schema:
+                class Schema(Config):
                     option = cls(exists=True)
 
                 conf = self.get_config(Schema, {'option': f})
-                self.assertEqual(conf['option'], f)
+                assert_type(conf.option, str)
+                self.assertEqual(conf.option, f)
 
-    def test_missing_without_exists(self):
+    def test_missing_without_exists(self) -> None:
         for cls in c.Dir, c.File, c.FilesystemObject:
             with self.subTest(cls):
                 d = os.path.join("not", "a", "real", "path", "I", "hope")
 
-                class Schema:
+                class Schema(Config):
                     option = cls()
 
                 conf = self.get_config(Schema, {'option': d})
-                self.assertEqual(conf['option'], os.path.abspath(d))
+                assert_type(conf.option, str)
+                self.assertEqual(conf.option, os.path.abspath(d))
 
-    def test_missing_but_required(self):
+    def test_missing_but_required(self) -> None:
         for cls in c.Dir, c.File, c.FilesystemObject:
             with self.subTest(cls):
                 d = os.path.join("not", "a", "real", "path", "I", "hope")
 
-                class Schema:
+                class Schema(Config):
                     option = cls(exists=True)
 
                 with self.expect_error(option=re.compile(r"The path '.+' isn't an existing .+")):
                     self.get_config(Schema, {'option': d})
 
-    def test_not_a_dir(self):
+    def test_not_a_dir(self) -> None:
         d = __file__
 
-        class Schema:
+        class Schema(Config):
             option = c.Dir(exists=True)
 
         with self.expect_error(option=re.compile(r"The path '.+' isn't an existing directory.")):
             self.get_config(Schema, {'option': d})
 
-    def test_not_a_file(self):
+    def test_not_a_file(self) -> None:
         d = os.path.dirname(__file__)
 
-        class Schema:
+        class Schema(Config):
             option = c.File(exists=True)
 
         with self.expect_error(option=re.compile(r"The path '.+' isn't an existing file.")):
             self.get_config(Schema, {'option': d})
 
-    def test_incorrect_type_error(self):
+    def test_incorrect_type_error(self) -> None:
         for cls in c.Dir, c.File, c.FilesystemObject:
             with self.subTest(cls):
 
-                class Schema:
+                class Schema(Config):
                     option = cls()
 
                 with self.expect_error(
@@ -720,29 +758,29 @@ class FilesystemObjectTest(TestCase):
                 ):
                     self.get_config(Schema, {'option': []})
 
-    def test_with_unicode(self):
+    def test_with_unicode(self) -> None:
         for cls in c.Dir, c.File, c.FilesystemObject:
             with self.subTest(cls):
 
-                class Schema:
+                class Schema(Config):
                     dir = cls()
 
                 conf = self.get_config(Schema, {'dir': 'юникод'})
-                self.assertIsInstance(conf['dir'], str)
+                self.assertIsInstance(conf.dir, str)
 
-    def test_dir_bytes(self):
-        class Schema:
+    def test_dir_bytes(self) -> None:
+        class Schema(Config):
             dir = c.Dir()
 
         with self.expect_error(dir="Expected type: <class 'str'> but received: <class 'bytes'>"):
             self.get_config(Schema, {'dir': b'foo'})
 
-    def test_config_dir_prepended(self):
+    def test_config_dir_prepended(self) -> None:
         for cls in c.Dir, c.File, c.FilesystemObject:
             with self.subTest(cls):
                 base_path = os.path.dirname(os.path.abspath(__file__))
 
-                class Schema:
+                class Schema(Config):
                     dir = cls()
 
                 conf = self.get_config(
@@ -750,10 +788,10 @@ class FilesystemObjectTest(TestCase):
                     {'dir': 'foo'},
                     config_file_path=os.path.join(base_path, 'mkdocs.yml'),
                 )
-                self.assertEqual(conf['dir'], os.path.join(base_path, 'foo'))
+                self.assertEqual(conf.dir, os.path.join(base_path, 'foo'))
 
-    def test_site_dir_is_config_dir_fails(self):
-        class Schema:
+    def test_site_dir_is_config_dir_fails(self) -> None:
+        class Schema(Config):
             dir = c.DocsDir()
 
         with self.expect_error(
@@ -768,18 +806,18 @@ class FilesystemObjectTest(TestCase):
 
 
 class ListOfPathsTest(TestCase):
-    def test_valid_path(self):
+    def test_valid_path(self) -> None:
         paths = [os.path.dirname(__file__)]
 
-        class Schema:
+        class Schema(Config):
             option = c.ListOfPaths()
 
         self.get_config(Schema, {'option': paths})
 
-    def test_missing_path(self):
+    def test_missing_path(self) -> None:
         paths = [os.path.join("does", "not", "exist", "i", "hope")]
 
-        class Schema:
+        class Schema(Config):
             option = c.ListOfPaths()
 
         with self.expect_error(
@@ -787,10 +825,10 @@ class ListOfPathsTest(TestCase):
         ):
             self.get_config(Schema, {'option': paths})
 
-    def test_non_path(self):
+    def test_non_path(self) -> None:
         paths = [os.path.dirname(__file__), None]
 
-        class Schema:
+        class Schema(Config):
             option = c.ListOfPaths()
 
         with self.expect_error(
@@ -798,36 +836,45 @@ class ListOfPathsTest(TestCase):
         ):
             self.get_config(Schema, {'option': paths})
 
-    def test_empty_list(self):
-        class Schema:
+    def test_empty_list(self) -> None:
+        class Schema(Config):
             option = c.ListOfPaths()
 
         conf = self.get_config(Schema, {'option': []})
-        self.assertEqual(conf['option'], [])
+        assert_type(conf.option, List[str])
+        self.assertEqual(conf.option, [])
 
-    def test_non_list(self):
+    def test_none(self) -> None:
+        class Schema(Config):
+            option = c.ListOfPaths()
+
+        with self.expect_error(option="Required configuration not provided."):
+            self.get_config(Schema, {'option': None})
+
+    def test_non_list(self) -> None:
         paths = os.path.dirname(__file__)
 
-        class Schema:
+        class Schema(Config):
             option = c.ListOfPaths()
 
         with self.expect_error(option="Expected a list of items, but a <class 'str'> was given."):
             self.get_config(Schema, {'option': paths})
 
-    def test_file(self):
+    def test_file(self) -> None:
         paths = [__file__]
 
-        class Schema:
+        class Schema(Config):
             option = c.ListOfPaths()
 
-        self.get_config(Schema, {'option': paths})
+        conf = self.get_config(Schema, {'option': paths})
+        assert_type(conf.option, List[str])
 
     @tempdir()
-    def test_paths_localized_to_config(self, base_path):
+    def test_paths_localized_to_config(self, base_path) -> None:
         with open(os.path.join(base_path, 'foo'), 'w') as f:
             f.write('hi')
 
-        class Schema:
+        class Schema(Config):
             watch = c.ListOfPaths()
 
         conf = self.get_config(
@@ -836,15 +883,15 @@ class ListOfPathsTest(TestCase):
             config_file_path=os.path.join(base_path, 'mkdocs.yml'),
         )
 
-        self.assertEqual(conf['watch'], [os.path.join(base_path, 'foo')])
+        self.assertEqual(conf.watch, [os.path.join(base_path, 'foo')])
 
 
 class SiteDirTest(TestCase):
-    class Schema:
+    class Schema(Config):
         site_dir = c.SiteDir()
         docs_dir = c.Dir()
 
-    def test_doc_dir_in_site_dir(self):
+    def test_doc_dir_in_site_dir(self) -> None:
         j = os.path.join
         # The parent dir is not the same on every system, so use the actual dir name
         parent_dir = mkdocs.__file__.split(os.sep)[-3]
@@ -866,7 +913,7 @@ class SiteDirTest(TestCase):
                 ):
                     self.get_config(self.Schema, test_config)
 
-    def test_site_dir_in_docs_dir(self):
+    def test_site_dir_in_docs_dir(self) -> None:
         j = os.path.join
 
         test_configs = (
@@ -883,7 +930,7 @@ class SiteDirTest(TestCase):
                 ):
                     self.get_config(self.Schema, test_config)
 
-    def test_common_prefix(self):
+    def test_common_prefix(self) -> None:
         """Legitimate settings with common prefixes should not fail validation."""
 
         test_configs = (
@@ -897,15 +944,17 @@ class SiteDirTest(TestCase):
 
 
 class ThemeTest(TestCase):
-    def test_theme_as_string(self):
-        class Schema:
+    def test_theme_as_string(self) -> None:
+        class Schema(Config):
             option = c.Theme()
 
         conf = self.get_config(Schema, {'option': "mkdocs"})
-        self.assertEqual(conf['option'].name, 'mkdocs')
+        assert_type(conf.option, Theme)
+        assert_type(conf.option.name, Optional[str])
+        self.assertEqual(conf.option.name, 'mkdocs')
 
-    def test_uninstalled_theme_as_string(self):
-        class Schema:
+    def test_uninstalled_theme_as_string(self) -> None:
+        class Schema(Config):
             option = c.Theme()
 
         with self.expect_error(
@@ -915,26 +964,26 @@ class ThemeTest(TestCase):
         ):
             self.get_config(Schema, {'option': "mkdocs2"})
 
-    def test_theme_default(self):
-        class Schema:
+    def test_theme_default(self) -> None:
+        class Schema(Config):
             option = c.Theme(default='mkdocs')
 
         conf = self.get_config(Schema, {'option': None})
-        self.assertEqual(conf['option'].name, 'mkdocs')
+        self.assertEqual(conf.option.name, 'mkdocs')
 
-    def test_theme_as_simple_config(self):
+    def test_theme_as_simple_config(self) -> None:
         config = {
             'name': 'mkdocs',
         }
 
-        class Schema:
+        class Schema(Config):
             option = c.Theme()
 
         conf = self.get_config(Schema, {'option': config})
-        self.assertEqual(conf['option'].name, 'mkdocs')
+        self.assertEqual(conf.option.name, 'mkdocs')
 
     @tempdir()
-    def test_theme_as_complex_config(self, custom_dir):
+    def test_theme_as_complex_config(self, custom_dir) -> None:
         config = {
             'name': 'mkdocs',
             'custom_dir': custom_dir,
@@ -942,24 +991,24 @@ class ThemeTest(TestCase):
             'show_sidebar': False,
         }
 
-        class Schema:
+        class Schema(Config):
             option = c.Theme()
 
         conf = self.get_config(Schema, {'option': config})
-        self.assertEqual(conf['option'].name, 'mkdocs')
-        self.assertIn(custom_dir, conf['option'].dirs)
+        self.assertEqual(conf.option.name, 'mkdocs')
+        self.assertIn(custom_dir, conf.option.dirs)
         self.assertEqual(
-            conf['option'].static_templates,
+            conf.option.static_templates,
             {'404.html', 'sitemap.xml', 'sitemap.html'},
         )
-        self.assertEqual(conf['option']['show_sidebar'], False)
+        self.assertEqual(conf.option['show_sidebar'], False)
 
-    def test_theme_name_is_none(self):
+    def test_theme_name_is_none(self) -> None:
         config = {
             'name': None,
         }
 
-        class Schema:
+        class Schema(Config):
             option = c.Theme()
 
         with self.expect_error(
@@ -967,23 +1016,23 @@ class ThemeTest(TestCase):
         ):
             self.get_config(Schema, {'option': config})
 
-    def test_theme_config_missing_name(self):
+    def test_theme_config_missing_name(self) -> None:
         config = {
             'custom_dir': 'custom',
         }
 
-        class Schema:
+        class Schema(Config):
             option = c.Theme()
 
         with self.expect_error(option="No theme name set."):
             self.get_config(Schema, {'option': config})
 
-    def test_uninstalled_theme_as_config(self):
+    def test_uninstalled_theme_as_config(self) -> None:
         config = {
             'name': 'mkdocs2',
         }
 
-        class Schema:
+        class Schema(Config):
             option = c.Theme()
 
         with self.expect_error(
@@ -993,10 +1042,10 @@ class ThemeTest(TestCase):
         ):
             self.get_config(Schema, {'option': config})
 
-    def test_theme_invalid_type(self):
+    def test_theme_invalid_type(self) -> None:
         config = ['mkdocs2']
 
-        class Schema:
+        class Schema(Config):
             option = c.Theme()
 
         with self.expect_error(
@@ -1004,14 +1053,14 @@ class ThemeTest(TestCase):
         ):
             self.get_config(Schema, {'option': config})
 
-    def test_post_validation_none_theme_name_and_missing_custom_dir(self):
+    def test_post_validation_none_theme_name_and_missing_custom_dir(self) -> None:
         config = {
             'theme': {
                 'name': None,
             },
         }
 
-        class Schema:
+        class Schema(Config):
             theme = c.Theme()
 
         with self.expect_error(
@@ -1020,7 +1069,7 @@ class ThemeTest(TestCase):
             self.get_config(Schema, config)
 
     @tempdir()
-    def test_post_validation_inexisting_custom_dir(self, abs_base_path):
+    def test_post_validation_inexisting_custom_dir(self, abs_base_path) -> None:
         path = os.path.join(abs_base_path, 'inexisting_custom_dir')
         config = {
             'theme': {
@@ -1029,7 +1078,7 @@ class ThemeTest(TestCase):
             },
         }
 
-        class Schema:
+        class Schema(Config):
             theme = c.Theme()
 
         with self.expect_error(
@@ -1037,7 +1086,7 @@ class ThemeTest(TestCase):
         ):
             self.get_config(Schema, config)
 
-    def test_post_validation_locale_none(self):
+    def test_post_validation_locale_none(self) -> None:
         config = {
             'theme': {
                 'name': 'mkdocs',
@@ -1045,13 +1094,13 @@ class ThemeTest(TestCase):
             },
         }
 
-        class Schema:
+        class Schema(Config):
             theme = c.Theme()
 
         with self.expect_error(theme="'theme.locale' must be a string."):
             self.get_config(Schema, config)
 
-    def test_post_validation_locale_invalid_type(self):
+    def test_post_validation_locale_invalid_type(self) -> None:
         config = {
             'theme': {
                 'name': 'mkdocs',
@@ -1059,13 +1108,13 @@ class ThemeTest(TestCase):
             },
         }
 
-        class Schema:
+        class Schema(Config):
             theme = c.Theme()
 
         with self.expect_error(theme="'theme.locale' must be a string."):
             self.get_config(Schema, config)
 
-    def test_post_validation_locale(self):
+    def test_post_validation_locale(self) -> None:
         config = {
             'theme': {
                 'name': 'mkdocs',
@@ -1073,32 +1122,32 @@ class ThemeTest(TestCase):
             },
         }
 
-        class Schema:
+        class Schema(Config):
             theme = c.Theme()
 
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['theme']['locale'].language, 'fr')
+        self.assertEqual(conf.theme['locale'].language, 'fr')
 
 
 class NavTest(TestCase):
-    class Schema:
+    class Schema(Config):
         option = c.Nav()
 
-    def test_old_format(self):
+    def test_old_format(self) -> None:
         with self.expect_error(
             option="Expected nav item to be a string or dict, got a list: ['index.md']"
         ):
             self.get_config(self.Schema, {'option': [['index.md']]})
 
-    def test_provided_dict(self):
+    def test_provided_dict(self) -> None:
         conf = self.get_config(self.Schema, {'option': ['index.md', {"Page": "page.md"}]})
-        self.assertEqual(conf['option'], ['index.md', {'Page': 'page.md'}])
+        self.assertEqual(conf.option, ['index.md', {'Page': 'page.md'}])
 
-    def test_provided_empty(self):
+    def test_provided_empty(self) -> None:
         conf = self.get_config(self.Schema, {'option': []})
-        self.assertEqual(conf['option'], None)
+        self.assertEqual(conf.option, None)
 
-    def test_normal_nav(self):
+    def test_normal_nav(self) -> None:
         nav_yaml = textwrap.dedent(
             '''\
             - Home: index.md
@@ -1111,52 +1160,52 @@ class NavTest(TestCase):
         nav = yaml_load(io.StringIO(nav_yaml))
 
         conf = self.get_config(self.Schema, {'option': nav})
-        self.assertEqual(conf['option'], nav)
+        self.assertEqual(conf.option, nav)
 
-    def test_invalid_type_dict(self):
+    def test_invalid_type_dict(self) -> None:
         with self.expect_error(option="Expected nav to be a list, got a dict: {}"):
             self.get_config(self.Schema, {'option': {}})
 
-    def test_invalid_type_int(self):
+    def test_invalid_type_int(self) -> None:
         with self.expect_error(option="Expected nav to be a list, got a int: 5"):
             self.get_config(self.Schema, {'option': 5})
 
-    def test_invalid_item_int(self):
+    def test_invalid_item_int(self) -> None:
         with self.expect_error(option="Expected nav item to be a string or dict, got a int: 1"):
             self.get_config(self.Schema, {'option': [1]})
 
-    def test_invalid_item_none(self):
+    def test_invalid_item_none(self) -> None:
         with self.expect_error(option="Expected nav item to be a string or dict, got None"):
             self.get_config(self.Schema, {'option': [None]})
 
-    def test_invalid_children_config_int(self):
+    def test_invalid_children_config_int(self) -> None:
         with self.expect_error(option="Expected nav to be a list, got a int: 1"):
             self.get_config(self.Schema, {'option': [{"foo.md": [{"bar.md": 1}]}]})
 
-    def test_invalid_children_config_none(self):
+    def test_invalid_children_config_none(self) -> None:
         with self.expect_error(option="Expected nav to be a list, got None"):
             self.get_config(self.Schema, {'option': [{"foo.md": None}]})
 
-    def test_invalid_children_empty_dict(self):
+    def test_invalid_children_empty_dict(self) -> None:
         nav = ['foo', {}]
         with self.expect_error(option="Expected nav item to be a dict of size 1, got a dict: {}"):
             self.get_config(self.Schema, {'option': nav})
 
-    def test_invalid_nested_list(self):
+    def test_invalid_nested_list(self) -> None:
         nav = [{'aaa': [[{"bbb": "user-guide/index.md"}]]}]
         with self.expect_error(
             option="Expected nav item to be a string or dict, got a list: [{'bbb': 'user-guide/index.md'}]"
         ):
             self.get_config(self.Schema, {'option': nav})
 
-    def test_invalid_children_oversized_dict(self):
+    def test_invalid_children_oversized_dict(self) -> None:
         nav = [{"aaa": [{"bbb": "user-guide/index.md", "ccc": "user-guide/installation.md"}]}]
         with self.expect_error(
             option="Expected nav item to be a dict of size 1, got dict with keys ('bbb', 'ccc')"
         ):
             self.get_config(self.Schema, {'option': nav})
 
-    def test_warns_for_dict(self):
+    def test_warns_for_dict(self) -> None:
         self.get_config(
             self.Schema,
             {'option': [{"a": {"b": "c.md", "d": "e.md"}}]},
@@ -1165,8 +1214,8 @@ class NavTest(TestCase):
 
 
 class PrivateTest(TestCase):
-    def test_defined(self):
-        class Schema:
+    def test_defined(self) -> None:
+        class Schema(Config):
             option = c.Private()
 
         with self.expect_error(option="For internal use only."):
@@ -1174,9 +1223,9 @@ class PrivateTest(TestCase):
 
 
 class SubConfigTest(TestCase):
-    def test_subconfig_wrong_type(self):
+    def test_subconfig_wrong_type(self) -> None:
         # Test that an error is raised if subconfig does not receive a dict
-        class Schema:
+        class Schema(Config):
             option = c.SubConfig()
 
         for val in "not_a_dict", ("not_a_dict",), ["not_a_dict"]:
@@ -1189,39 +1238,8 @@ class SubConfigTest(TestCase):
                 ):
                     self.get_config(Schema, {'option': val})
 
-    def test_subconfig_ignored(self):
-        """Default behaviour of subconfig: validation is ignored"""
-
-        # Nominal
-        class Schema:
-            option = c.SubConfig(('cc', c.Choice(('foo', 'bar'))))
-
-        conf = self.get_config(Schema, {'option': {'cc': 'foo'}})
-        self.assertEqual(conf, {'option': {'cc': 'foo'}})
-
-        # Invalid option: No error
-        class Schema:
-            option = c.SubConfig(('cc', c.Choice(('foo', 'bar'))))
-
-        conf = self.get_config(Schema, {'option': {'cc': True}})
-        self.assertEqual(conf, {'option': {'cc': True}})
-
-        # Missing option: Will be considered optional with default None
-        class Schema:
-            option = c.SubConfig(('cc', c.Choice(('foo', 'bar'))))
-
-        conf = self.get_config(Schema, {'option': {}})
-        self.assertEqual(conf, {'option': {'cc': None}})
-
-        # Unknown option: No warning
-        class Schema:
-            option = c.SubConfig(('cc', c.Choice(('foo', 'bar'))))
-
-        conf = self.get_config(Schema, {'option': {'unknown_key_is_ok': 0}})
-        self.assertEqual(conf, {'option': {'cc': None, 'unknown_key_is_ok': 0}})
-
-    def test_subconfig_unknown_option(self):
-        class Schema:
+    def test_subconfig_unknown_option(self) -> None:
+        class Schema(Config):
             option = c.SubConfig(validate=True)
 
         conf = self.get_config(
@@ -1229,37 +1247,40 @@ class SubConfigTest(TestCase):
             {'option': {'unknown': 0}},
             warnings=dict(option=('unknown', 'Unrecognised configuration name: unknown')),
         )
-        self.assertEqual(conf['option'], {"unknown": 0})
+        self.assertEqual(conf.option, {"unknown": 0})
 
-    def test_subconfig_invalid_option(self):
-        class Schema:
-            option = c.SubConfig(
-                ('cc', c.Choice(('foo', 'bar'))),
-                validate=True,
-            )
+    def test_subconfig_invalid_option(self) -> None:
+        class Sub(Config):
+            cc = c.Choice(('foo', 'bar'))
+
+        class Schema(Config):
+            option = c.SubConfig(Sub)
 
         with self.expect_error(
             option="Sub-option 'cc' configuration error: Expected one of: ('foo', 'bar') but received: True"
         ):
             self.get_config(Schema, {'option': {'cc': True}})
 
-    def test_subconfig_normal(self):
-        class Schema:
-            option = c.SubConfig(
-                ('cc', c.Choice(('foo', 'bar'))),
-            )
+    def test_subconfig_normal(self) -> None:
+        class Sub(Config):
+            cc = c.Choice(('foo', 'bar'))
 
-        conf = self.get_config(Schema, {'option': {'cc': 'foo'}})
-        self.assertEqual(conf['option'], {'cc': 'foo'})
+        class Schema(Config):
+            option = c.SubConfig(Sub)
 
+        conf = self.get_config(Schema, {'option': {'cc': 'foo'}})
+        assert_type(conf.option, Sub)
+        self.assertEqual(conf.option, {'cc': 'foo'})
+        assert_type(conf.option.cc, str)
+        self.assertEqual(conf.option.cc, 'foo')
 
-class ConfigItemsTest(TestCase):
-    def test_subconfig_with_multiple_items(self):
+    def test_subconfig_with_multiple_items(self) -> None:
         # This had a bug where subsequent items would get merged into the same dict.
-        class Schema:
-            the_items = c.ConfigItems(
-                ("value", c.Type(str)),
-            )
+        class Sub(Config):
+            value = c.Type(str)
+
+        class Schema(Config):
+            the_items = c.ListOfItems(c.SubConfig(Sub))
 
         conf = self.get_config(
             Schema,
@@ -1270,45 +1291,48 @@ class ConfigItemsTest(TestCase):
                 ]
             },
         )
-        self.assertEqual(conf['the_items'], [{'value': 'a'}, {'value': 'b'}])
+        assert_type(conf.the_items, List[Sub])
+        self.assertEqual(conf.the_items, [{'value': 'a'}, {'value': 'b'}])
+        assert_type(conf.the_items[1].value, str)
+        self.assertEqual(conf.the_items[1].value, 'b')
 
-    def test_optional(self):
-        class Schema:
-            sub = c.ListOfItems(
-                c.SubConfig(
-                    ('opt', c.Type(int)),
-                    validate=True,
-                )
-            )
+    def test_optional(self) -> None:
+        class Sub(Config):
+            opt = c.Optional(c.Type(int))
+
+        class Schema(Config):
+            sub = c.Optional(c.ListOfItems(c.SubConfig(Sub)))
 
         conf = self.get_config(Schema, {})
-        self.assertEqual(conf['sub'], [])
+        self.assertEqual(conf.sub, None)
 
         conf = self.get_config(Schema, {'sub': None})
-        self.assertEqual(conf['sub'], [])
+        self.assertEqual(conf.sub, None)
 
         conf = self.get_config(Schema, {'sub': [{'opt': 1}, {}]})
-        self.assertEqual(conf['sub'], [{'opt': 1}, {'opt': None}])
+        assert_type(conf.sub, Optional[List[Sub]])
+        self.assertEqual(conf.sub, [{'opt': 1}, {'opt': None}])
+        assert conf.sub is not None
+        assert_type(conf.sub[0].opt, Optional[int])
+        self.assertEqual(conf.sub[0].opt, 1)
 
         conf = self.get_config(Schema, {'sub': []})
 
         conf = self.get_config(Schema, {'sub': [{'opt': 1}, {'opt': 2}]})
-        self.assertEqual(conf['sub'], [{'opt': 1}, {'opt': 2}])
-
-    def test_required(self):
-        class Schema:
-            sub = c.ListOfItems(
-                c.SubConfig(
-                    ('opt', c.Type(int, required=True)),
-                    validate=True,
-                )
-            )
+        self.assertEqual(conf.sub, [{'opt': 1}, {'opt': 2}])
 
-        conf = self.get_config(Schema, {})
-        self.assertEqual(conf['sub'], [])
+    def test_required(self) -> None:
+        class Sub(Config):
+            opt = c.Type(int)
 
-        conf = self.get_config(Schema, {'sub': None})
-        self.assertEqual(conf['sub'], [])
+        class Schema(Config):
+            sub = c.ListOfItems(c.SubConfig(Sub))
+
+        with self.expect_error(sub="Required configuration not provided."):
+            conf = self.get_config(Schema, {})
+
+        with self.expect_error(sub="Required configuration not provided."):
+            conf = self.get_config(Schema, {'sub': None})
 
         with self.expect_error(
             sub="Sub-option 'opt' configuration error: Expected type: <class 'int'> but received: <class 'str'>"
@@ -1318,7 +1342,10 @@ class ConfigItemsTest(TestCase):
         conf = self.get_config(Schema, {'sub': []})
 
         conf = self.get_config(Schema, {'sub': [{'opt': 1}, {'opt': 2}]})
-        self.assertEqual(conf['sub'], [{'opt': 1}, {'opt': 2}])
+        assert_type(conf.sub, List[Sub])
+        self.assertEqual(conf.sub, [{'opt': 1}, {'opt': 2}])
+        assert_type(conf.sub[0].opt, int)
+        self.assertEqual(conf.sub[0].opt, 1)
 
         with self.expect_error(
             sub="Sub-option 'opt' configuration error: Expected type: <class 'int'> but "
@@ -1338,11 +1365,25 @@ class ConfigItemsTest(TestCase):
         ):
             conf = self.get_config(Schema, {'sub': [1, 2]})
 
+    def test_default(self) -> None:
+        class Sub(Config):
+            opt = c.Type(int)
+
+        class Schema(Config):
+            sub = c.ListOfItems(c.SubConfig(Sub), default=[])
+
+        conf = self.get_config(Schema, {})
+        assert_type(conf.sub, List[Sub])
+        self.assertEqual(conf.sub, [])
+
+        with self.expect_error(sub="Required configuration not provided."):
+            conf = self.get_config(Schema, {'sub': None})
+
 
 class MarkdownExtensionsTest(TestCase):
     @patch('markdown.Markdown')
-    def test_simple_list(self, mock_md):
-        class Schema:
+    def test_simple_list(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
 
@@ -1350,12 +1391,13 @@ class MarkdownExtensionsTest(TestCase):
             'markdown_extensions': ['foo', 'bar'],
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], ['foo', 'bar'])
-        self.assertEqual(conf['mdx_configs'], {})
+        assert_type(conf.markdown_extensions, List[str])
+        self.assertEqual(conf.markdown_extensions, ['foo', 'bar'])
+        self.assertEqual(conf.mdx_configs, {})
 
     @patch('markdown.Markdown')
-    def test_list_dicts(self, mock_md):
-        class Schema:
+    def test_list_dicts(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
 
@@ -1367,9 +1409,9 @@ class MarkdownExtensionsTest(TestCase):
             ]
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], ['foo', 'bar', 'baz'])
+        self.assertEqual(conf.markdown_extensions, ['foo', 'bar', 'baz'])
         self.assertEqual(
-            conf['mdx_configs'],
+            conf.mdx_configs,
             {
                 'foo': {'foo_option': 'foo value'},
                 'bar': {'bar_option': 'bar value'},
@@ -1377,8 +1419,8 @@ class MarkdownExtensionsTest(TestCase):
         )
 
     @patch('markdown.Markdown')
-    def test_mixed_list(self, mock_md):
-        class Schema:
+    def test_mixed_list(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
 
@@ -1389,17 +1431,17 @@ class MarkdownExtensionsTest(TestCase):
             ]
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], ['foo', 'bar'])
+        self.assertEqual(conf.markdown_extensions, ['foo', 'bar'])
         self.assertEqual(
-            conf['mdx_configs'],
+            conf.mdx_configs,
             {
                 'bar': {'bar_option': 'bar value'},
             },
         )
 
     @patch('markdown.Markdown')
-    def test_dict_of_dicts(self, mock_md):
-        class Schema:
+    def test_dict_of_dicts(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
 
@@ -1411,9 +1453,9 @@ class MarkdownExtensionsTest(TestCase):
             }
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], ['foo', 'bar', 'baz'])
+        self.assertEqual(conf.markdown_extensions, ['foo', 'bar', 'baz'])
         self.assertEqual(
-            conf['mdx_configs'],
+            conf.mdx_configs,
             {
                 'foo': {'foo_option': 'foo value'},
                 'bar': {'bar_option': 'bar value'},
@@ -1421,8 +1463,8 @@ class MarkdownExtensionsTest(TestCase):
         )
 
     @patch('markdown.Markdown')
-    def test_builtins(self, mock_md):
-        class Schema:
+    def test_builtins(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
             mdx_configs = c.Private()
 
@@ -1430,11 +1472,11 @@ class MarkdownExtensionsTest(TestCase):
             'markdown_extensions': ['foo', 'bar'],
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], ['meta', 'toc', 'foo', 'bar'])
-        self.assertEqual(conf['mdx_configs'], {})
+        self.assertEqual(conf.markdown_extensions, ['meta', 'toc', 'foo', 'bar'])
+        self.assertEqual(conf.mdx_configs, {})
 
-    def test_duplicates(self):
-        class Schema:
+    def test_duplicates(self) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
             mdx_configs = c.Private()
 
@@ -1442,11 +1484,11 @@ class MarkdownExtensionsTest(TestCase):
             'markdown_extensions': ['meta', 'toc'],
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], ['meta', 'toc'])
-        self.assertEqual(conf['mdx_configs'], {})
+        self.assertEqual(conf.markdown_extensions, ['meta', 'toc'])
+        self.assertEqual(conf.mdx_configs, {})
 
-    def test_builtins_config(self):
-        class Schema:
+    def test_builtins_config(self) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
             mdx_configs = c.Private()
 
@@ -1456,12 +1498,12 @@ class MarkdownExtensionsTest(TestCase):
             ],
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], ['meta', 'toc'])
-        self.assertEqual(conf['mdx_configs'], {'toc': {'permalink': True}})
+        self.assertEqual(conf.markdown_extensions, ['meta', 'toc'])
+        self.assertEqual(conf.mdx_configs, {'toc': {'permalink': True}})
 
     @patch('markdown.Markdown')
-    def test_configkey(self, mock_md):
-        class Schema:
+    def test_configkey(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions(configkey='bar')
             bar = c.Private()
 
@@ -1471,25 +1513,25 @@ class MarkdownExtensionsTest(TestCase):
             ]
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], ['foo'])
+        self.assertEqual(conf.markdown_extensions, ['foo'])
         self.assertEqual(
-            conf['bar'],
+            conf.bar,
             {
                 'foo': {'foo_option': 'foo value'},
             },
         )
 
-    def test_missing_default(self):
-        class Schema:
+    def test_missing_default(self) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
 
         conf = self.get_config(Schema, {})
-        self.assertEqual(conf['markdown_extensions'], [])
-        self.assertEqual(conf['mdx_configs'], {})
+        self.assertEqual(conf.markdown_extensions, [])
+        self.assertEqual(conf.mdx_configs, {})
 
-    def test_none(self):
-        class Schema:
+    def test_none(self) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions(default=[])
             mdx_configs = c.Private()
 
@@ -1497,20 +1539,20 @@ class MarkdownExtensionsTest(TestCase):
             'markdown_extensions': None,
         }
         conf = self.get_config(Schema, config)
-        self.assertEqual(conf['markdown_extensions'], [])
-        self.assertEqual(conf['mdx_configs'], {})
+        self.assertEqual(conf.markdown_extensions, [])
+        self.assertEqual(conf.mdx_configs, {})
 
     @patch('markdown.Markdown')
-    def test_not_list(self, mock_md):
-        class Schema:
+    def test_not_list(self, mock_md) -> None:
+        class Schema(Config):
             option = c.MarkdownExtensions()
 
         with self.expect_error(option="Invalid Markdown Extensions configuration"):
             self.get_config(Schema, {'option': 'not a list'})
 
     @patch('markdown.Markdown')
-    def test_invalid_config_option(self, mock_md):
-        class Schema:
+    def test_invalid_config_option(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
 
         config = {
@@ -1524,8 +1566,8 @@ class MarkdownExtensionsTest(TestCase):
             self.get_config(Schema, config)
 
     @patch('markdown.Markdown')
-    def test_invalid_config_item(self, mock_md):
-        class Schema:
+    def test_invalid_config_item(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
 
         config = {
@@ -1537,8 +1579,8 @@ class MarkdownExtensionsTest(TestCase):
             self.get_config(Schema, config)
 
     @patch('markdown.Markdown')
-    def test_invalid_dict_item(self, mock_md):
-        class Schema:
+    def test_invalid_dict_item(self, mock_md) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
 
         config = {
@@ -1549,8 +1591,8 @@ class MarkdownExtensionsTest(TestCase):
         with self.expect_error(markdown_extensions="Invalid Markdown Extensions configuration"):
             self.get_config(Schema, config)
 
-    def test_unknown_extension(self):
-        class Schema:
+    def test_unknown_extension(self) -> None:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
 
         config = {
@@ -1561,10 +1603,10 @@ class MarkdownExtensionsTest(TestCase):
         ):
             self.get_config(Schema, config)
 
-    def test_multiple_markdown_config_instances(self):
+    def test_multiple_markdown_config_instances(self) -> None:
         # This had a bug where an extension config would persist to separate
         # config instances that didn't specify extensions.
-        class Schema:
+        class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
 
@@ -1574,22 +1616,22 @@ class MarkdownExtensionsTest(TestCase):
                 'markdown_extensions': [{'toc': {'permalink': '##'}}],
             },
         )
-        self.assertEqual(conf['mdx_configs']['toc'], {'permalink': '##'})
+        self.assertEqual(conf.mdx_configs['toc'], {'permalink': '##'})
 
         conf = self.get_config(
             Schema,
             {},
         )
-        self.assertIsNone(conf['mdx_configs'].get('toc'))
+        self.assertIsNone(conf.mdx_configs.get('toc'))
 
 
 class TestHooks(TestCase):
-    class Schema:
+    class Schema(Config):
         plugins = c.Plugins(default=[])
         hooks = c.Hooks('plugins')
 
     @tempdir()
-    def test_hooks(self, src_dir):
+    def test_hooks(self, src_dir) -> None:
         write_file(
             b'def on_page_markdown(markdown, **kwargs): return markdown.replace("f", "z")',
             os.path.join(src_dir, 'hooks', 'my_hook.py'),
@@ -1603,12 +1645,54 @@ class TestHooks(TestCase):
             {'hooks': ['hooks/my_hook.py']},
             config_file_path=os.path.join(src_dir, 'mkdocs.yml'),
         )
-        self.assertIn('hooks/my_hook.py', conf['plugins'])
-        hook = conf['plugins']['hooks/my_hook.py']
+        self.assertIn('hooks/my_hook.py', conf.plugins)
+        hook = conf.plugins['hooks/my_hook.py']
         self.assertTrue(hasattr(hook, 'on_page_markdown'))
         self.assertEqual(
-            {**conf['plugins'].events, 'page_markdown': [hook.on_page_markdown]},
-            conf['plugins'].events,
+            {**conf.plugins.events, 'page_markdown': [hook.on_page_markdown]},
+            conf.plugins.events,
         )
         self.assertEqual(hook.on_page_markdown('foo foo'), 'zoo zoo')
         self.assertFalse(hasattr(hook, 'on_nav'))
+
+
+class SchemaTest(TestCase):
+    def test_copy(self) -> None:
+        class Schema(Config):
+            foo = c.MarkdownExtensions()
+
+        copy.deepcopy(Schema())
+
+        copy.deepcopy(self.get_config(IpAddressTest.Schema, {'option': '1.2.3.4:5678'}))
+        copy.deepcopy(IpAddressTest.Schema)
+        copy.deepcopy(IpAddressTest.Schema())
+
+        copy.deepcopy(self.get_config(EditURITest.Schema, {}))
+        copy.deepcopy(EditURITest.Schema)
+        copy.deepcopy(EditURITest.Schema())
+
+    def test_subclass(self) -> None:
+        class A(Config):
+            foo = c.Type(int)
+            bar = c.Optional(c.Type(str))
+
+        class B(A):
+            baz = c.ListOfItems(c.Type(str))
+
+        conf = self.get_config(B, {'foo': 1, 'baz': ['b']})
+        assert_type(conf.foo, int)
+        self.assertEqual(conf.foo, 1)
+        assert_type(conf.bar, Optional[str])
+        self.assertEqual(conf.bar, None)
+        assert_type(conf.baz, List[str])
+        self.assertEqual(conf.baz, ['b'])
+
+        with self.expect_error(baz="Required configuration not provided."):
+            self.get_config(B, {'foo': 1})
+        with self.expect_error(foo="Required configuration not provided."):
+            self.get_config(B, {'baz': ['b']})
+
+        bconf = self.get_config(A, {'foo': 2, 'bar': 'a'})
+        assert_type(bconf.foo, int)
+        self.assertEqual(bconf.foo, 2)
+        self.assertEqual(bconf.bar, 'a')
diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py
index 4b3c0dd5..7144e75f 100644
--- a/mkdocs/tests/config/config_tests.py
+++ b/mkdocs/tests/config/config_tests.py
@@ -12,8 +12,6 @@ from mkdocs.exceptions import ConfigurationError
 from mkdocs.localization import parse_locale
 from mkdocs.tests.base import dedent, tempdir
 
-DEFAULT_SCHEMA = defaults.get_schema()
-
 
 class ConfigTests(unittest.TestCase):
     def test_missing_config_file(self):
@@ -21,7 +19,7 @@ class ConfigTests(unittest.TestCase):
             config.load_config(config_file='bad_filename.yaml')
 
     def test_missing_site_name(self):
-        conf = config.Config(schema=DEFAULT_SCHEMA)
+        conf = defaults.MkDocsConfig()
         conf.load_dict({})
         errors, warnings = conf.validate()
         self.assertEqual(
@@ -216,7 +214,7 @@ class ConfigTests(unittest.TestCase):
                 self.assertEqual({k: conf['theme'][k] for k in iter(conf['theme'])}, result['vars'])
 
     def test_empty_nav(self):
-        conf = config.Config(schema=DEFAULT_SCHEMA)
+        conf = defaults.MkDocsConfig()
         conf.load_dict(
             {
                 'site_name': 'Example',
@@ -227,7 +225,7 @@ class ConfigTests(unittest.TestCase):
         self.assertEqual(conf['nav'], None)
 
     def test_error_on_pages(self):
-        conf = config.Config(schema=DEFAULT_SCHEMA)
+        conf = defaults.MkDocsConfig()
         conf.load_dict(
             {
                 'site_name': 'Example',
diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py
index 9fa8cf16..29a13546 100644
--- a/mkdocs/tests/plugin_tests.py
+++ b/mkdocs/tests/plugin_tests.py
@@ -14,18 +14,16 @@ from mkdocs.exceptions import Abort, BuildError, PluginError
 from mkdocs.tests.base import load_config
 
 
-class _DummyPluginConfig:
+class _DummyPluginConfig(base.Config):
     foo = c.Type(str, default='default foo')
     bar = c.Type(int, default=0)
-    dir = c.Dir(exists=False)
+    dir = c.Optional(c.Dir(exists=False))
 
 
-class DummyPlugin(plugins.BasePlugin):
-    config_scheme = base.get_schema(_DummyPluginConfig)
-
+class DummyPlugin(plugins.BasePlugin[_DummyPluginConfig]):
     def on_pre_page(self, content, **kwargs):
         """modify page content by prepending `foo` config value."""
-        return f'{self.config["foo"]} {content}'
+        return f'{self.config.foo} {content}'
 
     def on_nav(self, item, **kwargs):
         """do nothing (return None) to not modify item."""
@@ -33,7 +31,7 @@ class DummyPlugin(plugins.BasePlugin):
 
     def on_page_read_source(self, **kwargs):
         """create new source by prepending `foo` config value to 'source'."""
-        return f'{self.config["foo"]} source'
+        return f'{self.config.foo} source'
 
     def on_pre_build(self, **kwargs):
         """do nothing (return None)."""
diff --git a/mkdocs/tests/search_tests.py b/mkdocs/tests/search_tests.py
index e5d63258..ada861ca 100644
--- a/mkdocs/tests/search_tests.py
+++ b/mkdocs/tests/search_tests.py
@@ -168,7 +168,7 @@ class SearchPluginTests(unittest.TestCase):
         self.assertEqual(result['theme'].static_templates, {'404.html', 'sitemap.xml'})
         self.assertEqual(len(result['theme'].dirs), 3)
         self.assertEqual(result['extra_javascript'], ['search/main.js'])
-        self.assertEqual(plugin.config['lang'], [result['theme']['locale'].language])
+        self.assertEqual(plugin.config.lang, [result['theme']['locale'].language])
 
     def test_event_on_config_lang(self):
         plugin = search.SearchPlugin()
@@ -179,7 +179,7 @@ class SearchPluginTests(unittest.TestCase):
         self.assertEqual(result['theme'].static_templates, {'404.html', 'sitemap.xml'})
         self.assertEqual(len(result['theme'].dirs), 3)
         self.assertEqual(result['extra_javascript'], ['search/main.js'])
-        self.assertEqual(plugin.config['lang'], ['es'])
+        self.assertEqual(plugin.config.lang, ['es'])
 
     def test_event_on_config_theme_locale(self):
         plugin = search.SearchPlugin()
@@ -192,7 +192,7 @@ class SearchPluginTests(unittest.TestCase):
         self.assertEqual(result['theme'].static_templates, {'404.html', 'sitemap.xml'})
         self.assertEqual(len(result['theme'].dirs), 3)
         self.assertEqual(result['extra_javascript'], ['search/main.js'])
-        self.assertEqual(plugin.config['lang'], [result['theme']['locale'].language])
+        self.assertEqual(plugin.config.lang, [result['theme']['locale'].language])
 
     def test_event_on_config_include_search_page(self):
         plugin = search.SearchPlugin()
@@ -421,9 +421,7 @@ class SearchIndexTests(unittest.TestCase):
         def test_page(title, filename, config):
             test_page = Page(
                 title,
-                File(
-                    filename, config['docs_dir'], config['site_dir'], config['use_directory_urls']
-                ),
+                File(filename, config.docs_dir, config.site_dir, config.use_directory_urls),
                 config,
             )
             test_page.content = """
@@ -479,7 +477,7 @@ class SearchIndexTests(unittest.TestCase):
                 self.assertEqual(warnings, [])
 
                 base_cfg = load_config()
-                base_cfg['plugins']['search'].config['indexing'] = option
+                base_cfg['plugins']['search'].config.indexing = option
 
                 pages = [
                     test_page('Home', 'index.md', base_cfg),
diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py
index 020da859..7f5fd1ee 100644
--- a/mkdocs/tests/structure/file_tests.py
+++ b/mkdocs/tests/structure/file_tests.py
@@ -389,7 +389,7 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
     )
     def test_add_files_from_theme(self, tdir, ddir):
         config = load_config(docs_dir=ddir, theme={'name': None, 'custom_dir': tdir})
-        env = config['theme'].get_env()
+        env = config.theme.get_env()
         files = get_files(config)
         self.assertEqual(
             [file.src_path for file in files],