Repository: brentyi/tyro (language: python)

Issue:
Subcommands for choosing between base configs / defaults
One super common pattern is:
1. Define a set of base configurations, for example:
    ```python
    base_configs = {
        "small": ExperimentConfig(
            dataset="mnist",
            optimizer=SgdOptimizer(),
            batch_size=2048,
            num_layers=4,
            units=64,
            train_steps=30_000,
            # The dcargs.MISSING sentinel allows us to specify that the seed should have no
            # default, and needs to be populated from the CLI.
            seed=dcargs.MISSING,
            activation=nn.ReLU,
        ),
        "big": ExperimentConfig(
            dataset="imagenet-50",
            optimizer=AdamOptimizer(),
            batch_size=32,
            num_layers=8,
            units=256,
            train_steps=100_000,
            seed=dcargs.MISSING,
            activation=nn.GELU,
        ),
    }
    ```

2. Choose one of these base configurations, and then use the CLI to override fields in it.

This behavior can currently be achieved by either reading from an environment variable or manually inspecting `sys.argv`, as we do in the [base config example](https://github.com/brentyi/dcargs/blob/f5c28656c432a4aad89658fd05ac85738494edcf/examples/06_base_configs.py).

However, both of these methods have drawbacks: they feel hacky, aren't compatible with tools like `shtab` or `argcomplete`, and can make it hard to introduce CLI flags that don't need to be part of the config object.

The ultimate behavior also looks identical to adding subcommands, which `argparse` fully supports. Note that we could already create subcommands if we just created a subclass for each base config and passed in `Union[SmallExperimentConfig, BigExperimentConfig]`, but this seems clunky.

---

What would be nice: being able to define a set of subcommands that correspond to a single nested type, where each subcommand differs only in the defaults that are used.

Some API options:

1.
    ```python
    import dcargs
    from typing import Annotated, Optional
    import pathlib

    base_configs = {...}

    def train(
        config: Annotated[ExperimentConfig, dcargs.extras.magically_produce_subcommands(base_configs)],
        restore_checkpoint_path: Optional[pathlib.Path] = None,
    ) -> None:
        ...
    ```
    This is reasonably succinct, but can't be type-checked.

2.
    ```python
    import dcargs
    from typing import Optional
    import pathlib

    base_configs = {...}

    def train(
        config: ExperimentConfig = dcargs.extras.magically_produce_subcommands(base_configs),
        restore_checkpoint_path: Optional[pathlib.Path] = None,
    ) -> None:
        ...
    ```
    This is slightly more succinct, and in contrast to using `Annotated[]` with some hacking could be statically type-checked (similar to `dataclasses.field()`). But it becomes unclear how this field should be handled when `train()` is called outside of `dcargs.cli()`.

3.
    ```python
    import pathlib
    from typing import Annotated, Optional, Type, TypeVar, Union
    
    import dcargs
    
    
    base_configs = {...}
    
    
    SmallExperimentConfig = Annotated[
        ExperimentConfig,
        dcargs.extras.SubcommandMetadata(name="small", default_instance=base_configs["small"]),
    ]
    BigExperimentConfig = Annotated[
        ExperimentConfig,
        dcargs.extras.SubcommandMetadata(name="big", default_instance=base_configs["big"]),
    ]
    
    
    def train(
        config: Union[SmallExperimentConfig, BigExperimentConfig],
        restore_checkpoint_path: Optional[pathlib.Path] = None,
    ) -> None:
        ...
    ```
    This is... verbose, and there's no way to type-check the default value, but it's also much more general and follows the existing `Union[]` idiom for creating subparsers,

    To reduce verbosity, we could write a helper that dynamically returns the necessary `Union[Annotated[...], Annotated[...]]` type from a dictionary. Pyright understands this, but unfortunately mypy doesn't:
    ```python
    T = TypeVar("T")

    def make_subcommands_type(instance_from_subcommand: Mapping[str, T]) -> Type[T]:
        return Union.__getitem__(  # type: ignore
            tuple(
                Annotated.__class_getitem__((type(v), SubcommandMetadata(k, v)))  # type: ignore
                for k, v in instance_from_subcommand.items()
            )
        )


    base_configs = {...}
    SelectableExperimentConfig = make_subcommands_type(base_configs)


    def train(
        config: SelectableExperimentConfig,
        restore_checkpoint_path: Optional[pathlib.Path] = None,
    ) -> None:
        reveal_type(config)  # correctly inferred from the type of `base_configs` in Pyright!
        ...
    ```

    We could make `mypy` happy by writing:
    ```python
    if TYPE_CHECKING:
        SelectableExperimentConfig = ExperimentConfig
    else:
        SelectableExperimentConfig = make_subcommands_type(base_configs)
    ```
    
    Or, if we want to get really crazy:
    ```python
    SelectableExperimentConfig = ExperimentConfig
    SelectableExperimentConfig = make_subcommands_type(base_configs)  # type: ignore
    ```

---

Some questions to think about:
1. The syntax options above all seem pretty gross. Is there a cleaner approach, particularly one that's type-safe and/or doesn't require custom metadata? Avoiding this kind of logic is/was a core design goal.
2. If custom metadata is necessary, is it worth it?
Subcommands for choosing between base configs / defaults
One super common pattern is:
1. Define a set of base configurations, for example:
    ```python
    base_configs = {
        "small": ExperimentConfig(
            dataset="mnist",
            optimizer=SgdOptimizer(),
            batch_size=2048,
            num_layers=4,
            units=64,
            train_steps=30_000,
            # The dcargs.MISSING sentinel allows us to specify that the seed should have no
            # default, and needs to be populated from the CLI.
            seed=dcargs.MISSING,
            activation=nn.ReLU,
        ),
        "big": ExperimentConfig(
            dataset="imagenet-50",
            optimizer=AdamOptimizer(),
            batch_size=32,
            num_layers=8,
            units=256,
            train_steps=100_000,
            seed=dcargs.MISSING,
            activation=nn.GELU,
        ),
    }
    ```

2. Choose one of these base configurations, and then use the CLI to override fields in it.

This behavior can currently be achieved by either reading from an environment variable or manually inspecting `sys.argv`, as we do in the [base config example](https://github.com/brentyi/dcargs/blob/f5c28656c432a4aad89658fd05ac85738494edcf/examples/06_base_configs.py).

However, both of these methods have drawbacks: they feel hacky, aren't compatible with tools like `shtab` or `argcomplete`, and can make it hard to introduce CLI flags that don't need to be part of the config object.

The ultimate behavior also looks identical to adding subcommands, which `argparse` fully supports. Note that we could already create subcommands if we just created a subclass for each base config and passed in `Union[SmallExperimentConfig, BigExperimentConfig]`, but this seems clunky.

---

What would be nice: being able to define a set of subcommands that correspond to a single nested type, where each subcommand differs only in the defaults that are used.

Some API options:

1.
    ```python
    import dcargs
    from typing import Annotated, Optional
    import pathlib

    base_configs = {...}

    def train(
        config: Annotated[ExperimentConfig, dcargs.extras.magically_produce_subcommands(base_configs)],
        restore_checkpoint_path: Optional[pathlib.Path] = None,
    ) -> None:
        ...
    ```
    This is reasonably succinct, but can't be type-checked.

2.
    ```python
    import dcargs
    from typing import Optional
    import pathlib

    base_configs = {...}

    def train(
        config: ExperimentConfig = dcargs.extras.magically_produce_subcommands(base_configs),
        restore_checkpoint_path: Optional[pathlib.Path] = None,
    ) -> None:
        ...
    ```
    This is slightly more succinct, and in contrast to using `Annotated[]` with some hacking could be statically type-checked (similar to `dataclasses.field()`). But it becomes unclear how this field should be handled when `train()` is called outside of `dcargs.cli()`.

3.
    ```python
    import pathlib
    from typing import Annotated, Optional, Type, TypeVar, Union
    
    import dcargs
    
    
    base_configs = {...}
    
    
    SmallExperimentConfig = Annotated[
        ExperimentConfig,
        dcargs.extras.SubcommandMetadata(name="small", default_instance=base_configs["small"]),
    ]
    BigExperimentConfig = Annotated[
        ExperimentConfig,
        dcargs.extras.SubcommandMetadata(name="big", default_instance=base_configs["big"]),
    ]
    
    
    def train(
        config: Union[SmallExperimentConfig, BigExperimentConfig],
        restore_checkpoint_path: Optional[pathlib.Path] = None,
    ) -> None:
        ...
    ```
    This is... verbose, and there's no way to type-check the default value, but it's also much more general and follows the existing `Union[]` idiom for creating subparsers,

    To reduce verbosity, we could write a helper that dynamically returns the necessary `Union[Annotated[...], Annotated[...]]` type from a dictionary. Pyright understands this, but unfortunately mypy doesn't:
    ```python
    T = TypeVar("T")

    def make_subcommands_type(instance_from_subcommand: Mapping[str, T]) -> Type[T]:
        return Union.__getitem__(  # type: ignore
            tuple(
                Annotated.__class_getitem__((type(v), SubcommandMetadata(k, v)))  # type: ignore
                for k, v in instance_from_subcommand.items()
            )
        )


    base_configs = {...}
    SelectableExperimentConfig = make_subcommands_type(base_configs)


    def train(
        config: SelectableExperimentConfig,
        restore_checkpoint_path: Optional[pathlib.Path] = None,
    ) -> None:
        reveal_type(config)  # correctly inferred from the type of `base_configs` in Pyright!
        ...
    ```

    We could make `mypy` happy by writing:
    ```python
    if TYPE_CHECKING:
        SelectableExperimentConfig = ExperimentConfig
    else:
        SelectableExperimentConfig = make_subcommands_type(base_configs)
    ```
    
    Or, if we want to get really crazy:
    ```python
    SelectableExperimentConfig = ExperimentConfig
    SelectableExperimentConfig = make_subcommands_type(base_configs)  # type: ignore
    ```

---

Some questions to think about:
1. The syntax options above all seem pretty gross. Is there a cleaner approach, particularly one that's type-safe and/or doesn't require custom metadata? Avoiding this kind of logic is/was a core design goal.
2. If custom metadata is necessary, is it worth it?


Discussion / hints:

Fix patch:
diff --git a/dcargs/__init__.py b/dcargs/__init__.py
index 4022ff6a..6f225244 100644
--- a/dcargs/__init__.py
+++ b/dcargs/__init__.py
@@ -1,12 +1,12 @@
-from . import extras
-from ._cli import cli, generate_parser
+from . import conf, extras
+from ._cli import cli
 from ._fields import MISSING_PUBLIC as MISSING
 from ._instantiators import UnsupportedTypeAnnotationError
 
 __all__ = [
+    "conf",
     "extras",
     "cli",
-    "generate_parser",
     "MISSING",
     "UnsupportedTypeAnnotationError",
 ]
diff --git a/dcargs/_argparse_formatter.py b/dcargs/_argparse_formatter.py
index 3a59b9b6..a6dac359 100644
--- a/dcargs/_argparse_formatter.py
+++ b/dcargs/_argparse_formatter.py
@@ -5,6 +5,8 @@
 import shutil
 from typing import Any, ContextManager, Generator
 
+import termcolor
+
 from . import _strings
 
 
@@ -15,6 +17,22 @@ def monkeypatch_len(obj: Any) -> int:
         return len(obj)
 
 
+def dummy_termcolor_context() -> ContextManager[None]:
+    """Context for turning termcolor off."""
+
+    def dummy_colored(*args, **kwargs) -> str:
+        return args[0]
+
+    @contextlib.contextmanager
+    def inner() -> Generator[None, None, None]:
+        orig_colored = termcolor.colored
+        termcolor.colored = dummy_colored
+        yield
+        termcolor.colored = orig_colored
+
+    return inner()
+
+
 def ansi_context() -> ContextManager[None]:
     """Context for working with ANSI codes + argparse:
     - Applies a temporary monkey patch for making argparse ignore ANSI codes when
@@ -25,6 +43,7 @@ def ansi_context() -> ContextManager[None]:
     @contextlib.contextmanager
     def inner() -> Generator[None, None, None]:
         if not hasattr(argparse, "len"):
+            # Sketchy, but seems to work.
             argparse.len = monkeypatch_len  # type: ignore
             try:
                 # Use Colorama to support coloring in Windows shells.
@@ -136,7 +155,8 @@ def _format_action(self, action):
                 )
             )
             # </new>
-            parts.append("%*s%s\n" % (indent_first, "", help_lines[0]))
+
+            parts.append("%*s%s\n" % (indent_first, "", help_lines[0]))  # type: ignore
             for line in help_lines[1:]:
                 parts.append("%*s%s\n" % (help_position, "", line))
 
@@ -150,3 +170,18 @@ def _format_action(self, action):
 
         # return a single string
         return self._join_parts(parts)
+
+    def _split_lines(self, text, width):
+        text = self._whitespace_matcher.sub(" ", text).strip()
+        # The textwrap module is used only for formatting help.
+        # Delay its import for speeding up the common usage of argparse.
+        import textwrap as textwrap
+
+        # Sketchy, but seems to work.
+        textwrap.len = monkeypatch_len  # type: ignore
+        out = textwrap.wrap(text, width)
+        del textwrap.len  # type: ignore
+        return out
+
+    def _fill_text(self, text, width, indent):
+        return "".join(indent + line for line in text.splitlines(keepends=True))
diff --git a/dcargs/_arguments.py b/dcargs/_arguments.py
index d10df3c6..dfb8d08d 100644
--- a/dcargs/_arguments.py
+++ b/dcargs/_arguments.py
@@ -23,7 +23,8 @@
 
 import termcolor
 
-from . import _fields, _instantiators, _strings
+from . import _fields, _instantiators, _resolver, _strings
+from .conf import _markers
 
 try:
     # Python >=3.8.
@@ -95,9 +96,9 @@ class LoweredArgumentDefinition:
 
     def is_fixed(self) -> bool:
         """If the instantiator is set to `None`, even after all argument
-        transformations, it means that we weren't able to determine a valid instantiator
-        for an argument. We then mark the argument as 'fixed', with a value always equal
-        to the field default."""
+        transformations, it means that we don't have a valid instantiator for an
+        argument. We then mark the argument as 'fixed', with a value always equal to the
+        field default."""
         return self.instantiator is None
 
     # From here on out, all fields correspond 1:1 to inputs to argparse's
@@ -132,26 +133,35 @@ def _rule_handle_boolean_flags(
     arg: ArgumentDefinition,
     lowered: LoweredArgumentDefinition,
 ) -> LoweredArgumentDefinition:
-    if arg.type_from_typevar.get(arg.field.typ, arg.field.typ) is not bool:  # type: ignore
+    if _resolver.apply_type_from_typevar(arg.field.typ, arg.type_from_typevar) is not bool:  # type: ignore
         return lowered
 
-    if lowered.default is False and not arg.field.positional:
+    if (
+        arg.field.default in _fields.MISSING_SINGLETONS
+        or arg.field.is_positional()
+        or _markers.FLAG_CONVERSION_OFF in arg.field.markers
+    ):
+        # Treat bools as a normal parameter.
+        return lowered
+    elif arg.field.default is False:
         # Default `False` => --flag passed in flips to `True`.
         return dataclasses.replace(
             lowered,
             action="store_true",
             instantiator=lambda x: x,  # argparse will directly give us a bool!
         )
-    elif lowered.default is True and not arg.field.positional:
+    elif arg.field.default is True:
         # Default `True` => --no-flag passed in flips to `False`.
         return dataclasses.replace(
             lowered,
             action="store_false",
             instantiator=lambda x: x,  # argparse will directly give us a bool!
         )
-    else:
-        # Treat bools as a normal parameter.
-        return lowered
+
+    assert False, (
+        "Expected a boolean as a default for {arg.field.name}, but got"
+        " {lowered.default}."
+    )
 
 
 def _rule_recursive_instantiator_from_type(
@@ -166,6 +176,14 @@ def _rule_recursive_instantiator_from_type(
     Conversions from strings to our desired types happen in the instantiator; this is a
     bit more flexible, and lets us handle more complex types like enums and multi-type
     tuples."""
+    if _markers.FIXED in arg.field.markers:
+        return dataclasses.replace(
+            lowered,
+            instantiator=None,
+            metavar=termcolor.colored("{fixed}", color="red"),
+            required=False,
+            default=_fields.MISSING_PROP,
+        )
     if lowered.instantiator is not None:
         return lowered
     try:
@@ -175,7 +193,11 @@ def _rule_recursive_instantiator_from_type(
         )
     except _instantiators.UnsupportedTypeAnnotationError as e:
         if arg.field.default in _fields.MISSING_SINGLETONS:
-            raise e
+            raise _instantiators.UnsupportedTypeAnnotationError(
+                "Unsupported type annotation for the field"
+                f" {_strings.make_field_name([arg.prefix, arg.field.name])}. To"
+                " suppress this error, assign the field a default value."
+            ) from e
         else:
             # For fields with a default, we'll get by even if there's no instantiator
             # available.
@@ -238,9 +260,9 @@ def _rule_generate_helptext(
         # https://stackoverflow.com/questions/21168120/python-argparse-errors-with-in-help-string
         docstring_help = docstring_help.replace("%", "%%")
         help_parts.append(docstring_help)
-    elif arg.field.positional and arg.field.name != _strings.dummy_field_name:
+    elif arg.field.is_positional() and arg.field.name != _strings.dummy_field_name:
         # Place the type in the helptext. Note that we skip this for dummy fields, which
-        # will sitll have the type in the metavar.
+        # will still have the type in the metavar.
         help_parts.append(str(lowered.metavar))
 
     default = lowered.default
@@ -287,7 +309,7 @@ def _rule_set_name_or_flag(
     arg: ArgumentDefinition,
     lowered: LoweredArgumentDefinition,
 ) -> LoweredArgumentDefinition:
-    if arg.field.positional:
+    if arg.field.is_positional():
         name_or_flag = _strings.make_field_name([arg.prefix, arg.field.name])
     elif lowered.action == "store_false":
         name_or_flag = "--" + _strings.make_field_name(
@@ -309,7 +331,7 @@ def _rule_positional_special_handling(
     arg: ArgumentDefinition,
     lowered: LoweredArgumentDefinition,
 ) -> LoweredArgumentDefinition:
-    if not arg.field.positional:
+    if not arg.field.is_positional():
         return lowered
 
     metavar = _strings.make_field_name([arg.prefix, arg.field.name]).upper()
diff --git a/dcargs/_calling.py b/dcargs/_calling.py
index 958b587f..1c979eca 100644
--- a/dcargs/_calling.py
+++ b/dcargs/_calling.py
@@ -5,7 +5,7 @@
 
 from typing import Any, Callable, Dict, List, Sequence, Set, Tuple, TypeVar, Union
 
-from typing_extensions import get_args, get_origin
+from typing_extensions import get_args
 
 from . import _arguments, _fields, _parsers, _resolver, _strings
 
@@ -24,7 +24,6 @@ def call_from_args(
     default_instance: Union[T, _fields.NonpropagatingMissingType],
     value_from_prefixed_field_name: Dict[str, Any],
     field_name_prefix: str,
-    avoid_subparsers: bool,
 ) -> Tuple[T, Set[str]]:
     """Call `f` with arguments specified by a dictionary of values from argparse.
 
@@ -56,11 +55,7 @@ def get_value_from_arg(prefixed_field_name: str) -> Any:
         prefixed_field_name = _strings.make_field_name([field_name_prefix, field.name])
 
         # Resolve field type.
-        field_type = (
-            type_from_typevar[field.typ]  # type: ignore
-            if field.typ in type_from_typevar
-            else field.typ
-        )
+        field_type = _resolver.apply_type_from_typevar(field.typ, type_from_typevar)  # type: ignore
 
         if prefixed_field_name in arg_from_prefixed_field_name:
             assert prefixed_field_name not in consumed_keywords
@@ -100,8 +95,7 @@ def get_value_from_arg(prefixed_field_name: str) -> Any:
             in parser_definition.helptext_from_nested_class_field_name
         ):
             # Nested callable.
-            if get_origin(field_type) is Union:
-                assert avoid_subparsers
+            if _resolver.unwrap_origin_strip_extras(field_type) is Union:
                 field_type = type(field.default)
             value, consumed_keywords_child = call_from_args(
                 field_type,
@@ -109,7 +103,6 @@ def get_value_from_arg(prefixed_field_name: str) -> Any:
                 field.default,
                 value_from_prefixed_field_name,
                 field_name_prefix=prefixed_field_name,
-                avoid_subparsers=avoid_subparsers,
             )
             consumed_keywords |= consumed_keywords_child
         else:
@@ -148,8 +141,8 @@ def get_value_from_arg(prefixed_field_name: str) -> Any:
                 value = None
             else:
                 options = map(
-                    lambda x: x if x not in type_from_typevar else type_from_typevar[x],
-                    get_args(field_type),
+                    lambda x: _resolver.apply_type_from_typevar(x, type_from_typevar),
+                    get_args(_resolver.unwrap_annotated(field_type)[0]),
                 )
                 chosen_f = None
                 for option in options:
@@ -166,19 +159,18 @@ def get_value_from_arg(prefixed_field_name: str) -> Any:
                     field.default if type(field.default) is chosen_f else None,
                     value_from_prefixed_field_name,
                     field_name_prefix=prefixed_field_name,
-                    avoid_subparsers=avoid_subparsers,
                 )
                 consumed_keywords |= consumed_keywords_child
 
         if value is not _fields.EXCLUDE_FROM_CALL:
-            if field.positional:
+            if field.is_positional():
                 args.append(value)
             else:
                 kwargs[
                     field.name if field.name_override is None else field.name_override
                 ] = value
 
-    unwrapped_f = _resolver.unwrap_origin(f)
+    unwrapped_f = _resolver.unwrap_origin_strip_extras(f)
     unwrapped_f = list if unwrapped_f is Sequence else unwrapped_f  # type: ignore
     unwrapped_f = _resolver.narrow_type(unwrapped_f, default_instance)
     if unwrapped_f in (tuple, list, set):
diff --git a/dcargs/_cli.py b/dcargs/_cli.py
index ee40c530..84a1caee 100644
--- a/dcargs/_cli.py
+++ b/dcargs/_cli.py
@@ -1,12 +1,13 @@
 """Core public API."""
-
 import argparse
 import dataclasses
+import sys
+import warnings
 from typing import Callable, Optional, Sequence, Type, TypeVar, Union, cast, overload
 
-from typing_extensions import Literal, assert_never
+import shtab
 
-from . import _argparse_formatter, _calling, _fields, _parsers, _strings
+from . import _argparse_formatter, _calling, _fields, _parsers, _strings, conf
 
 OutT = TypeVar("OutT")
 
@@ -26,8 +27,7 @@ def cli(
     prog: Optional[str] = None,
     description: Optional[str] = None,
     args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
+    default: Optional[OutT] = None,
 ) -> OutT:
     ...
 
@@ -39,8 +39,7 @@ def cli(
     prog: Optional[str] = None,
     description: Optional[str] = None,
     args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
+    default: Optional[OutT] = None,
 ) -> OutT:
     ...
 
@@ -51,8 +50,8 @@ def cli(
     prog: Optional[str] = None,
     description: Optional[str] = None,
     args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
+    default: Optional[OutT] = None,
+    **deprecated_kwargs,
 ) -> OutT:
     """Call `f(...)`, with arguments populated from an automatically generated CLI
     interface.
@@ -61,7 +60,7 @@ def cli(
     `f` is a class, `dcargs.cli()` returns an instance.
 
     The parser is generated by populating helptext from docstrings and types from
-    annotations; a broad range of core type annotations are supported...
+    annotations; a broad range of core type annotations are supported.
     - Types natively accepted by `argparse`: str, int, float, pathlib.Path, etc.
     - Default values for optional parameters.
     - Booleans, which are automatically converted to flags when provided a default
@@ -88,6 +87,9 @@ def cli(
       - Optional unions over nested structures (optional subparsers).
     - Generics (including nested generics).
 
+    Completion scripts for interactive shells is also provided. To print a script that
+    can be used for tab completion, pass in `--dcargs-print-completion {bash/zsh/tcsh}`.
+
     Args:
         f: Callable.
         prog: The name of the program printed in helptext. Mirrors argument from
@@ -97,130 +99,45 @@ def cli(
             `argparse.ArgumentParser()`.
         args: If set, parse arguments from a sequence of strings instead of the
             commandline. Mirrors argument from `argparse.ArgumentParser.parse_args()`.
-        default_instance: An instance of `T` to use for default values; only supported
+        default: An instance of `T` to use for default values; only supported
             if `T` is a dataclass, TypedDict, or NamedTuple. Helpful for merging CLI
             arguments with values loaded from elsewhere. (for example, a config object
             loaded from a yaml file)
-        avoid_subparsers: Avoid creating a subparser when defaults are provided for
-            unions over nested types. Generates cleaner but less expressive CLIs.
 
     Returns:
         The output of `f(...)`.
     """
-    out = _cli_impl(
-        "f_out",
-        f,
-        prog=prog,
-        description=description,
-        args=args,
-        default_instance=default_instance,
-        avoid_subparsers=avoid_subparsers,
-    )
-    return out
-
-
-@overload
-def generate_parser(
-    f: Type[OutT],
-    *,
-    prog: Optional[str] = None,
-    description: Optional[str] = None,
-    args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
-) -> argparse.ArgumentParser:
-    ...
-
-
-@overload
-def generate_parser(
-    f: Callable[..., OutT],
-    *,
-    prog: Optional[str] = None,
-    description: Optional[str] = None,
-    args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
-) -> argparse.ArgumentParser:
-    ...
-
-
-def generate_parser(
-    f: Union[Type[OutT], Callable[..., OutT]],
-    *,
-    prog: Optional[str] = None,
-    description: Optional[str] = None,
-    args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
-) -> argparse.ArgumentParser:
-    """Returns the argparse parser that would be used under-the-hood if `dcargs.cli()`
-    was called with the same arguments.
-
-    This can be useful for libraries like argcomplete, pyzshcomplete, or shtab, which
-    enable autocompletion for argparse parsers."""
-    out = _cli_impl(
-        "parser",
-        f,
-        prog=prog,
-        description=description,
-        args=args,
-        default_instance=default_instance,
-        avoid_subparsers=avoid_subparsers,
-    )
-    assert isinstance(out, argparse.ArgumentParser)
-    return out
-
-
-@overload
-def _cli_impl(
-    _return_stage: Literal["parser"],
-    f: Callable[..., OutT],
-    *,
-    prog: Optional[str] = None,
-    description: Optional[str] = None,
-    args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
-) -> argparse.ArgumentParser:
-    ...
-
-
-@overload
-def _cli_impl(
-    _return_stage: Literal["f_out"],
-    f: Callable[..., OutT],
-    *,
-    prog: Optional[str] = None,
-    description: Optional[str] = None,
-    args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
-) -> OutT:
-    ...
-
+    if "default_instance" in deprecated_kwargs:
+        warnings.warn(
+            "`default_instance=` is deprecated! use `default=` instead.", stacklevel=2
+        )
+        default = deprecated_kwargs["default_instance"]
+    if deprecated_kwargs.get("avoid_subparsers", False):
+        f = conf.AvoidSubcommands[f]  # type: ignore
+        warnings.warn(
+            "`avoid_subparsers=` is deprecated! use `dcargs.conf.AvoidSubparsers[]`"
+            " instead.",
+            stacklevel=2,
+        )
 
-def _cli_impl(
-    _return_stage: Literal["parser", "f_out"],
-    f: Callable[..., OutT],
-    *,
-    prog: Optional[str] = None,
-    description: Optional[str] = None,
-    args: Optional[Sequence[str]] = None,
-    default_instance: Optional[OutT] = None,
-    avoid_subparsers: bool = False,
-) -> Union[OutT, argparse.ArgumentParser]:
+    # Internally, we distinguish between two concepts:
+    # - "default", which is used for individual arguments.
+    # - "default_instance", which is used for _fields_ (which may be broken down into
+    #   one or many arguments, depending on various factors).
+    #
+    # This could be revisited.
     default_instance_internal: Union[_fields.NonpropagatingMissingType, OutT] = (
-        _fields.MISSING_NONPROP if default_instance is None else default_instance
+        _fields.MISSING_NONPROP if default is None else default
     )
 
+    # We wrap our type with a dummy dataclass if it can't be treated as a nested type.
+    # For example: passing in f=int will result in a dataclass with a single field
+    # typed as int.
     if not _fields.is_nested_type(cast(Type, f), default_instance_internal):
         dummy_field = cast(
             dataclasses.Field,
             dataclasses.field(
-                default=default_instance
-                if default_instance is not None
-                else dataclasses.MISSING
+                default=default if default is not None else dataclasses.MISSING
             ),
         )
         f = dataclasses.make_dataclass(
@@ -239,11 +156,24 @@ def _cli_impl(
         parent_type_from_typevar=None,  # Used for recursive calls.
         default_instance=default_instance_internal,  # Overrides for default values.
         prefix="",  # Used for recursive calls.
-        avoid_subparsers=avoid_subparsers,
     )
 
+    # If we pass in the --dcargs-print-completion flag: turn termcolor off, and get the
+    # shell we want to generate a completion script for (bash/zsh/tcsh).
+    #
+    # Note that shtab also offers an add_argument_to() functions that fulfills a similar
+    # goal, but manual parsing of argv is convenient for turning off colors.
+    args = sys.argv[1:] if args is None else args
+    print_completion = len(args) >= 2 and args[0] == "--dcargs-print-completion"
+
+    formatting_context = _argparse_formatter.ansi_context()
+    completion_shell = None
+    if print_completion:
+        formatting_context = _argparse_formatter.dummy_termcolor_context()
+        completion_shell = args[1]
+
     # Generate parser!
-    with _argparse_formatter.ansi_context():
+    with formatting_context:
         parser = argparse.ArgumentParser(
             prog=prog,
             formatter_class=_argparse_formatter.make_formatter_class(
@@ -251,8 +181,21 @@ def _cli_impl(
             ),
         )
         parser_definition.apply(parser)
-        if _return_stage == "parser":
-            return parser
+
+        if print_completion:
+            assert completion_shell in ("bash", "zsh", "tcsh",), (
+                "Shell should be one `bash`, `zsh`, or `tcsh`, but got"
+                f" {completion_shell}"
+            )
+            print(
+                shtab.complete(
+                    parser=parser,
+                    shell=completion_shell,
+                    root_prefix=f"dcargs_{parser.prog}",
+                )
+            )
+            raise SystemExit()
+
         value_from_prefixed_field_name = vars(parser.parse_args(args=args))
 
     if dummy_wrapped:
@@ -269,7 +212,6 @@ def _cli_impl(
             default_instance_internal,
             value_from_prefixed_field_name,
             field_name_prefix="",
-            avoid_subparsers=avoid_subparsers,
         )
     except _calling.InstantiationError as e:
         # Emulate argparse's error behavior when invalid arguments are passed in.
@@ -285,7 +227,4 @@ def _cli_impl(
 
     if dummy_wrapped:
         out = getattr(out, _strings.dummy_field_name)
-    if _return_stage == "f_out":
-        return out
-
-    assert_never(_return_stage)
+    return out
diff --git a/dcargs/_docstrings.py b/dcargs/_docstrings.py
index dcca98ad..b236b583 100644
--- a/dcargs/_docstrings.py
+++ b/dcargs/_docstrings.py
@@ -150,6 +150,12 @@ def get_class_tokenization_with_field(
 def get_field_docstring(cls: Type, field_name: str) -> Optional[str]:
     """Get docstring for a field in a class."""
 
+    docstring = inspect.getdoc(cls)
+    if docstring is not None:
+        for param_doc in docstring_parser.parse(docstring).params:
+            if param_doc.arg_name == field_name:
+                return param_doc.description
+
     tokenization = get_class_tokenization_with_field(cls, field_name)
     if tokenization is None:  # Currently only happens for dynamic dataclasses.
         return None
@@ -254,7 +260,8 @@ def get_callable_description(f: Callable) -> str:
     these docstrings."""
 
     f, _unused = _resolver.resolve_generic_types(f)
-    if _resolver.unwrap_origin(f) in _callable_description_blocklist:
+    f = _resolver.unwrap_origin_strip_extras(f)
+    if f in _callable_description_blocklist:
         return ""
 
     # Note inspect.getdoc() causes some corner cases with TypedDicts.
@@ -276,17 +283,16 @@ def get_callable_description(f: Callable) -> str:
         default_doc = f.__name__ + str(inspect.signature(f)).replace(" -> None", "")
         if docstring == default_doc:
             return ""
-        return docstring
-    else:
-        parsed_docstring = docstring_parser.parse(docstring)
-        return "\n".join(
-            list(
-                filter(
-                    lambda x: x is not None,  # type: ignore
-                    [
-                        parsed_docstring.short_description,
-                        parsed_docstring.long_description,
-                    ],
-                )
+
+    parsed_docstring = docstring_parser.parse(docstring)
+    return "\n".join(
+        list(
+            filter(
+                lambda x: x is not None,  # type: ignore
+                [
+                    parsed_docstring.short_description,
+                    parsed_docstring.long_description,
+                ],
             )
         )
+    )
diff --git a/dcargs/_fields.py b/dcargs/_fields.py
index 4f9d0cf9..95048efc 100644
--- a/dcargs/_fields.py
+++ b/dcargs/_fields.py
@@ -11,13 +11,27 @@
 import itertools
 import typing
 import warnings
-from typing import Any, Callable, Hashable, Iterable, List, Optional, Type, Union, cast
+from typing import (
+    Any,
+    Callable,
+    FrozenSet,
+    Hashable,
+    Iterable,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    Union,
+    cast,
+)
 
 import docstring_parser
 import typing_extensions
-from typing_extensions import get_args, get_type_hints, is_typeddict
+from typing_extensions import Annotated, get_args, get_type_hints, is_typeddict
 
-from . import _docstrings, _instantiators, _resolver, _strings
+from . import conf  # Avoid circular import.
+from . import _docstrings, _instantiators, _resolver, _singleton, _strings
+from .conf import _markers
 
 
 @dataclasses.dataclass(frozen=True)
@@ -26,38 +40,60 @@ class FieldDefinition:
     typ: Type
     default: Any
     helptext: Optional[str]
-    positional: bool
+    markers: FrozenSet[_markers.Marker]
 
     # Override the name in our kwargs. Currently only used for dictionary types when
     # the key values aren't strings, but in the future could be used whenever the
     # user-facing argument name doesn't match the keyword expected by our callable.
-    name_override: Optional[Any] = None
-
+    name_override: Optional[Any]
+
+    @staticmethod
+    def make(
+        name: str,
+        typ: Type,
+        default: Any,
+        helptext: Optional[str],
+        *,
+        markers: Tuple[_markers.Marker, ...] = (),
+        name_override: Optional[Any] = None,
+    ):
+        _, inferred_markers = _resolver.unwrap_annotated(typ, _markers.Marker)
+        return FieldDefinition(
+            name,
+            typ,
+            default,
+            helptext,
+            frozenset(inferred_markers).union(markers),
+            name_override,
+        )
 
-class _Singleton:
-    # Singleton pattern.
-    # https://www.python.org/download/releases/2.2/descrintro/#__new__
-    def __new__(cls, *args, **kwds):
-        it = cls.__dict__.get("__it__")
-        if it is not None:
-            return it
-        cls.__it__ = it = object.__new__(cls)
-        it.init(*args, **kwds)
-        return it
+    def add_markers(self, markers: Tuple[_markers.Marker, ...]) -> FieldDefinition:
+        if len(markers) == 0:
+            return self
+        return dataclasses.replace(
+            self,
+            typ=Annotated.__class_getitem__((self.typ,) + markers),  # type: ignore
+            markers=self.markers.union(markers),
+        )
 
-    def init(self, *args, **kwds):
-        pass
+    def is_positional(self) -> bool:
+        return (
+            # Explicit positionals.
+            _markers.POSITIONAL in self.markers
+            # Dummy dataclasses should have a single positional field.
+            or self.name == _strings.dummy_field_name
+        )
 
 
-class PropagatingMissingType(_Singleton):
+class PropagatingMissingType(_singleton.Singleton):
     pass
 
 
-class NonpropagatingMissingType(_Singleton):
+class NonpropagatingMissingType(_singleton.Singleton):
     pass
 
 
-class ExcludeFromCallType(_Singleton):
+class ExcludeFromCallType(_singleton.Singleton):
     pass
 
 
@@ -118,6 +154,10 @@ def field_list_from_callable(
 
     if isinstance(out, UnsupportedNestedTypeMessage):
         raise _instantiators.UnsupportedTypeAnnotationError(out.message)
+
+    # Recursively apply markers.
+    _, parent_markers = _resolver.unwrap_annotated(f, _markers.Marker)
+    out = list(map(lambda field: field.add_markers(parent_markers), out))
     return out
 
 
@@ -145,6 +185,12 @@ def _try_field_list_from_callable(
     f: Union[Callable, Type],
     default_instance: _DefaultInstance,
 ) -> Union[List[FieldDefinition], UnsupportedNestedTypeMessage]:
+    f, found_subcommand_configs = _resolver.unwrap_annotated(
+        f, conf._subcommands._SubcommandConfiguration
+    )
+    if len(found_subcommand_configs) > 0:
+        default_instance = found_subcommand_configs[0].default
+
     # Unwrap generics.
     f, type_from_typevar = _resolver.resolve_generic_types(f)
     f = _resolver.narrow_type(f, default_instance)
@@ -156,8 +202,8 @@ def _try_field_list_from_callable(
     if isinstance(f, type):
         cls = f
         f = cls.__init__  # type: ignore
-        f_origin: Callable = cls
-    f_origin = _resolver.unwrap_origin(f)
+        f_origin: Callable = cls  # type: ignore
+    f_origin = _resolver.unwrap_origin_strip_extras(f)
 
     # Try special cases.
     if cls is not None and is_typeddict(cls):
@@ -191,6 +237,7 @@ def _try_field_list_from_callable(
                     " default to infer from."
                 )
             assert isinstance(default_instance, Iterable)
+            contained_type = next(iter(default_instance))
         else:
             (contained_type,) = get_args(f)
         f_origin = list if f_origin is typing.Sequence else f_origin  # type: ignore
@@ -215,9 +262,9 @@ def _try_field_list_from_callable(
             return container_fields
 
     # General cases.
-    if (cls is not None and cls in _known_parsable_types) or _resolver.unwrap_origin(
-        f
-    ) in _known_parsable_types:
+    if (
+        cls is not None and cls in _known_parsable_types
+    ) or _resolver.unwrap_origin_strip_extras(f) in _known_parsable_types:
         return UnsupportedNestedTypeMessage(f"{f} should be parsed directly!")
     else:
         return _try_field_list_from_general_callable(f, cls, default_instance)
@@ -232,7 +279,7 @@ def _try_field_list_from_typeddict(
         and default_instance is not EXCLUDE_FROM_CALL
     )
     assert not valid_default_instance or isinstance(default_instance, dict)
-    for name, typ in get_type_hints(cls).items():
+    for name, typ in get_type_hints(cls, include_extras=True).items():
         if valid_default_instance:
             default = default_instance.get(name, MISSING_PROP)  # type: ignore
         elif getattr(cls, "__total__") is False:
@@ -245,12 +292,11 @@ def _try_field_list_from_typeddict(
             default = MISSING_PROP
 
         field_list.append(
-            FieldDefinition(
+            FieldDefinition.make(
                 name=name,
                 typ=typ,
                 default=default,
                 helptext=_docstrings.get_field_docstring(cls, name),
-                positional=False,
             )
         )
     return field_list
@@ -268,7 +314,7 @@ def _try_field_list_from_namedtuple(
     field_defaults = getattr(cls, "_field_defaults")
 
     # Note that _field_types is removed in Python 3.9.
-    for name, typ in _resolver.get_type_hints(cls).items():
+    for name, typ in get_type_hints(cls, include_extras=True).items():
         # Get default, with priority for `default_instance`.
         default = field_defaults.get(name, MISSING_NONPROP)
         if hasattr(default_instance, name):
@@ -277,12 +323,11 @@ def _try_field_list_from_namedtuple(
             default = MISSING_PROP
 
         field_list.append(
-            FieldDefinition(
+            FieldDefinition.make(
                 name=name,
                 typ=typ,
                 default=default,
                 helptext=_docstrings.get_field_docstring(cls, name),
-                positional=False,
             )
         )
     return field_list
@@ -296,14 +341,11 @@ def _try_field_list_from_dataclass(
     for dc_field in filter(lambda field: field.init, _resolver.resolved_fields(cls)):
         default = _get_dataclass_field_default(dc_field, default_instance)
         field_list.append(
-            FieldDefinition(
+            FieldDefinition.make(
                 name=dc_field.name,
                 typ=dc_field.type,
                 default=default,
                 helptext=_docstrings.get_field_docstring(cls, dc_field.name),
-                # Only mark positional if using a dummy field, for taking single types
-                # directly as input.
-                positional=dc_field.name == _strings.dummy_field_name,
             )
         )
     return field_list
@@ -342,7 +384,7 @@ def _field_list_from_tuple(
     for i, child in enumerate(children):
         default_i = default_instance[i]  # type: ignore
         field_list.append(
-            FieldDefinition(
+            FieldDefinition.make(
                 # Ideally we'd have --tuple[0] instead of --tuple.0 as the command-line
                 # argument, but in practice the brackets are annoying because they
                 # require escaping.
@@ -350,10 +392,9 @@ def _field_list_from_tuple(
                 typ=child,
                 default=default_i,
                 helptext="",
-                # This should really be positional=True, but the CLI is more
-                # intuitive for mixed nested/non-nested types in tuples when we
-                # stick with kwargs. Tuples are special-cased in _calling.py.
-                positional=False,
+                # This should really set the positional marker, but the CLI is more
+                # intuitive for mixed nested/non-nested types in tuples when we stick
+                # with kwargs. Tuples are special-cased in _calling.py.
             )
         )
 
@@ -404,12 +445,11 @@ def _try_field_list_from_sequence(
     field_list = []
     for i, default_i in enumerate(default_instance):  # type: ignore
         field_list.append(
-            FieldDefinition(
+            FieldDefinition.make(
                 name=str(i),
                 typ=contained_type,
                 default=default_i,
                 helptext="",
-                positional=False,
             )
         )
     return field_list
@@ -426,12 +466,11 @@ def _try_field_list_from_dict(
     field_list = []
     for k, v in cast(dict, default_instance).items():
         field_list.append(
-            FieldDefinition(
+            FieldDefinition.make(
                 name=str(k) if not isinstance(k, enum.Enum) else k.name,
                 typ=type(v),
                 default=v,
                 helptext=None,
-                positional=False,
                 # Dictionary specific key:
                 name_override=k,
             )
@@ -483,7 +522,7 @@ def _field_list_from_params(
 
     # This will throw a type error for torch.device, typing.Dict, etc.
     try:
-        hints = get_type_hints(f)
+        hints = get_type_hints(f, include_extras=True)
     except TypeError:
         return UnsupportedNestedTypeMessage(f"Could not get hints for {f}!")
 
@@ -510,13 +549,15 @@ def _field_list_from_params(
             return out
 
         field_list.append(
-            FieldDefinition(
+            FieldDefinition.make(
                 name=param.name,
                 # Note that param.annotation does not resolve forward references.
                 typ=hints[param.name],
                 default=default,
                 helptext=helptext,
-                positional=param.kind is inspect.Parameter.POSITIONAL_ONLY,
+                markers=(_markers.POSITIONAL,)
+                if param.kind is inspect.Parameter.POSITIONAL_ONLY
+                else (),
             )
         )
 
diff --git a/dcargs/_instantiators.py b/dcargs/_instantiators.py
index 4a0fd20b..d7ff78c4 100644
--- a/dcargs/_instantiators.py
+++ b/dcargs/_instantiators.py
@@ -22,13 +22,11 @@
     Tuple[int, float]
 
         lambda strings: tuple(
-            [
-                typ(x)
-                for typ, x in zip(
-                    (int, float),
-                    strings,
-                )
-            ]
+            typ(x)
+            for typ, x in zip(
+                (int, float),
+                strings,
+            )
         )
 ```
 """
diff --git a/dcargs/_parsers.py b/dcargs/_parsers.py
index 815bd187..31e3ca0e 100644
--- a/dcargs/_parsers.py
+++ b/dcargs/_parsers.py
@@ -18,6 +18,7 @@
     _resolver,
     _strings,
 )
+from .conf import _markers, _subcommands
 
 T = TypeVar("T")
 
@@ -26,6 +27,7 @@
 class ParserSpecification:
     """Each parser contains a list of arguments and optionally some subparsers."""
 
+    f: Callable
     description: str
     args: List[_arguments.ArgumentDefinition]
     helptext_from_nested_class_field_name: Dict[str, Optional[str]]
@@ -42,7 +44,6 @@ def from_callable(
             T, _fields.PropagatingMissingType, _fields.NonpropagatingMissingType
         ],
         prefix: str,
-        avoid_subparsers: bool,
     ) -> ParserSpecification:
         """Create a parser definition from a callable."""
 
@@ -78,76 +79,88 @@ def from_callable(
         for field in field_list:
             field = dataclasses.replace(
                 field,
+                # Resolve generic types.
                 typ=_resolver.type_from_typevar_constraints(
-                    type_from_typevar.get(  # type: ignore
-                        field.typ,
+                    _resolver.apply_type_from_typevar(
                         field.typ,
+                        type_from_typevar,
                     )
                 ),
             )
+
             if isinstance(field.typ, TypeVar):
                 raise _instantiators.UnsupportedTypeAnnotationError(
                     f"Field {field.name} has an unbound TypeVar: {field.typ}."
                 )
 
-            # (1) Handle Unions over callables; these result in subparsers.
-            subparsers_attempt = SubparsersSpecification.from_field(
-                field,
-                type_from_typevar=type_from_typevar,
-                parent_classes=parent_classes,
-                prefix=_strings.make_field_name([prefix, field.name]),
-                avoid_subparsers=avoid_subparsers,
-            )
-            if subparsers_attempt is not None:
-                if (
-                    not avoid_subparsers
-                    # Required subparsers => must create a subparser.
-                    or subparsers_attempt.required
-                ):
-                    subparsers_from_name[
-                        _strings.make_field_name([prefix, subparsers_attempt.name])
-                    ] = subparsers_attempt
-                    continue
-                else:
-                    field = dataclasses.replace(field, typ=type(field.default))
-
-            # (2) Handle nested callables.
-            if _fields.is_nested_type(field.typ, field.default):
-                field = dataclasses.replace(
-                    field, typ=_resolver.narrow_type(field.typ, field.default)
-                )
-                nested_parser = ParserSpecification.from_callable(
-                    field.typ,
-                    description=None,
+            if _markers.FIXED not in field.markers:
+                # (1) Handle Unions over callables; these result in subparsers.
+                subparsers_attempt = SubparsersSpecification.from_field(
+                    field,
+                    type_from_typevar=type_from_typevar,
                     parent_classes=parent_classes,
-                    parent_type_from_typevar=type_from_typevar,
-                    default_instance=field.default,
                     prefix=_strings.make_field_name([prefix, field.name]),
-                    avoid_subparsers=avoid_subparsers,
                 )
-                args.extend(nested_parser.args)
-
-                # Include nested subparsers.
-                subparsers_from_name.update(nested_parser.subparsers_from_name)
-
-                # Include nested strings.
-                for k, v in nested_parser.helptext_from_nested_class_field_name.items():
-                    helptext_from_nested_class_field_name[
-                        _strings.make_field_name([field.name, k])
-                    ] = v
-
-                if field.helptext is not None:
-                    helptext_from_nested_class_field_name[field.name] = field.helptext
-                else:
-                    helptext_from_nested_class_field_name[
-                        field.name
-                    ] = _docstrings.get_callable_description(field.typ)
-                continue
+                if subparsers_attempt is not None:
+                    if (
+                        not subparsers_attempt.required
+                        and _markers.AVOID_SUBCOMMANDS in field.markers
+                    ):
+                        # Don't make a subparser.
+                        field = dataclasses.replace(field, typ=type(field.default))
+                    else:
+                        subparsers_from_name[
+                            _strings.make_field_name([prefix, subparsers_attempt.name])
+                        ] = subparsers_attempt
+                        continue
+
+                # (2) Handle nested callables.
+                if _fields.is_nested_type(field.typ, field.default):
+                    field = dataclasses.replace(
+                        field,
+                        typ=_resolver.narrow_type(
+                            field.typ,
+                            field.default,
+                        ),
+                    )
+                    nested_parser = ParserSpecification.from_callable(
+                        field.typ,
+                        description=None,
+                        parent_classes=parent_classes,
+                        parent_type_from_typevar=type_from_typevar,
+                        default_instance=field.default,
+                        prefix=_strings.make_field_name([prefix, field.name]),
+                    )
+                    args.extend(nested_parser.args)
+
+                    # Include nested subparsers.
+                    subparsers_from_name.update(nested_parser.subparsers_from_name)
+
+                    # Include nested strings.
+                    for (
+                        k,
+                        v,
+                    ) in nested_parser.helptext_from_nested_class_field_name.items():
+                        helptext_from_nested_class_field_name[
+                            _strings.make_field_name([field.name, k])
+                        ] = v
+
+                    if field.helptext is not None:
+                        helptext_from_nested_class_field_name[
+                            field.name
+                        ] = field.helptext
+                    else:
+                        helptext_from_nested_class_field_name[
+                            field.name
+                        ] = _docstrings.get_callable_description(field.typ)
+                    continue
 
-            # (3) Handle primitive types. These produce a single argument!
+            # (3) Handle primitive or fixed types. These produce a single argument!
             args.append(
                 _arguments.ArgumentDefinition(
-                    prefix=prefix, field=field, type_from_typevar=type_from_typevar
+                    prefix=prefix,
+                    field=field,
+                    type_from_typevar=type_from_typevar,
                 )
             )
 
@@ -161,6 +174,7 @@ def from_callable(
             )
 
         return ParserSpecification(
+            f=f,
             description=description
             if description is not None
             else _docstrings.get_callable_description(f),
@@ -195,7 +209,7 @@ def format_group_name(nested_field_name: str) -> str:
 
         # Add each argument.
         for arg in self.args:
-            if arg.field.positional:
+            if arg.field.is_positional():
                 arg.add_argument(positional_group)
                 continue
 
@@ -235,14 +249,17 @@ def from_field(
         type_from_typevar: Dict[TypeVar, Type],
         parent_classes: Set[Type],
         prefix: str,
-        avoid_subparsers: bool,
     ) -> Optional[SubparsersSpecification]:
         # Union of classes should create subparsers.
-        if get_origin(field.typ) is not Union:
+        typ = _resolver.unwrap_annotated(field.typ)[0]
+        if get_origin(typ) is not Union:
             return None
 
         # We don't use sets here to retain order of subcommands.
-        options = [type_from_typevar.get(typ, typ) for typ in get_args(field.typ)]
+        options = [
+            _resolver.apply_type_from_typevar(typ, type_from_typevar)
+            for typ in get_args(typ)
+        ]
         options_no_none = [o for o in options if o != type(None)]  # noqa
         if not all(
             [
@@ -256,16 +273,32 @@ def from_field(
         parser_from_name: Dict[str, ParserSpecification] = {}
         for option in options_no_none:
             name = _strings.subparser_name_from_type(prefix, option)
+            option, found_subcommand_configs = _resolver.unwrap_annotated(
+                option, _subcommands._SubcommandConfiguration
+            )
+            if len(found_subcommand_configs) == 0:
+                # Make a dummy subcommand config.
+                found_subcommand_configs = (
+                    _subcommands._SubcommandConfiguration(
+                        "unused",
+                        description=None,
+                        default=(
+                            field.default
+                            if type(field.default)
+                            is _resolver.unwrap_origin_strip_extras(option)
+                            else _fields.MISSING_NONPROP
+                        ),
+                        prefix_name=True,
+                    ),
+                )
+
             subparser = ParserSpecification.from_callable(
                 option,
-                description=None,
+                description=found_subcommand_configs[0].description,
                 parent_classes=parent_classes,
                 parent_type_from_typevar=type_from_typevar,
-                default_instance=field.default
-                if type(field.default) == _resolver.unwrap_origin(option)  # type: ignore
-                else _fields.MISSING_NONPROP,
+                default_instance=found_subcommand_configs[0].default,
                 prefix=prefix,
-                avoid_subparsers=avoid_subparsers,
             )
 
             # Apply prefix to helptext in nested classes in subparsers.
@@ -285,6 +318,7 @@ def from_field(
 
         # If there are any required arguments in the default subparser, we should mark
         # the subparser group as a whole as required.
+        default_name = None
         if (
             field.default is not None
             and field.default not in _fields.MISSING_SINGLETONS
@@ -296,9 +330,28 @@ def from_field(
                     "Default values for generic subparsers are not supported."
                 )
 
-            default_parser = parser_from_name[
-                _strings.subparser_name_from_type(prefix, type(field.default))
-            ]
+            default_name = _strings.subparser_name_from_type(
+                prefix, type(field.default)
+            )
+            if default_name not in parser_from_name:
+                # If we can't find the subparser by name, search by type. This is needed
+                # when the user renames their subcommands. (eg via dcargs.subcommand)
+                #
+                # TODO: this will display some weird behaviors if multiple subcommands
+                # have the same type.
+                default_name = None
+
+                for name, parser in parser_from_name.items():
+                    if type(field.default) is _resolver.unwrap_origin_strip_extras(
+                        parser.f
+                    ):
+                        default_name = name
+                        break
+                assert default_name is not None, (
+                    f"Default with type {type(field.default)} was passed in, but no"
+                    " matching subparser."
+                )
+            default_parser = parser_from_name[default_name]
             if any(map(lambda arg: arg.lowered.required, default_parser.args)):
                 required = True
             if any(
@@ -314,10 +367,7 @@ def from_field(
         if field.helptext is not None:
             description_parts.append(field.helptext)
         if not required and field.default not in _fields.MISSING_SINGLETONS:
-            default = field.default
-            if default is not None:
-                default = _strings.subparser_name_from_type(prefix, type(default))
-            description_parts.append(f" (default: {default})")
+            description_parts.append(f" (default: {default_name})")
         description = (
             # We use `None` instead of an empty string to prevent a line break from
             # being created where the description would be.
@@ -378,6 +428,7 @@ def apply(
                 subparser = argparse_subparsers.add_parser(
                     name=_strings.subparser_name_from_type(self.prefix, None),
                     formatter_class=_argparse_formatter.make_formatter_class(0),
+                    help="",
                 )
                 subparser_tree_nodes.append(subparser)
 
@@ -387,6 +438,7 @@ def apply(
                     formatter_class=_argparse_formatter.make_formatter_class(
                         len(subparser_def.args)
                     ),
+                    help=subparser_def.description,
                 )
                 subparser_def.apply(subparser)
 
diff --git a/dcargs/_resolver.py b/dcargs/_resolver.py
index f6598cf9..8848eade 100644
--- a/dcargs/_resolver.py
+++ b/dcargs/_resolver.py
@@ -1,26 +1,40 @@
 """Utilities for resolving types and forward references."""
 
+import collections.abc
 import copy
 import dataclasses
-from typing import Any, Callable, Dict, List, Tuple, Type, TypeVar, Union, cast
-
-from typing_extensions import get_args, get_origin, get_type_hints
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+    cast,
+)
+
+from typing_extensions import Annotated, get_args, get_origin, get_type_hints
 
 TypeOrCallable = TypeVar("TypeOrCallable", Type, Callable)
 
 
-def unwrap_origin(tp: TypeOrCallable) -> TypeOrCallable:
-    """Returns the origin of tp if it exists. Otherwise, returns tp."""
-    origin = get_origin(tp)
+def unwrap_origin_strip_extras(typ: TypeOrCallable) -> TypeOrCallable:
+    """Returns the origin, ignoring typing.Annotated, of typ if it exists. Otherwise, returns typ."""
+    # TODO: Annotated[] handling should be revisited...
+    typ, _ = unwrap_annotated(typ)
+    origin = get_origin(typ)
     if origin is None:
-        return tp
+        return typ
     else:
         return origin
 
 
 def is_dataclass(cls: Union[Type, Callable]) -> bool:
     """Same as `dataclasses.is_dataclass`, but also handles generic aliases."""
-    return dataclasses.is_dataclass(unwrap_origin(cls))
+    return dataclasses.is_dataclass(unwrap_origin_strip_extras(cls))
 
 
 def resolve_generic_types(
@@ -31,25 +45,25 @@ def resolve_generic_types(
 
     origin_cls = get_origin(cls)
 
-    type_from_typevars = {}
+    type_from_typevar = {}
     if origin_cls is not None and hasattr(origin_cls, "__parameters__"):
         typevars = origin_cls.__parameters__
         typevar_values = get_args(cls)
         assert len(typevars) == len(typevar_values)
         cls = origin_cls
-        type_from_typevars.update(dict(zip(typevars, typevar_values)))
+        type_from_typevar.update(dict(zip(typevars, typevar_values)))
 
     if hasattr(cls, "__orig_bases__"):
         bases = getattr(cls, "__orig_bases__")
         for base in bases:
-            origin_base = unwrap_origin(base)
+            origin_base = unwrap_origin_strip_extras(base)
             if origin_base is base or not hasattr(origin_base, "__parameters__"):
                 continue
             typevars = origin_base.__parameters__
             typevar_values = get_args(base)
-            type_from_typevars.update(dict(zip(typevars, typevar_values)))
+            type_from_typevar.update(dict(zip(typevars, typevar_values)))
 
-    return cls, type_from_typevars
+    return cls, type_from_typevar
 
 
 def resolved_fields(cls: Type) -> List[dataclasses.Field]:
@@ -57,7 +71,7 @@ def resolved_fields(cls: Type) -> List[dataclasses.Field]:
 
     assert dataclasses.is_dataclass(cls)
     fields = []
-    annotations = get_type_hints(cls)
+    annotations = get_type_hints(cls, include_extras=True)
     for field in dataclasses.fields(cls):
         # Avoid mutating original field.
         field = copy.copy(field)
@@ -99,12 +113,64 @@ def type_from_typevar_constraints(typ: Union[Type, TypeVar]) -> Union[Type, Type
 
 def narrow_type(typ: TypeT, default_instance: Any) -> TypeT:
     """Type narrowing: if we annotate as Animal but specify a default instance of Cat, we
-    should parse as Cat."""
+    should parse as Cat.
+
+    Note that Union types are intentionally excluded here."""
     try:
         potential_subclass = type(default_instance)
-        superclass = typ
+        superclass = unwrap_annotated(typ)[0]
         if superclass is Any or issubclass(potential_subclass, superclass):  # type: ignore
+            if get_origin(typ) is Annotated:
+                return Annotated.__class_getitem__(  # type: ignore
+                    (potential_subclass,) + get_args(typ)[1:]
+                )
             return cast(TypeT, potential_subclass)
     except TypeError:
         pass
     return typ
+
+
+MetadataType = TypeVar("MetadataType")
+
+
+def unwrap_annotated(
+    typ: TypeOrCallable, search_type: Optional[Type[MetadataType]] = None
+) -> Tuple[TypeOrCallable, Tuple[MetadataType, ...]]:
+    """Helper for parsing typing.Annotated types.
+
+    Examples:
+    - int, int => (int, ())
+    - Annotated[int, 1], int => (int, 1)
+    - Annotated[int, "1"], int => (int, None)
+    """
+    if not hasattr(typ, "__metadata__"):
+        return typ, ()
+
+    args = get_args(typ)
+    assert len(args) >= 2
+
+    # Don't search for a specific metadata type if `None` is passed in.
+    if search_type is None:
+        return args[0], ()
+
+    # Look through metadata for desired metadata type.
+    targets = tuple(x for x in args[1:] if isinstance(x, search_type))
+    return args[0], targets
+
+
+def apply_type_from_typevar(
+    typ: TypeOrCallable, type_from_typevar: Dict[TypeVar, Type]
+) -> TypeOrCallable:
+    if typ in type_from_typevar:
+        return type_from_typevar[typ]  # type: ignore
+
+    if len(get_args(typ)) > 0:
+        args = get_args(typ)
+        if get_origin(typ) is Annotated:
+            args = args[:1]
+        if get_origin(typ) is collections.abc.Callable:
+            assert isinstance(args[0], list)
+            args = tuple(args[0]) + args[1:]
+        return typ.copy_with(tuple(apply_type_from_typevar(x, type_from_typevar) for x in args))  # type: ignore
+
+    return typ
diff --git a/dcargs/_singleton.py b/dcargs/_singleton.py
new file mode 100644
index 00000000..f52efefd
--- /dev/null
+++ b/dcargs/_singleton.py
@@ -0,0 +1,13 @@
+class Singleton:
+    # Singleton pattern.
+    # https://www.python.org/download/releases/2.2/descrintro/#__new__
+    def __new__(cls, *args, **kwds):
+        it = cls.__dict__.get("__it__")
+        if it is not None:
+            return it
+        cls.__it__ = it = object.__new__(cls)
+        it.init(*args, **kwds)
+        return it
+
+    def init(self, *args, **kwds):
+        pass
diff --git a/dcargs/_strings.py b/dcargs/_strings.py
index 7ac05c46..13e58fac 100644
--- a/dcargs/_strings.py
+++ b/dcargs/_strings.py
@@ -3,7 +3,7 @@
 import functools
 import re
 import textwrap
-from typing import Iterable, List, Sequence, Type, Union
+from typing import Iterable, List, Sequence, Tuple, Type, Union
 
 import termcolor
 
@@ -53,25 +53,47 @@ def hyphen_separated_from_camel_case(name: str) -> str:
     return _camel_separator_pattern().sub(r"-\1", name).lower()
 
 
-def _subparser_name_from_type(cls: Type) -> str:
+def _subparser_name_from_type(cls: Type) -> Tuple[str, bool]:
+    from .conf import _subcommands  # Prevent circular imports
+
     cls, type_from_typevar = _resolver.resolve_generic_types(cls)
+    cls, found_subcommand_configs = _resolver.unwrap_annotated(
+        cls, _subcommands._SubcommandConfiguration
+    )
+
+    # Subparser name from `dcargs.metadata.subcommand()`.
+    found_name = None
+    prefix_name = True
+    if len(found_subcommand_configs) > 0:
+        found_name = found_subcommand_configs[0].name
+        prefix_name = found_subcommand_configs[0].prefix_name
+
+    if found_name is not None:
+        return found_name, prefix_name
+
+    # Subparser name from class name.
     if len(type_from_typevar) == 0:
         assert hasattr(cls, "__name__")
-        return hyphen_separated_from_camel_case(cls.__name__)  # type: ignore
-
-    return "-".join(
-        map(
-            _subparser_name_from_type,
-            [cls] + list(type_from_typevar.values()),
-        )
+        return hyphen_separated_from_camel_case(cls.__name__), prefix_name  # type: ignore
+
+    return (
+        "-".join(
+            map(
+                lambda x: _subparser_name_from_type(x)[0],
+                [cls] + list(type_from_typevar.values()),
+            )
+        ),
+        prefix_name,
     )
 
 
 def subparser_name_from_type(prefix: str, cls: Union[Type, None]) -> str:
-    suffix = _subparser_name_from_type(cls) if cls is not None else "None"
-    if len(prefix) == 0:
+    suffix, use_prefix = (
+        _subparser_name_from_type(cls) if cls is not None else ("None", True)
+    )
+    if len(prefix) == 0 or not use_prefix:
         return suffix
-    return f"{prefix}:{suffix}"
+    return f"{prefix}:{suffix}".replace("_", "-")
 
 
 @functools.lru_cache(maxsize=None)
diff --git a/dcargs/conf/__init__.py b/dcargs/conf/__init__.py
new file mode 100644
index 00000000..e1a1743e
--- /dev/null
+++ b/dcargs/conf/__init__.py
@@ -0,0 +1,17 @@
+"""The :mod:`dcargs.conf` submodule contains helpers for attaching parsing-specific
+configuration metadata to types via [PEP 593](https://peps.python.org/pep-0593/) runtime
+annotations.
+
+Features here are supported, but generally unnecessary and should be used sparingly.
+"""
+
+from ._markers import AvoidSubcommands, Fixed, FlagConversionOff, Positional
+from ._subcommands import subcommand
+
+__all__ = [
+    "AvoidSubcommands",
+    "Fixed",
+    "FlagConversionOff",
+    "Positional",
+    "subcommand",
+]
diff --git a/dcargs/conf/_markers.py b/dcargs/conf/_markers.py
new file mode 100644
index 00000000..6aa83f9c
--- /dev/null
+++ b/dcargs/conf/_markers.py
@@ -0,0 +1,51 @@
+from typing import Type, TypeVar
+
+from typing_extensions import Annotated
+
+from .. import _singleton
+
+
+class Marker(_singleton.Singleton):
+    pass
+
+
+def _make_marker(description: str) -> Marker:
+    class _InnerMarker(Marker):
+        def __repr__(self):
+            return description
+
+    return _InnerMarker()
+
+
+# Current design issue: markers are applied recursively to nested structures, but can't
+# be unapplied.
+
+T = TypeVar("T", bound=Type)
+
+POSITIONAL = _make_marker("Positional")
+Positional = Annotated[T, POSITIONAL]
+"""A type `T` can be annotated as `Positional[T]` if we want to parse it as a positional
+argument."""
+
+
+FIXED = _make_marker("Fixed")
+Fixed = Annotated[T, FIXED]
+"""A type `T` can be annotated as `Fixed[T]` to prevent `dcargs.cli` from parsing it; a
+default value should be set instead. Note that fields with defaults that can't be parsed
+will also be marked as fixed automatically."""
+
+FLAG_CONVERSION_OFF = _make_marker("FlagConversionOff")
+FlagConversionOff = Annotated[T, FLAG_CONVERSION_OFF]
+"""Turn off flag conversion for booleans with default values. Instead, types annotated
+with `bool` will expect an explicit True or False.
+
+Can be used directly on boolean annotations, `FlagConversionOff[bool]`, or recursively
+applied to nested types."""
+
+AVOID_SUBCOMMANDS = _make_marker("AvoidSubcommands")
+AvoidSubcommands = Annotated[T, AVOID_SUBCOMMANDS]
+"""Avoid creating subcommands when a default is provided for unions over nested types.
+This simplifies CLI interfaces, but makes them less expressive.
+
+Can be used directly on union types, `AvoidSubcommands[Union[...]]`, or recursively
+applied to nested types."""
diff --git a/dcargs/conf/_subcommands.py b/dcargs/conf/_subcommands.py
new file mode 100644
index 00000000..92f84b03
--- /dev/null
+++ b/dcargs/conf/_subcommands.py
@@ -0,0 +1,55 @@
+import dataclasses
+from typing import Any, Optional
+
+from .._fields import MISSING_NONPROP
+
+
+@dataclasses.dataclass(frozen=True)
+class _SubcommandConfiguration:
+    name: Optional[str]
+    default: Any
+    description: Optional[str]
+    prefix_name: bool
+
+    def __hash__(self) -> int:
+        return object.__hash__(self)
+
+
+def subcommand(
+    name: Optional[str] = None,
+    *,
+    default: Any = MISSING_NONPROP,
+    description: Optional[str] = None,
+    prefix_name: bool = True,
+) -> Any:
+    """Returns a metadata object for configuring subcommands with `typing.Annotated`.
+    This is useful but can make code harder to read, so usage is discouraged.
+
+    Consider the standard approach for creating subcommands:
+
+    ```python
+    dcargs.cli(
+        Union[NestedTypeA, NestedTypeB]
+    )
+    ```
+
+    This will create two subcommands: `nested-type-a` and `nested-type-b`.
+
+    Annotating each type with `dcargs.metadata.subcommand()` allows us to override for
+    each subcommand the (a) name, (b) defaults, (c) helptext, and (d) whether to prefix
+    the name or not.
+
+    ```python
+    dcargs.cli(
+        Union[
+            Annotated[
+                NestedTypeA, subcommand("a", ...)
+            ],
+            Annotated[
+                NestedTypeA, subcommand("b", ...)
+            ],
+        ]
+    )
+    ```
+    """
+    return _SubcommandConfiguration(name, default, description, prefix_name)
diff --git a/dcargs/extras/__init__.py b/dcargs/extras/__init__.py
index 71ab7651..9671a957 100644
--- a/dcargs/extras/__init__.py
+++ b/dcargs/extras/__init__.py
@@ -1,3 +1,7 @@
+"""The :mod:`dcargs.extras` submodule contains helpers that complement :func:`dcargs.cli()`, but
+aren't considered part of the core interface."""
+
+from ._base_configs import subcommand_union_from_mapping
 from ._serialization import from_yaml, to_yaml
 
-__all__ = ["to_yaml", "from_yaml"]
+__all__ = ["subcommand_union_from_mapping", "to_yaml", "from_yaml"]
diff --git a/dcargs/extras/_base_configs.py b/dcargs/extras/_base_configs.py
new file mode 100644
index 00000000..1159df5d
--- /dev/null
+++ b/dcargs/extras/_base_configs.py
@@ -0,0 +1,77 @@
+from typing import Mapping, Type, TypeVar, Union
+
+from typing_extensions import Annotated
+
+from ..conf import subcommand
+
+T = TypeVar("T")
+
+
+def subcommand_union_from_mapping(
+    default_from_name: Mapping[str, T], descriptions: Mapping[str, str] = {}
+) -> Type[T]:
+    """Returns a Union type for defining subcommands that choose between nested types.
+
+    For example, when `default` is set to:
+
+    ```python
+    {
+        "small": Config(...),
+        "big": Config(...),
+    }
+    ```
+
+    We return:
+
+    ```python
+    Union[
+        Annotated[
+            Config,
+            dcargs.conf.subcommand("small", default=Config(...))
+        ],
+        Annotated[
+            Config,
+            dcargs.conf.subcommand("big", default=Config(...))
+        ]
+    ]
+    ```
+
+    This can be used directly in dcargs.cli:
+
+    ```python
+    config = dcargs.cli(subcommand_union_from_mapping(default_from_name))
+    reveal_type(config)  # Should be correct!
+    ```
+
+    Or to generate annotations for classes and functions:
+
+    ```python
+    SelectableConfig = subcommand_union_from_mapping(default_from_name)
+
+    def train(
+        config: SelectableConfig,
+        checkpoint_path: Optional[pathlib.Path] = None,
+    ) -> None:
+        ...
+
+    dcargs.cli(train)
+    ```
+
+    Note that Pyright understands the latter case, but mypy does not. If mypy support is
+    necessary we can work around this with an `if TYPE_CHECKING` guard:
+
+    ```python
+    if TYPE_CHECKING:
+        SelectableConfig = ExperimentConfig
+    else:
+        SelectableConfig = subcommand_union_from_mapping(base_mapping)
+    ```
+    """
+    return Union.__getitem__(  # type: ignore
+        tuple(
+            Annotated.__class_getitem__(  # type: ignore
+                (type(v), subcommand(k, default=v, description=descriptions.get(k)))
+            )
+            for k, v in default_from_name.items()
+        )
+    )
diff --git a/dcargs/extras/_serialization.py b/dcargs/extras/_serialization.py
index c5c433a4..9d23f31c 100644
--- a/dcargs/extras/_serialization.py
+++ b/dcargs/extras/_serialization.py
@@ -17,20 +17,6 @@
 DataclassType = TypeVar("DataclassType")
 
 
-def _get_contained_special_types_from_instance(instance: Any) -> Set[Type]:
-    """Takes an object and recursively searches its cihldren for dataclass or enum
-    types."""
-    if issubclass(type(instance), enum.Enum):
-        return {type(instance)}
-    elif not dataclasses.is_dataclass(instance):
-        return set()
-
-    out = {type(instance)}
-    for v in vars(instance).values():
-        out |= _get_contained_special_types_from_instance(v)
-    return out
-
-
 def _get_contained_special_types_from_type(
     cls: Type,
     _parent_contained_dataclasses: Optional[Set[Type]] = None,
@@ -44,17 +30,17 @@ def _get_contained_special_types_from_type(
         else _parent_contained_dataclasses
     )
 
+    cls, _ = _resolver.unwrap_annotated(cls)
     cls, type_from_typevar = _resolver.resolve_generic_types(cls)
 
-    contained_dataclasses = {cls}
+    contained_special_types = {cls}
 
-    def handle_type(typ) -> Set[Type]:
-        print(typ)
+    def handle_type(typ: Type) -> Set[Type]:
         # Handle dataclasses.
         if _resolver.is_dataclass(typ) and typ not in parent_contained_dataclasses:
             return _get_contained_special_types_from_type(
                 typ,
-                _parent_contained_dataclasses=contained_dataclasses
+                _parent_contained_dataclasses=contained_special_types
                 | parent_contained_dataclasses,
             )
 
@@ -67,16 +53,20 @@ def handle_type(typ) -> Set[Type]:
 
     # Handle generics.
     for typ in type_from_typevar.values():
-        contained_dataclasses |= handle_type(typ)
+        contained_special_types |= handle_type(typ)
 
     if cls in parent_contained_dataclasses:
-        return contained_dataclasses
+        return contained_special_types
 
     # Handle fields.
     for field in _resolver.resolved_fields(cls):  # type: ignore
-        contained_dataclasses |= handle_type(field.type)
+        contained_special_types |= handle_type(field.type)
 
-    return contained_dataclasses
+    # Handle subclasses.
+    for subclass in cls.__subclasses__():
+        contained_special_types |= handle_type(subclass)
+
+    return contained_special_types
 
 
 def _make_loader(cls: Type) -> Type[yaml.Loader]:
@@ -134,7 +124,7 @@ class DataclassDumper(yaml.Dumper):
         def ignore_aliases(self, data):
             return super().ignore_aliases(data) or data is _fields.MISSING_PROP
 
-    contained_types = list(_get_contained_special_types_from_instance(instance))
+    contained_types = list(_get_contained_special_types_from_type(type(instance)))
     contained_type_names = list(map(lambda cls: cls.__name__, contained_types))
 
     # Note: this is currently a stricter than necessary assert.
@@ -183,7 +173,19 @@ def from_yaml(
     stream: Union[str, IO[str], bytes, IO[bytes]],
 ) -> DataclassType:
     """Re-construct a dataclass instance from a yaml-compatible string, which should be
-    generated from `dcargs.extra.to_yaml()`.
+    generated from `dcargs.extras.to_yaml()`.
+
+    As a secondary feature aimed at enabling the use of :func:`dcargs.cli` for general
+    configuration use cases, we also introduce functions for human-readable dataclass
+    serialization: :func:`dcargs.conf.from_yaml` and :func:`dcargs.conf.to_yaml` attempt
+    to strike a balance between flexibility and robustness — in contrast to naively
+    dumping or loading dataclass instances (via pickle, PyYAML, etc), explicit type
+    references enable custom tags that are robust against code reorganization and
+    refactor, while a PyYAML backend enables serialization of arbitrary Python objects.
+
+    .. warning::
+        Serialization functionality is stable but deprecated. It may be removed in a
+        future version of :code:`dcargs`.
 
     Args:
         cls: Type to reconstruct.
@@ -202,6 +204,18 @@ def to_yaml(instance: Any) -> str:
     """Serialize a dataclass; returns a yaml-compatible string that can be deserialized
     via `dcargs.extras.from_yaml()`.
 
+    As a secondary feature aimed at enabling the use of :func:`dcargs.cli` for general
+    configuration use cases, we also introduce functions for human-readable dataclass
+    serialization: :func:`dcargs.conf.from_yaml` and :func:`dcargs.conf.to_yaml` attempt
+    to strike a balance between flexibility and robustness — in contrast to naively
+    dumping or loading dataclass instances (via pickle, PyYAML, etc), explicit type
+    references enable custom tags that are robust against code reorganization and
+    refactor, while a PyYAML backend enables serialization of arbitrary Python objects.
+
+    .. warning::
+        Serialization functionality is stable but deprecated. It may be removed in a
+        future version of :code:`dcargs`.
+
     Args:
         instance: Dataclass instance to serialize.
 
diff --git a/docs/source/examples/04_flags.rst b/docs/source/examples/04_flags.rst
index ceece83d..52de2c13 100644
--- a/docs/source/examples/04_flags.rst
+++ b/docs/source/examples/04_flags.rst
@@ -8,6 +8,8 @@
 Booleans can either be expected to be explicitly passed in, or, if given a default
 value, automatically converted to flags.
 
+To turn off conversion, see :func:`dcargs.conf.FlagConversionOff`.
+
 
 
 .. code-block:: python
diff --git a/docs/source/examples/06_base_configs.rst b/docs/source/examples/06_base_configs.rst
deleted file mode 100644
index f28c0041..00000000
--- a/docs/source/examples/06_base_configs.rst
+++ /dev/null
@@ -1,165 +0,0 @@
-.. Comment: this file is automatically generated by `update_example_docs.py`.
-   It should not be modified manually.
-
-6. Base Configs
-==========================================
-
-
-We can integrate ``dcargs.cli()`` into common configuration patterns: here, we select
-one of multiple possible base configurations, and then use the CLI to either override
-(existing) or fill in (missing) values.
-
-Note that our interfaces don't prescribe any of the mechanics used for storing or
-choosing between base configurations. A Hydra-style YAML approach could just as easily
-be used for the config libary (although we generally prefer to avoid YAMLs; staying in
-Python is convenient for autocompletion and type checking). For selection, we could also
-avoid fussing with ``sys.argv`` by using a ``BASE_CONFIG`` environment variable.
-
-
-
-.. code-block:: python
-       :linenos:
-
-       import sys
-       from dataclasses import dataclass
-       from typing import Callable, Dict, Literal, Tuple, TypeVar, Union
-       
-       from torch import nn
-       
-       import dcargs
-       
-       
-       @dataclass(frozen=True)
-       class AdamOptimizer:
-           learning_rate: float = 1e-3
-           betas: Tuple[float, float] = (0.9, 0.999)
-       
-       
-       @dataclass(frozen=True)
-       class SgdOptimizer:
-           learning_rate: float = 3e-4
-       
-       
-       @dataclass(frozen=True)
-       class ExperimentConfig:
-           # Dataset to run experiment on.
-           dataset: Literal["mnist", "imagenet-50"]
-       
-           # Optimizer parameters.
-           optimizer: Union[AdamOptimizer, SgdOptimizer]
-       
-           # Model size.
-           num_layers: int
-           units: int
-       
-           # Batch size.
-           batch_size: int
-       
-           # Total number of training steps.
-           train_steps: int
-       
-           # Random seed. This is helpful for making sure that our experiments are all
-           # reproducible!
-           seed: int
-       
-           # Activation to use. Not specifiable via the commandline.
-           activation: Callable[[], nn.Module]
-       
-       
-       # Note that we could also define this library using separate YAML files (similar to
-       # `config_path`/`config_name` in Hydra), but staying in Python enables seamless type
-       # checking + IDE support.
-       base_configs = {
-           "small": ExperimentConfig(
-               dataset="mnist",
-               optimizer=SgdOptimizer(),
-               batch_size=2048,
-               num_layers=4,
-               units=64,
-               train_steps=30_000,
-               # The dcargs.MISSING sentinel allows us to specify that the seed should have no
-               # default, and needs to be populated from the CLI.
-               seed=dcargs.MISSING,
-               activation=nn.ReLU,
-           ),
-           "big": ExperimentConfig(
-               dataset="imagenet-50",
-               optimizer=AdamOptimizer(),
-               batch_size=32,
-               num_layers=8,
-               units=256,
-               train_steps=100_000,
-               seed=dcargs.MISSING,
-               activation=nn.GELU,
-           ),
-       }
-       
-       
-       T = TypeVar("T")
-       
-       
-       def cli_from_base_configs(base_library: Dict[str, T]) -> T:
-           """Populate an instance of `cls`, where the first positional argument is used to
-           select from a library of named base configs."""
-           # Get base configuration name from the first positional argument.
-           if len(sys.argv) < 2 or sys.argv[1] not in base_library:
-               valid_usages = map(lambda k: f"{sys.argv[0]} {k} --help", base_library.keys())
-               raise SystemExit("usage:\n  " + "\n  ".join(valid_usages))
-       
-           # Get base configuration from our library, and use it for default CLI parameters.
-           default_instance = base_library[sys.argv[1]]
-           return dcargs.cli(
-               type(default_instance),
-               prog=" ".join(sys.argv[:2]),
-               args=sys.argv[2:],
-               default_instance=default_instance,
-               # `avoid_subparsers` will avoid making a subparser for unions when a default is
-               # provided; in this case, it simplifies our CLI but makes it less expressive
-               # (cannot switch away from the base optimizer types).
-               avoid_subparsers=True,
-           )
-       
-       
-       if __name__ == "__main__":
-           config = cli_from_base_configs(base_configs)
-           print(config)
-
-------------
-
-.. raw:: html
-
-        <kbd>python 06_base_configs.py</kbd>
-
-.. program-output:: python ../../examples/06_base_configs.py
-
-------------
-
-.. raw:: html
-
-        <kbd>python 06_base_configs.py small --help</kbd>
-
-.. program-output:: python ../../examples/06_base_configs.py small --help
-
-------------
-
-.. raw:: html
-
-        <kbd>python 06_base_configs.py small --seed 94720</kbd>
-
-.. program-output:: python ../../examples/06_base_configs.py small --seed 94720
-
-------------
-
-.. raw:: html
-
-        <kbd>python 06_base_configs.py big --help</kbd>
-
-.. program-output:: python ../../examples/06_base_configs.py big --help
-
-------------
-
-.. raw:: html
-
-        <kbd>python 06_base_configs.py big --seed 94720</kbd>
-
-.. program-output:: python ../../examples/06_base_configs.py big --seed 94720
diff --git a/docs/source/examples/07_literals_and_unions.rst b/docs/source/examples/06_literals_and_unions.rst
similarity index 92%
rename from docs/source/examples/07_literals_and_unions.rst
rename to docs/source/examples/06_literals_and_unions.rst
index 6f43fa83..f5bbf58c 100644
--- a/docs/source/examples/07_literals_and_unions.rst
+++ b/docs/source/examples/06_literals_and_unions.rst
@@ -1,7 +1,7 @@
 .. Comment: this file is automatically generated by `update_example_docs.py`.
    It should not be modified manually.
 
-7. Literals And Unions
+6. Literals And Unions
 ==========================================
 
 
@@ -60,6 +60,6 @@
 
 .. raw:: html
 
-        <kbd>python 07_literals_and_unions.py --help</kbd>
+        <kbd>python 06_literals_and_unions.py --help</kbd>
 
-.. program-output:: python ../../examples/07_literals_and_unions.py --help
+.. program-output:: python ../../examples/06_literals_and_unions.py --help
diff --git a/docs/source/examples/08_positional_args.rst b/docs/source/examples/07_positional_args.rst
similarity index 86%
rename from docs/source/examples/08_positional_args.rst
rename to docs/source/examples/07_positional_args.rst
index 6dd19374..0e508d82 100644
--- a/docs/source/examples/08_positional_args.rst
+++ b/docs/source/examples/07_positional_args.rst
@@ -1,12 +1,14 @@
 .. Comment: this file is automatically generated by `update_example_docs.py`.
    It should not be modified manually.
 
-8. Positional Args
+7. Positional Args
 ==========================================
 
 
 Positional-only arguments in functions are converted to positional CLI arguments.
 
+For more general positional arguments, see :func:`dcargs.conf.Positional`.
+
 
 
 .. code-block:: python
@@ -69,14 +71,14 @@ Positional-only arguments in functions are converted to positional CLI arguments
 
 .. raw:: html
 
-        <kbd>python 08_positional_args.py --help</kbd>
+        <kbd>python 07_positional_args.py --help</kbd>
 
-.. program-output:: python ../../examples/08_positional_args.py --help
+.. program-output:: python ../../examples/07_positional_args.py --help
 
 ------------
 
 .. raw:: html
 
-        <kbd>python 08_positional_args.py ./a ./b --optimizer.learning-rate 1e-5</kbd>
+        <kbd>python 07_positional_args.py ./a ./b --optimizer.learning-rate 1e-5</kbd>
 
-.. program-output:: python ../../examples/08_positional_args.py ./a ./b --optimizer.learning-rate 1e-5
+.. program-output:: python ../../examples/07_positional_args.py ./a ./b --optimizer.learning-rate 1e-5
diff --git a/docs/source/examples/09_subparsers.rst b/docs/source/examples/08_subcommands.rst
similarity index 58%
rename from docs/source/examples/09_subparsers.rst
rename to docs/source/examples/08_subcommands.rst
index f41c03a4..0ce749ef 100644
--- a/docs/source/examples/09_subparsers.rst
+++ b/docs/source/examples/08_subcommands.rst
@@ -1,11 +1,14 @@
 .. Comment: this file is automatically generated by `update_example_docs.py`.
    It should not be modified manually.
 
-9. Subparsers
+8. Subcommands
 ==========================================
 
 
-Unions over nested types (classes or dataclasses) are populated using subparsers.
+Unions over nested types (classes or dataclasses) are populated using subcommands.
+
+For configuring subcommands beyond what can be expressed with type annotations, see
+:func:`dcargs.conf.subcommand()`.
 
 
 
@@ -49,38 +52,38 @@ Unions over nested types (classes or dataclasses) are populated using subparsers
 
 .. raw:: html
 
-        <kbd>python 09_subparsers.py --help</kbd>
+        <kbd>python 08_subcommands.py --help</kbd>
 
-.. program-output:: python ../../examples/09_subparsers.py --help
+.. program-output:: python ../../examples/08_subcommands.py --help
 
 ------------
 
 .. raw:: html
 
-        <kbd>python 09_subparsers.py cmd:commit --help</kbd>
+        <kbd>python 08_subcommands.py cmd:commit --help</kbd>
 
-.. program-output:: python ../../examples/09_subparsers.py cmd:commit --help
+.. program-output:: python ../../examples/08_subcommands.py cmd:commit --help
 
 ------------
 
 .. raw:: html
 
-        <kbd>python 09_subparsers.py cmd:commit --cmd.message hello --cmd.all</kbd>
+        <kbd>python 08_subcommands.py cmd:commit --cmd.message hello --cmd.all</kbd>
 
-.. program-output:: python ../../examples/09_subparsers.py cmd:commit --cmd.message hello --cmd.all
+.. program-output:: python ../../examples/08_subcommands.py cmd:commit --cmd.message hello --cmd.all
 
 ------------
 
 .. raw:: html
 
-        <kbd>python 09_subparsers.py cmd:checkout --help</kbd>
+        <kbd>python 08_subcommands.py cmd:checkout --help</kbd>
 
-.. program-output:: python ../../examples/09_subparsers.py cmd:checkout --help
+.. program-output:: python ../../examples/08_subcommands.py cmd:checkout --help
 
 ------------
 
 .. raw:: html
 
-        <kbd>python 09_subparsers.py cmd:checkout --cmd.branch main</kbd>
+        <kbd>python 08_subcommands.py cmd:checkout --cmd.branch main</kbd>
 
-.. program-output:: python ../../examples/09_subparsers.py cmd:checkout --cmd.branch main
+.. program-output:: python ../../examples/08_subcommands.py cmd:checkout --cmd.branch main
diff --git a/docs/source/examples/10_multiple_subparsers.rst b/docs/source/examples/09_multiple_subcommands.rst
similarity index 72%
rename from docs/source/examples/10_multiple_subparsers.rst
rename to docs/source/examples/09_multiple_subcommands.rst
index ee2fd733..bf44d935 100644
--- a/docs/source/examples/10_multiple_subparsers.rst
+++ b/docs/source/examples/09_multiple_subcommands.rst
@@ -1,11 +1,11 @@
 .. Comment: this file is automatically generated by `update_example_docs.py`.
    It should not be modified manually.
 
-10. Multiple Subparsers
+9. Multiple Subcommands
 ==========================================
 
 
-Multiple unions over nested types are populated using a series of subparsers.
+Multiple unions over nested types are populated using a series of subcommands.
 
 
 
@@ -75,30 +75,30 @@ Multiple unions over nested types are populated using a series of subparsers.
 
 .. raw:: html
 
-        <kbd>python 10_multiple_subparsers.py</kbd>
+        <kbd>python 09_multiple_subcommands.py</kbd>
 
-.. program-output:: python ../../examples/10_multiple_subparsers.py
+.. program-output:: python ../../examples/09_multiple_subcommands.py
 
 ------------
 
 .. raw:: html
 
-        <kbd>python 10_multiple_subparsers.py --help</kbd>
+        <kbd>python 09_multiple_subcommands.py --help</kbd>
 
-.. program-output:: python ../../examples/10_multiple_subparsers.py --help
+.. program-output:: python ../../examples/09_multiple_subcommands.py --help
 
 ------------
 
 .. raw:: html
 
-        <kbd>python 10_multiple_subparsers.py dataset:mnist --help</kbd>
+        <kbd>python 09_multiple_subcommands.py dataset:mnist --help</kbd>
 
-.. program-output:: python ../../examples/10_multiple_subparsers.py dataset:mnist --help
+.. program-output:: python ../../examples/09_multiple_subcommands.py dataset:mnist --help
 
 ------------
 
 .. raw:: html
 
-        <kbd>python 10_multiple_subparsers.py dataset:mnist optimizer:adam --optimizer.learning-rate 3e-4</kbd>
+        <kbd>python 09_multiple_subcommands.py dataset:mnist optimizer:adam --optimizer.learning-rate 3e-4</kbd>
 
-.. program-output:: python ../../examples/10_multiple_subparsers.py dataset:mnist optimizer:adam --optimizer.learning-rate 3e-4
+.. program-output:: python ../../examples/09_multiple_subcommands.py dataset:mnist optimizer:adam --optimizer.learning-rate 3e-4
diff --git a/docs/source/examples/10_base_configs.rst b/docs/source/examples/10_base_configs.rst
new file mode 100644
index 00000000..5e1a3b70
--- /dev/null
+++ b/docs/source/examples/10_base_configs.rst
@@ -0,0 +1,168 @@
+.. Comment: this file is automatically generated by `update_example_docs.py`.
+   It should not be modified manually.
+
+10. Base Configs
+==========================================
+
+
+We can integrate ``dcargs.cli()`` into common configuration patterns: here, we select
+one of multiple possible base configurations, create a subcommand for each one, and then
+use the CLI to either override (existing) or fill in (missing) values.
+
+Note that our interfaces don't prescribe any of the mechanics used for storing
+base configurations. A Hydra-style YAML approach could just as easily
+be used for the config libary (although we generally prefer to avoid YAMLs; staying in
+Python is convenient for autocompletion and type checking). For selection, we could also
+avoid fussing with ``sys.argv`` by using a ``BASE_CONFIG`` environment variable.
+
+
+
+.. code-block:: python
+       :linenos:
+
+       from dataclasses import dataclass
+       from typing import Callable, Literal, Tuple, Union
+       
+       from torch import nn
+       
+       import dcargs
+       
+       
+       @dataclass(frozen=True)
+       class AdamOptimizer:
+           learning_rate: float = 1e-3
+           betas: Tuple[float, float] = (0.9, 0.999)
+       
+       
+       @dataclass(frozen=True)
+       class SgdOptimizer:
+           learning_rate: float = 3e-4
+       
+       
+       @dataclass(frozen=True)
+       class ExperimentConfig:
+           # Dataset to run experiment on.
+           dataset: Literal["mnist", "imagenet-50"]
+       
+           # Optimizer parameters.
+           optimizer: Union[AdamOptimizer, SgdOptimizer]
+       
+           # Model size.
+           num_layers: int
+           units: int
+       
+           # Batch size.
+           batch_size: int
+       
+           # Total number of training steps.
+           train_steps: int
+       
+           # Random seed. This is helpful for making sure that our experiments are all
+           # reproducible!
+           seed: int
+       
+           # Activation to use. Not specifiable via the commandline.
+           activation: Callable[[], nn.Module]
+       
+       
+       # Note that we could also define this library using separate YAML files (similar to
+       # `config_path`/`config_name` in Hydra), but staying in Python enables seamless type
+       # checking + IDE support.
+       descriptions = {}
+       base_configs = {}
+       
+       descriptions["small"] = "Train a smaller model."
+       base_configs["small"] = ExperimentConfig(
+           dataset="mnist",
+           optimizer=SgdOptimizer(),
+           batch_size=2048,
+           num_layers=4,
+           units=64,
+           train_steps=30_000,
+           # The dcargs.MISSING sentinel allows us to specify that the seed should have no
+           # default, and needs to be populated from the CLI.
+           seed=dcargs.MISSING,
+           activation=nn.ReLU,
+       )
+       
+       
+       descriptions["big"] = "Train a bigger model."
+       base_configs["big"] = ExperimentConfig(
+           dataset="imagenet-50",
+           optimizer=AdamOptimizer(),
+           batch_size=32,
+           num_layers=8,
+           units=256,
+           train_steps=100_000,
+           seed=dcargs.MISSING,
+           activation=nn.GELU,
+       )
+       
+       
+       if __name__ == "__main__":
+           config = dcargs.cli(
+               dcargs.extras.subcommand_union_from_mapping(base_configs, descriptions),
+           )
+           # Note that this is equivalent to:
+           #
+           # config = dcargs.cli(
+           #     Union[
+           #         Annotated[
+           #             ExperimentConfig,
+           #             dcargs.conf.subcommand(
+           #                 "small",
+           #                 default=base_configs["small"],
+           #                 description=descriptions["small"],
+           #             ),
+           #         ],
+           #         Annotated[
+           #             ExperimentConfig,
+           #             dcargs.conf.subcommand(
+           #                 "big",
+           #                 default=base_configs["big"],
+           #                 description=descriptions["big"],
+           #             ),
+           #         ],
+           #     ]
+           # )
+           print(config)
+
+------------
+
+.. raw:: html
+
+        <kbd>python 10_base_configs.py --help</kbd>
+
+.. program-output:: python ../../examples/10_base_configs.py --help
+
+------------
+
+.. raw:: html
+
+        <kbd>python 10_base_configs.py small --help</kbd>
+
+.. program-output:: python ../../examples/10_base_configs.py small --help
+
+------------
+
+.. raw:: html
+
+        <kbd>python 10_base_configs.py small --seed 94720</kbd>
+
+.. program-output:: python ../../examples/10_base_configs.py small --seed 94720
+
+------------
+
+.. raw:: html
+
+        <kbd>python 10_base_configs.py big --help</kbd>
+
+.. program-output:: python ../../examples/10_base_configs.py big --help
+
+------------
+
+.. raw:: html
+
+        <kbd>python 10_base_configs.py big --seed 94720</kbd>
+
+.. program-output:: python ../../examples/10_base_configs.py big --seed 94720
diff --git a/docs/source/examples/16_advanced_configuration.rst b/docs/source/examples/16_advanced_configuration.rst
new file mode 100644
index 00000000..12c27e8c
--- /dev/null
+++ b/docs/source/examples/16_advanced_configuration.rst
@@ -0,0 +1,77 @@
+.. Comment: this file is automatically generated by `update_example_docs.py`.
+   It should not be modified manually.
+
+16. Advanced Configuration
+==========================================
+
+
+The :mod:`dcargs.conf` module contains utilities that can be used to configure
+command-line interfaces beyond what is expressible via static type annotations.
+
+Features here are supported, but generally unnecessary and should be used sparingly.
+
+
+
+.. code-block:: python
+       :linenos:
+
+       import dataclasses
+       from typing import Union
+       
+       from typing_extensions import Annotated
+       
+       import dcargs
+       
+       
+       @dataclasses.dataclass(frozen=True)
+       class CheckoutArgs:
+           """Checkout a branch."""
+       
+           branch: str
+       
+       
+       @dataclasses.dataclass(frozen=True)
+       class CommitArgs:
+           """Commit changes."""
+       
+           message: str
+           all: bool = False
+       
+       
+       @dataclasses.dataclass
+       class Args:
+           # A boolean field with flag conversion turned off.
+           boolean: dcargs.conf.FlagConversionOff[bool] = False
+       
+           # A numeric field parsed as a positional argument.
+           positional: dcargs.conf.Positional[int] = 3
+       
+           # A numeric field that can't be changed via the CLI.
+           fixed: dcargs.conf.Fixed[int] = 5
+       
+           # A union over nested structures, but without subcommand generation. When a default
+           # is provided, the type is simply fixed to that default.
+           union_without_subcommand: dcargs.conf.AvoidSubcommands[
+               Union[CheckoutArgs, CommitArgs]
+           ] = CheckoutArgs("main")
+       
+           # `dcargs.conf.subcommand()` can be used to configure subcommands in a Union. Here,
+           # we make the subcommand names more succinct.
+           renamed_subcommand: Union[
+               Annotated[
+                   CheckoutArgs, dcargs.conf.subcommand(name="checkout", prefix_name=False)
+               ],
+               Annotated[CommitArgs, dcargs.conf.subcommand(name="commit", prefix_name=False)],
+           ] = CheckoutArgs("main")
+       
+       
+       if __name__ == "__main__":
+           print(dcargs.cli(Args))
+
+------------
+
+.. raw:: html
+
+        <kbd>python 16_advanced_configuration.py --help</kbd>
+
+.. program-output:: python ../../examples/16_advanced_configuration.py --help
diff --git a/docs/source/goals_and_alternatives.md b/docs/source/goals_and_alternatives.md
index 445903b9..e1667ac8 100644
--- a/docs/source/goals_and_alternatives.md
+++ b/docs/source/goals_and_alternatives.md
@@ -6,18 +6,17 @@ The core functionality of `dcargs` — generating argument parsers from type
 annotations — overlaps significantly with features offered by other libraries.
 Usage distinctions are the result of two API goals:
 
-- **One uninvasive function.** Whenever possible, learning to use `dcargs`
-  should reduce to learning to write (type-annotated) Python. For example, types
-  are specified using standard annotations, helptext using docstrings, choices
-  using the standard `typing.Literal` type, subparsers with `typing.Union` of
-  nested types, and positional arguments with `/`.
-  - In contrast, similar libraries have more expansive APIs (sometimes spanning
-    dozens of specialized class and functions), and often require
+- **One uninvasive function.** For all core functionality, learning to use
+  `dcargs` should reduce to learning to write (type-annotated) Python. For
+  example, types are specified using standard annotations, helptext using
+  docstrings, choices using the standard `typing.Literal` type, subcommands with
+  `typing.Union` of nested types, and positional arguments with `/`.
+  - In contrast, similar libraries have more expansive APIs , and require more
     library-specific structures, decorators, or metadata formats for configuring
     parsing behavior.
 - **Strict typing.** Any type that can be annotated and unambiguously parsed
   with an `argparse`-style CLI interface should work out-of-the-box; any public
-  API that isn't statically analyzable shouldn't be implemented.
+  API that isn't statically analyzable should be avoided.
   - In contrast, many similar libraries implement features that depend on
     dynamic argparse-style namespaces, or string-based accessors that can't be
     statically checked.
@@ -56,10 +55,10 @@ More concretely, we can also compare specific features. A noncomprehensive set:
 [omegaconf]: https://omegaconf.readthedocs.io/en/2.1_branch/structured_config.html
 
 [^datargs_unions_nested]: One allowed per class.
-[^tap_unions_nested]: Not supported, but API exists for creating subparsers that accomplish a similar goal.
+[^tap_unions_nested]: Not supported, but API exists for creating subcommands that accomplish a similar goal.
 [^simp_unions_nested]: One allowed per class.
 [^yahp_unions_nested]: Not supported, but similar functionality available via ["registries"](https://docs.mosaicml.com/projects/yahp/en/stable/examples/registry.html).
-[^typer_unions_nested]: Not supported, but API exists for creating subparsers that accomplish a similar goal.
+[^typer_unions_nested]: Not supported, but API exists for creating subcommands that accomplish a similar goal.
 [^simp_literals]: Not supported for mixed (eg `Literal[5, "five"]`) or in container (eg `List[Literal[1, 2]]`) types.
 [^datargs_literals]: Not supported for mixed types (eg `Literal[5, "five"]`).
 [^typer_containers]: `typer` uses positional arguments for all required fields, which means that only one variable-length argument (such as `List[int]`) without a default is supported per argument parser.
diff --git a/docs/source/helptext_generation.md b/docs/source/helptext_generation.md
new file mode 100644
index 00000000..f341e59d
--- /dev/null
+++ b/docs/source/helptext_generation.md
@@ -0,0 +1,76 @@
+# Helptext generation
+
+In addition to type annotations, :func:`dcargs.cli()` will also parse docstrings
+and comments. These are used to automatically generate helptext; see examples
+for how these end up being formatted.
+
+## General callables
+
+For general callables, field helptext is extracted from the corresponding field
+docstring. Our examples use Google-style docstrings, but ReST, Numpydoc-style
+and Epydoc docstrings are supported as well. Under the hood, all of these
+options use [docstring_parser](https://github.com/rr-/docstring_parser).
+
+```python
+def main(
+    field1: str,
+    field2: int = 3,
+) -> None:
+    """Function, whose arguments will be populated from a CLI interface.
+
+    Args:
+        field1: A string field.
+        field2: A numeric field, with a default value.
+    """
+    print(field1, field2)
+```
+
+## Dataclasses, TypedDict, NamedTuple
+
+For types defined using class attributes, enumerating each argument list in the
+class docstring can be cumbersome.
+
+If they are unavailable, :func:`dcargs.cli` will generate helptext from
+docstrings and comments on attributes. These are parsed via source code
+inspection.
+
+**(1) Attribute docstrings**
+
+As per [PEP 257](https://peps.python.org/pep-0257/#what-is-a-docstring).
+
+```python
+@dataclasses.dataclass
+class Args:
+    field1: str
+    """A string field."""
+    field2: int = 3
+    """A numeric field, with a default value."""
+```
+
+**(2) Inline comments**
+
+Inline comments can be more succinct than true attribute docstrings.
+
+```python
+@dataclasses.dataclass
+class Args:
+    field1: str  # A string field.
+    field2: int = 3  # A numeric field, with a default value.
+```
+
+**(3) Preceding comments**
+
+These can also be handy for semantically grouping fields, such as the two string
+fields below.
+
+```python
+@dataclasses.dataclass
+class Args:
+    # String fields.
+    field1: str
+    field2: str
+
+    # An integer field.
+    # Multi-line comments are supported.
+    field3: int
+```
diff --git a/docs/source/index.rst b/docs/source/index.rst
index afa9ad10..0ab9f442 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -105,8 +105,9 @@ The broader goal is also a replacement for tools like :code:`hydra`,
    :hidden:
    :glob:
 
+   helptext_generation
+   tab_completion
    goals_and_alternatives
-   serialization
 
 
 .. toctree::
diff --git a/docs/source/serialization.rst b/docs/source/serialization.rst
deleted file mode 100644
index 6c47af1e..00000000
--- a/docs/source/serialization.rst
+++ /dev/null
@@ -1,22 +0,0 @@
-Serialization
-====================================
-
-As a secondary feature aimed at enabling the use of :func:`dcargs.cli` for general
-configuration use cases, we also introduce functions for human-readable
-dataclass serialization:
-
-- :func:`dcargs.extras.to_yaml` and :func:`dcargs.extras.from_yaml` convert
-  between YAML-style strings and dataclass instances.
-
-The functions attempt to strike a balance between flexibility and robustness —
-in contrast to naively dumping or loading dataclass instances (via pickle,
-PyYAML, etc), explicit type references enable custom tags that are robust
-against code reorganization and refactor, while a PyYAML backend enables
-serialization of arbitrary Python objects.
-
-Note that we generally prefer to use YAML purely for serialization, as opposed
-to a configuration interface that humans are expected to manually write or
-modify. Specifying things like loadable base configurations can be done directly
-in Python, which enables all of the usual autocompletion and type checking
-features.
-
diff --git a/docs/source/tab_completion.md b/docs/source/tab_completion.md
new file mode 100644
index 00000000..559e693d
--- /dev/null
+++ b/docs/source/tab_completion.md
@@ -0,0 +1,79 @@
+# Tab completion
+
+Interfaces built with :func:`dcargs.cli()` can be tab completed in interactive
+shells without any source code modification.
+
+Completion scripts can be generated by passing the
+`--dcargs-print-completion {bash/zsh/tcsh}` flag to a dcargs CLI. This generates
+a completion script via [shtab](https://docs.iterative.ai/shtab/) and prints it
+to stdout. To set up tab completion, the printed script simply needs to be
+written somewhere where your shell will find it.
+
+## Zsh
+
+For zsh, one option is to emulate the pattern used for completions in
+[poetry](https://python-poetry.org/docs):
+
+```bash
+# Set up zsh autocompletion for 01_functions.py, which is located in
+# dcargs/examples.
+
+# (1) Make directory for local completions.
+mkdir -p ~/.zfunc
+
+# (2) Write completion script. The name here (_01_functions_py) doesn't matter,
+# as long as it's prefixed with an underscore.
+python 01_functions.py --dcargs-print-completion zsh > ~/.zfunc/_01_functions_py
+```
+
+And if it's not in your `.zshrc` already:
+
+```bash
+# (3) Add .zfunc to our function search path, then initialize completions.
+# Ideally this should go in your .zshrc!
+fpath+=~/.zfunc
+autoload -Uz compinit && compinit
+```
+
+## Bash
+
+Local completion scripts for bash can be written as described in the
+[bash-complete documentation](https://github.com/scop/bash-completion/blob/master/README.md#FAQ).
+
+> **Q.** Where should I install my own local completions?
+>
+> **A.** Put them in the completions subdir of `$BASH_COMPLETION_USER_DIR`
+> (defaults to `$XDG_DATA_HOME/bash-completion` or
+> `~/.local/share/bash-completion` if `$XDG_DATA_HOME` is not set) to have them
+> loaded automatically on demand when the respective command is being completed
+> [...]
+
+Borrowing from the `bash-completion` source[^bash_completion], we can run:
+
+<!-- prettier-ignore-start -->
+
+[^bash_completion]: `completion_dir` query is borrowed from [here](https://github.com/scop/bash-completion/blob/966a4e043822613040546e8c003509798c5fae1a/bash_completion#L2440).
+
+<!-- prettier-ignore-end -->
+
+```bash
+# Set up bash autocompletion for 01_functions.py, which is located in
+# dcargs/examples.
+
+# (1) Find and make completion directory.
+completion_dir=${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion}/completions/
+mkdir -p $completion_dir
+
+# (2) Write completion scripts. Note that the name of the completion script must
+# match the name of the file.
+python 01_functions.py --dcargs-print-completion bash > ${completion_dir}/01_functions.py
+```
+
+In contrast to zsh, tab completion in bash requires that scripts are either set
+up as an entry point or run as :code:`./01_functions.py <TAB>`, as opposed to
+with the `python` command, :code:`python 01_functions.py <TAB>`.
+
+Making a script directly executable typically requires:
+
+1. A permissions update: `chmod +x ./01_functions.py`.
+2. A shebang as the first line of your script: `#!/usr/bin/env python`
diff --git a/examples/01_functions.py b/examples/01_functions.py
old mode 100644
new mode 100755
diff --git a/examples/04_flags.py b/examples/04_flags.py
index 233e5fb4..635acea2 100644
--- a/examples/04_flags.py
+++ b/examples/04_flags.py
@@ -1,6 +1,8 @@
 """Booleans can either be expected to be explicitly passed in, or, if given a default
 value, automatically converted to flags.
 
+To turn off conversion, see :func:`dcargs.conf.FlagConversionOff`.
+
 Usage:
 `python ./04_flags.py --help`
 `python ./04_flags.py --boolean True`
diff --git a/examples/06_base_configs.py b/examples/06_base_configs.py
deleted file mode 100644
index 0e721b5f..00000000
--- a/examples/06_base_configs.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""We can integrate `dcargs.cli()` into common configuration patterns: here, we select
-one of multiple possible base configurations, and then use the CLI to either override
-(existing) or fill in (missing) values.
-
-Note that our interfaces don't prescribe any of the mechanics used for storing or
-choosing between base configurations. A Hydra-style YAML approach could just as easily
-be used for the config libary (although we generally prefer to avoid YAMLs; staying in
-Python is convenient for autocompletion and type checking). For selection, we could also
-avoid fussing with `sys.argv` by using a `BASE_CONFIG` environment variable.
-
-Usage:
-`python ./06_base_configs.py`
-`python ./06_base_configs.py small --help`
-`python ./06_base_configs.py small --seed 94720`
-`python ./06_base_configs.py big --help`
-`python ./06_base_configs.py big --seed 94720`
-"""
-
-import sys
-from dataclasses import dataclass
-from typing import Callable, Dict, Literal, Tuple, TypeVar, Union
-
-from torch import nn
-
-import dcargs
-
-
-@dataclass(frozen=True)
-class AdamOptimizer:
-    learning_rate: float = 1e-3
-    betas: Tuple[float, float] = (0.9, 0.999)
-
-
-@dataclass(frozen=True)
-class SgdOptimizer:
-    learning_rate: float = 3e-4
-
-
-@dataclass(frozen=True)
-class ExperimentConfig:
-    # Dataset to run experiment on.
-    dataset: Literal["mnist", "imagenet-50"]
-
-    # Optimizer parameters.
-    optimizer: Union[AdamOptimizer, SgdOptimizer]
-
-    # Model size.
-    num_layers: int
-    units: int
-
-    # Batch size.
-    batch_size: int
-
-    # Total number of training steps.
-    train_steps: int
-
-    # Random seed. This is helpful for making sure that our experiments are all
-    # reproducible!
-    seed: int
-
-    # Activation to use. Not specifiable via the commandline.
-    activation: Callable[[], nn.Module]
-
-
-# Note that we could also define this library using separate YAML files (similar to
-# `config_path`/`config_name` in Hydra), but staying in Python enables seamless type
-# checking + IDE support.
-base_configs = {
-    "small": ExperimentConfig(
-        dataset="mnist",
-        optimizer=SgdOptimizer(),
-        batch_size=2048,
-        num_layers=4,
-        units=64,
-        train_steps=30_000,
-        # The dcargs.MISSING sentinel allows us to specify that the seed should have no
-        # default, and needs to be populated from the CLI.
-        seed=dcargs.MISSING,
-        activation=nn.ReLU,
-    ),
-    "big": ExperimentConfig(
-        dataset="imagenet-50",
-        optimizer=AdamOptimizer(),
-        batch_size=32,
-        num_layers=8,
-        units=256,
-        train_steps=100_000,
-        seed=dcargs.MISSING,
-        activation=nn.GELU,
-    ),
-}
-
-
-T = TypeVar("T")
-
-
-def cli_from_base_configs(base_library: Dict[str, T]) -> T:
-    """Populate an instance of `cls`, where the first positional argument is used to
-    select from a library of named base configs."""
-    # Get base configuration name from the first positional argument.
-    if len(sys.argv) < 2 or sys.argv[1] not in base_library:
-        valid_usages = map(lambda k: f"{sys.argv[0]} {k} --help", base_library.keys())
-        raise SystemExit("usage:\n  " + "\n  ".join(valid_usages))
-
-    # Get base configuration from our library, and use it for default CLI parameters.
-    default_instance = base_library[sys.argv[1]]
-    return dcargs.cli(
-        type(default_instance),
-        prog=" ".join(sys.argv[:2]),
-        args=sys.argv[2:],
-        default_instance=default_instance,
-        # `avoid_subparsers` will avoid making a subparser for unions when a default is
-        # provided; in this case, it simplifies our CLI but makes it less expressive
-        # (cannot switch away from the base optimizer types).
-        avoid_subparsers=True,
-    )
-
-
-if __name__ == "__main__":
-    config = cli_from_base_configs(base_configs)
-    print(config)
diff --git a/examples/07_literals_and_unions.py b/examples/06_literals_and_unions.py
similarity index 96%
rename from examples/07_literals_and_unions.py
rename to examples/06_literals_and_unions.py
index b8231ae1..a46fff65 100644
--- a/examples/07_literals_and_unions.py
+++ b/examples/06_literals_and_unions.py
@@ -2,7 +2,7 @@
 `typing.Union[]` can be used to restrict inputs to a fixed set of types.
 
 Usage:
-`python ./07_literals_and_unions.py --help`
+`python ./06_literals_and_unions.py --help`
 """
 
 import dataclasses
diff --git a/examples/08_positional_args.py b/examples/07_positional_args.py
similarity index 88%
rename from examples/08_positional_args.py
rename to examples/07_positional_args.py
index 713d2aab..fc81f712 100644
--- a/examples/08_positional_args.py
+++ b/examples/07_positional_args.py
@@ -1,8 +1,10 @@
 """Positional-only arguments in functions are converted to positional CLI arguments.
 
+For more general positional arguments, see :func:`dcargs.conf.Positional`.
+
 Usage:
-`python ./08_positional_args.py --help`
-`python ./08_positional_args.py ./a ./b --optimizer.learning-rate 1e-5`
+`python ./07_positional_args.py --help`
+`python ./07_positional_args.py ./a ./b --optimizer.learning-rate 1e-5`
 """
 
 from __future__ import annotations
diff --git a/examples/09_subparsers.py b/examples/08_subcommands.py
similarity index 62%
rename from examples/09_subparsers.py
rename to examples/08_subcommands.py
index aa0ec883..7ae6b5db 100644
--- a/examples/09_subparsers.py
+++ b/examples/08_subcommands.py
@@ -1,11 +1,14 @@
-"""Unions over nested types (classes or dataclasses) are populated using subparsers.
+"""Unions over nested types (classes or dataclasses) are populated using subcommands.
+
+For configuring subcommands beyond what can be expressed with type annotations, see
+:func:`dcargs.conf.subcommand()`.
 
 Usage:
-`python ./09_subparsers.py --help`
-`python ./09_subparsers.py cmd:commit --help`
-`python ./09_subparsers.py cmd:commit --cmd.message hello --cmd.all`
-`python ./09_subparsers.py cmd:checkout --help`
-`python ./09_subparsers.py cmd:checkout --cmd.branch main`
+`python ./08_subcommands.py --help`
+`python ./08_subcommands.py cmd:commit --help`
+`python ./08_subcommands.py cmd:commit --cmd.message hello --cmd.all`
+`python ./08_subcommands.py cmd:checkout --help`
+`python ./08_subcommands.py cmd:checkout --cmd.branch main`
 """
 
 from __future__ import annotations
diff --git a/examples/10_multiple_subparsers.py b/examples/09_multiple_subcommands.py
similarity index 80%
rename from examples/10_multiple_subparsers.py
rename to examples/09_multiple_subcommands.py
index fd474660..cede20a1 100644
--- a/examples/10_multiple_subparsers.py
+++ b/examples/09_multiple_subcommands.py
@@ -1,10 +1,10 @@
-"""Multiple unions over nested types are populated using a series of subparsers.
+"""Multiple unions over nested types are populated using a series of subcommands.
 
 Usage:
-`python ./10_multiple_subparsers.py`
-`python ./10_multiple_subparsers.py --help`
-`python ./10_multiple_subparsers.py dataset:mnist --help`
-`python ./10_multiple_subparsers.py dataset:mnist optimizer:adam --optimizer.learning-rate 3e-4`
+`python ./09_multiple_subcommands.py`
+`python ./09_multiple_subcommands.py --help`
+`python ./09_multiple_subcommands.py dataset:mnist --help`
+`python ./09_multiple_subcommands.py dataset:mnist optimizer:adam --optimizer.learning-rate 3e-4`
 """
 from __future__ import annotations
 
diff --git a/examples/10_base_configs.py b/examples/10_base_configs.py
new file mode 100644
index 00000000..e87bf945
--- /dev/null
+++ b/examples/10_base_configs.py
@@ -0,0 +1,124 @@
+"""We can integrate `dcargs.cli()` into common configuration patterns: here, we select
+one of multiple possible base configurations, create a subcommand for each one, and then
+use the CLI to either override (existing) or fill in (missing) values.
+
+Note that our interfaces don't prescribe any of the mechanics used for storing
+base configurations. A Hydra-style YAML approach could just as easily
+be used for the config libary (although we generally prefer to avoid YAMLs; staying in
+Python is convenient for autocompletion and type checking). For selection, we could also
+avoid fussing with `sys.argv` by using a `BASE_CONFIG` environment variable.
+
+Usage:
+`python ./10_base_configs.py --help`
+`python ./10_base_configs.py small --help`
+`python ./10_base_configs.py small --seed 94720`
+`python ./10_base_configs.py big --help`
+`python ./10_base_configs.py big --seed 94720`
+"""
+
+from dataclasses import dataclass
+from typing import Callable, Literal, Tuple, Union
+
+from torch import nn
+
+import dcargs
+
+
+@dataclass(frozen=True)
+class AdamOptimizer:
+    learning_rate: float = 1e-3
+    betas: Tuple[float, float] = (0.9, 0.999)
+
+
+@dataclass(frozen=True)
+class SgdOptimizer:
+    learning_rate: float = 3e-4
+
+
+@dataclass(frozen=True)
+class ExperimentConfig:
+    # Dataset to run experiment on.
+    dataset: Literal["mnist", "imagenet-50"]
+
+    # Optimizer parameters.
+    optimizer: Union[AdamOptimizer, SgdOptimizer]
+
+    # Model size.
+    num_layers: int
+    units: int
+
+    # Batch size.
+    batch_size: int
+
+    # Total number of training steps.
+    train_steps: int
+
+    # Random seed. This is helpful for making sure that our experiments are all
+    # reproducible!
+    seed: int
+
+    # Activation to use. Not specifiable via the commandline.
+    activation: Callable[[], nn.Module]
+
+
+# Note that we could also define this library using separate YAML files (similar to
+# `config_path`/`config_name` in Hydra), but staying in Python enables seamless type
+# checking + IDE support.
+descriptions = {}
+base_configs = {}
+
+descriptions["small"] = "Train a smaller model."
+base_configs["small"] = ExperimentConfig(
+    dataset="mnist",
+    optimizer=SgdOptimizer(),
+    batch_size=2048,
+    num_layers=4,
+    units=64,
+    train_steps=30_000,
+    # The dcargs.MISSING sentinel allows us to specify that the seed should have no
+    # default, and needs to be populated from the CLI.
+    seed=dcargs.MISSING,
+    activation=nn.ReLU,
+)
+
+
+descriptions["big"] = "Train a bigger model."
+base_configs["big"] = ExperimentConfig(
+    dataset="imagenet-50",
+    optimizer=AdamOptimizer(),
+    batch_size=32,
+    num_layers=8,
+    units=256,
+    train_steps=100_000,
+    seed=dcargs.MISSING,
+    activation=nn.GELU,
+)
+
+
+if __name__ == "__main__":
+    config = dcargs.cli(
+        dcargs.extras.subcommand_union_from_mapping(base_configs, descriptions),
+    )
+    # Note that this is equivalent to:
+    #
+    # config = dcargs.cli(
+    #     Union[
+    #         Annotated[
+    #             ExperimentConfig,
+    #             dcargs.conf.subcommand(
+    #                 "small",
+    #                 default=base_configs["small"],
+    #                 description=descriptions["small"],
+    #             ),
+    #         ],
+    #         Annotated[
+    #             ExperimentConfig,
+    #             dcargs.conf.subcommand(
+    #                 "big",
+    #                 default=base_configs["big"],
+    #                 description=descriptions["big"],
+    #             ),
+    #         ],
+    #     ]
+    # )
+    print(config)
diff --git a/examples/16_advanced_configuration.py b/examples/16_advanced_configuration.py
new file mode 100644
index 00000000..8f0c5815
--- /dev/null
+++ b/examples/16_advanced_configuration.py
@@ -0,0 +1,61 @@
+"""The :mod:`dcargs.conf` module contains utilities that can be used to configure
+command-line interfaces beyond what is expressible via static type annotations.
+
+Features here are supported, but generally unnecessary and should be used sparingly.
+
+Usage:
+`python ./16_advanced_configuration.py --help`
+"""
+
+import dataclasses
+from typing import Union
+
+from typing_extensions import Annotated
+
+import dcargs
+
+
+@dataclasses.dataclass(frozen=True)
+class CheckoutArgs:
+    """Checkout a branch."""
+
+    branch: str
+
+
+@dataclasses.dataclass(frozen=True)
+class CommitArgs:
+    """Commit changes."""
+
+    message: str
+    all: bool = False
+
+
+@dataclasses.dataclass
+class Args:
+    # A boolean field with flag conversion turned off.
+    boolean: dcargs.conf.FlagConversionOff[bool] = False
+
+    # A numeric field parsed as a positional argument.
+    positional: dcargs.conf.Positional[int] = 3
+
+    # A numeric field that can't be changed via the CLI.
+    fixed: dcargs.conf.Fixed[int] = 5
+
+    # A union over nested structures, but without subcommand generation. When a default
+    # is provided, the type is simply fixed to that default.
+    union_without_subcommand: dcargs.conf.AvoidSubcommands[
+        Union[CheckoutArgs, CommitArgs]
+    ] = CheckoutArgs("main")
+
+    # `dcargs.conf.subcommand()` can be used to configure subcommands in a Union. Here,
+    # we make the subcommand names more succinct.
+    renamed_subcommand: Union[
+        Annotated[
+            CheckoutArgs, dcargs.conf.subcommand(name="checkout", prefix_name=False)
+        ],
+        Annotated[CommitArgs, dcargs.conf.subcommand(name="commit", prefix_name=False)],
+    ] = CheckoutArgs("main")
+
+
+if __name__ == "__main__":
+    print(dcargs.cli(Args))
diff --git a/poetry.lock b/poetry.lock
index 2ee84552..14a89181 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -23,10 +23,10 @@ optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
-docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
+dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"]
+docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
+tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"]
+tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"]
 
 [[package]]
 name = "backports.cached-property"
@@ -87,9 +87,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
 zipp = ">=0.5"
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
+docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"]
 perf = ["ipython"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
 
 [[package]]
 name = "iniconfig"
@@ -134,6 +134,9 @@ category = "dev"
 optional = false
 python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
 
+[package.dependencies]
+setuptools = "*"
+
 [[package]]
 name = "numpy"
 version = "1.21.1"
@@ -177,8 +180,8 @@ python-versions = ">=3.6"
 importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
 
 [package.extras]
-testing = ["pytest-benchmark", "pytest"]
-dev = ["tox", "pre-commit"]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "py"
@@ -197,7 +200,7 @@ optional = false
 python-versions = ">=3.6.8"
 
 [package.extras]
-diagrams = ["railroad-diagrams", "jinja2"]
+diagrams = ["jinja2", "railroad-diagrams"]
 
 [[package]]
 name = "pyright"
@@ -250,7 +253,7 @@ coverage = {version = ">=5.2.1", extras = ["toml"]}
 pytest = ">=4.6"
 
 [package.extras]
-testing = ["virtualenv", "pytest-xdist", "six", "process-tests", "hunter", "fields"]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
 
 [[package]]
 name = "pyyaml"
@@ -260,6 +263,27 @@ category = "main"
 optional = false
 python-versions = ">=3.6"
 
+[[package]]
+name = "setuptools"
+version = "65.3.0"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
+[[package]]
+name = "shtab"
+version = "1.5.5"
+description = "Automagic shell tab completion for Python CLI applications"
+category = "main"
+optional = false
+python-versions = ">=3.2"
+
 [[package]]
 name = "termcolor"
 version = "1.1.0"
@@ -312,13 +336,13 @@ optional = false
 python-versions = ">=3.7"
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
+docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"]
+testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.7"
-content-hash = "897e78d65e9a00cd292034a23eb35922e934052dbba5b927795e39cf21b3f449"
+content-hash = "880ee7ca4570c69fe1fadfac1b13d81c6719eaf7b45420bbb587985f9e9ef5ed"
 
 [metadata.files]
 antlr4-python3-runtime = [
@@ -552,6 +576,14 @@ pyyaml = [
     {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
     {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
 ]
+setuptools = [
+    {file = "setuptools-65.3.0-py3-none-any.whl", hash = "sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82"},
+    {file = "setuptools-65.3.0.tar.gz", hash = "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57"},
+]
+shtab = [
+    {file = "shtab-1.5.5-py2.py3-none-any.whl", hash = "sha256:f4bf7cc122fb434cb65b96db7e3adcf3b5258846e906e60c48b3725d2965c6b5"},
+    {file = "shtab-1.5.5.tar.gz", hash = "sha256:f90a6ce64b821002d5881b6212992a27ab40c3bab36aabca8de118b0b78f61f6"},
+]
 termcolor = [
     {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"},
 ]
diff --git a/pyproject.toml b/pyproject.toml
index bd329a31..9e382d90 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ termcolor = "^1.1.0"
 "backports.cached-property" = { version = "^1.0.2", python = "~3.7" }
 colorama = {version = "^0.4.0", platform = "win32"}
 frozendict = "^2.3.4"
+shtab = "^1.5.5"
 
 [tool.poetry.dev-dependencies]
 pytest = "^7.1.2"


Test patch:
diff --git a/tests/test_dcargs.py b/tests/test_dcargs.py
index 6c474064..ede75c57 100644
--- a/tests/test_dcargs.py
+++ b/tests/test_dcargs.py
@@ -40,8 +40,6 @@ class ManyTypes:
         f: float
         p: pathlib.Path
 
-    assert isinstance(dcargs.generate_parser(ManyTypes), argparse.ArgumentParser)
-
     # We can directly pass a dataclass to `dcargs.cli()`:
     assert dcargs.cli(
         ManyTypes,
diff --git a/tests/test_dict_namedtuple.py b/tests/test_dict_namedtuple.py
index d1898e18..c129a424 100644
--- a/tests/test_dict_namedtuple.py
+++ b/tests/test_dict_namedtuple.py
@@ -165,7 +165,7 @@ class NestedTypedDict(TypedDict):
         dcargs.cli(NestedTypedDict, args=["--x", "1"])
 
 
-def test_helptext_and_default_instance_typeddict():
+def test_helptext_and_default_typeddict():
     class HelptextTypedDict(TypedDict):
         """This docstring should be printed as a description."""
 
@@ -180,7 +180,7 @@ class HelptextTypedDict(TypedDict):
     f = io.StringIO()
     with pytest.raises(SystemExit):
         with contextlib.redirect_stdout(f):
-            dcargs.cli(HelptextTypedDict, default_instance={"z": 3}, args=["--help"])
+            dcargs.cli(HelptextTypedDict, default={"z": 3}, args=["--help"])
     helptext = dcargs._strings.strip_ansi_sequences(f.getvalue())
     assert cast(str, HelptextTypedDict.__doc__) in helptext
     assert "--x INT" in helptext
@@ -254,7 +254,7 @@ class HelptextNamedTupleDefault(NamedTuple):
     assert "Documentation 3 (default: 3)\n" in helptext
 
 
-def test_helptext_and_default_instance_namedtuple():
+def test_helptext_and_default_namedtuple_alternate():
     class HelptextNamedTuple(NamedTuple):
         """This docstring should be printed as a description."""
 
@@ -269,7 +269,7 @@ class HelptextNamedTuple(NamedTuple):
     with pytest.raises(SystemExit):
         dcargs.cli(
             HelptextNamedTuple,
-            default_instance=dcargs.MISSING,
+            default=dcargs.MISSING,
             args=[],
         )
 
@@ -278,7 +278,7 @@ class HelptextNamedTuple(NamedTuple):
         with contextlib.redirect_stdout(f):
             dcargs.cli(
                 HelptextNamedTuple,
-                default_instance=HelptextNamedTuple(
+                default=HelptextNamedTuple(
                     x=dcargs.MISSING,
                     y=dcargs.MISSING,
                     z=3,
diff --git a/tests/test_generics_and_serialization.py b/tests/test_generics_and_serialization.py
index 43a4d895..4fe59935 100644
--- a/tests/test_generics_and_serialization.py
+++ b/tests/test_generics_and_serialization.py
@@ -5,6 +5,7 @@
 from typing import Generic, List, Tuple, Type, TypeVar, Union
 
 import pytest
+import yaml
 from typing_extensions import Annotated
 
 import dcargs
@@ -217,7 +218,10 @@ class DataclassGeneric(Generic[T]):
         DataclassGeneric[Child], args=["--child.a", "5", "--child.b", "7"]
     )
     assert parsed_instance == DataclassGeneric(Child(5, 7))
-    _check_serialization_identity(DataclassGeneric[Child], parsed_instance)
+
+    # Local generics will break.
+    with pytest.raises(yaml.constructor.ConstructorError):
+        _check_serialization_identity(DataclassGeneric[Child], parsed_instance)
 
 
 def test_generic_nested_dataclass_helptext():
@@ -263,14 +267,22 @@ class Subparser(Generic[T1, T2]):
         args="command:command-one --command.a 5".split(" "),
     )
     assert parsed_instance == Subparser(CommandOne(5))
-    _check_serialization_identity(Subparser[CommandOne, CommandTwo], parsed_instance)
+    # Local generics will break.
+    with pytest.raises(yaml.constructor.ConstructorError):
+        _check_serialization_identity(
+            Subparser[CommandOne, CommandTwo], parsed_instance
+        )
 
     parsed_instance = dcargs.cli(
         Subparser[CommandOne, CommandTwo],
         args="command:command-two --command.b 7".split(" "),
     )
     assert parsed_instance == Subparser(CommandTwo(7))
-    _check_serialization_identity(Subparser[CommandOne, CommandTwo], parsed_instance)
+    # Local generics will break.
+    with pytest.raises(yaml.constructor.ConstructorError):
+        _check_serialization_identity(
+            Subparser[CommandOne, CommandTwo], parsed_instance
+        )
 
 
 def test_generic_subparsers_in_container():
@@ -294,9 +306,11 @@ class Subparser(Generic[T1, T2]):
     assert parsed_instance == Subparser(Command([5, 3])) and isinstance(
         parsed_instance.command.a[0], int
     )
-    _check_serialization_identity(
-        Subparser[Command[int], Command[float]], parsed_instance
-    )
+    # Local generics will break.
+    with pytest.raises(yaml.constructor.ConstructorError):
+        _check_serialization_identity(
+            Subparser[Command[int], Command[float]], parsed_instance
+        )
 
     parsed_instance = dcargs.cli(
         Subparser[Command[int], Command[float]],
@@ -305,9 +319,11 @@ class Subparser(Generic[T1, T2]):
     assert parsed_instance == Subparser(Command([7.0, 2.0])) and isinstance(
         parsed_instance.command.a[0], float
     )
-    _check_serialization_identity(
-        Subparser[Command[int], Command[float]], parsed_instance
-    )
+    # Local generics will break.
+    with pytest.raises(yaml.constructor.ConstructorError):
+        _check_serialization_identity(
+            Subparser[Command[int], Command[float]], parsed_instance
+        )
 
 
 def test_serialize_missing():
@@ -366,9 +382,7 @@ class Wrapper:
         subclass: Union[TypeA, TypeB] = TypeA(1)
 
     wrapper1 = Wrapper()  # Create Wrapper object.
-    dcargs.extras.from_yaml(
-        Wrapper, dcargs.extras.to_yaml(wrapper1)
-    )  # Errors, no constructor for TypeA
+    assert wrapper1 == dcargs.extras.from_yaml(Wrapper, dcargs.extras.to_yaml(wrapper1))
 
 
 def test_annotated():
@@ -383,6 +397,23 @@ class Wrapper:
         subclass: Annotated[TypeA, int] = TypeA(1)
 
     wrapper1 = Wrapper()  # Create Wrapper object.
-    dcargs.extras.from_yaml(
-        Wrapper, dcargs.extras.to_yaml(wrapper1)
-    )  # Errors, no constructor for TypeA
+    assert wrapper1 == dcargs.extras.from_yaml(Wrapper, dcargs.extras.to_yaml(wrapper1))
+
+
+def test_superclass():
+    # https://github.com/brentyi/dcargs/issues/7
+
+    @dataclasses.dataclass
+    class TypeA:
+        data: int
+
+    @dataclasses.dataclass
+    class TypeASubclass(TypeA):
+        pass
+
+    @dataclasses.dataclass
+    class Wrapper:
+        subclass: TypeA
+
+    wrapper1 = Wrapper(TypeASubclass(3))  # Create Wrapper object.
+    assert wrapper1 == dcargs.extras.from_yaml(Wrapper, dcargs.extras.to_yaml(wrapper1))
diff --git a/tests/test_helptext.py b/tests/test_helptext.py
index 2160c40b..dd008965 100644
--- a/tests/test_helptext.py
+++ b/tests/test_helptext.py
@@ -8,7 +8,7 @@
 
 import pytest
 import torch.nn as nn
-from typing_extensions import Literal
+from typing_extensions import Annotated, Literal
 
 import dcargs
 import dcargs._argparse_formatter
@@ -20,15 +20,15 @@ def _get_helptext(f: Callable, args: List[str] = ["--help"]) -> str:
     with pytest.raises(SystemExit), contextlib.redirect_stdout(target):
         dcargs.cli(f, args=args)
 
-    # Check against dcargs.generate_parser(); this should return the same underlying
-    # parser.
+    # Check helptext with vs without termcolor. This can help catch text wrapping bugs
+    # caused by ANSI sequences.
     target2 = io.StringIO()
     with pytest.raises(SystemExit), contextlib.redirect_stdout(target2):
-        with dcargs._argparse_formatter.ansi_context():
-            dcargs.generate_parser(f).parse_args(args)
-    assert target.getvalue() == target2.getvalue()
+        with dcargs._argparse_formatter.dummy_termcolor_context():
+            dcargs.cli(f, args=args)
 
-    return dcargs._strings.strip_ansi_sequences(target.getvalue())
+    assert target2.getvalue() == dcargs._strings.strip_ansi_sequences(target.getvalue())
+    return target2.getvalue()
 
 
 def test_helptext():
@@ -39,7 +39,7 @@ class Helptext:
         x: int  # Documentation 1
 
         # Documentation 2
-        y: int
+        y: Annotated[int, "ignored"]
 
         z: int = 3
         """Documentation 3"""
@@ -54,6 +54,58 @@ class Helptext:
     assert "Documentation 3 (default: 3)\n" in helptext
 
 
+def test_helptext_from_class_docstring():
+    @dataclasses.dataclass
+    class Helptext2:
+        """This docstring should be printed as a description.
+
+        Attributes:
+            x: Documentation 1
+            y: Documentation 2
+            z: Documentation 3
+        """
+
+        x: int
+        y: Annotated[int, "ignored"]
+        z: int = 3
+
+    helptext = _get_helptext(Helptext2)
+    assert "This docstring should be printed as a description" in helptext
+    assert "Attributes" not in helptext
+    assert "x INT" in helptext
+    assert "y INT" in helptext
+    assert "z INT" in helptext
+    assert "Documentation 1 (required)\n" in helptext
+    assert "Documentation 2 (required)\n" in helptext
+    assert "Documentation 3 (default: 3)\n" in helptext
+
+
+def test_helptext_from_class_docstring_args():
+    @dataclasses.dataclass
+    class Helptext3:
+        """This docstring should be printed as a description.
+
+        Args:
+            x: Documentation 1
+            y: Documentation 2
+            z: Documentation 3
+        """
+
+        x: int
+        y: Annotated[int, "ignored"]
+        z: int = 3
+
+    helptext = _get_helptext(Helptext3)
+    assert "This docstring should be printed as a description" in helptext
+    assert "Args" not in helptext
+    assert "x INT" in helptext
+    assert "y INT" in helptext
+    assert "z INT" in helptext
+    assert "Documentation 1 (required)\n" in helptext
+    assert "Documentation 2 (required)\n" in helptext
+    assert "Documentation 3 (default: 3)\n" in helptext
+
+
 def test_helptext_inherited():
     class UnrelatedParentClass:
         pass
@@ -104,8 +156,6 @@ def some_method(self) -> None:  # noqa
     class ChildClass(ParentClass):
         """This docstring should be printed as a description."""
 
-        pass
-
     def main(x: ParentClass = ChildClass(x=5, y=5)) -> Any:
         return x
 
@@ -127,7 +177,6 @@ def __init__(self, a: int):
             Args:
                 a (int): Hello world!
             """
-            pass
 
     def main_with_docstring(a: Inner) -> None:
         """main_with_docstring.
@@ -138,7 +187,6 @@ def main_with_docstring(a: Inner) -> None:
 
     def main_no_docstring(a: Inner) -> None:
         """main_no_docstring."""
-        pass
 
     helptext = _get_helptext(main_with_docstring)
     assert "Documented in function" in helptext and str(Inner.__doc__) not in helptext
@@ -525,9 +573,9 @@ def main(x: Any = Struct()):
     helptext = _get_helptext(main)
     assert "--x {fixed}" in helptext
 
-    def main(x: Callable = nn.ReLU):
+    def main2(x: Callable = nn.ReLU):
         pass
 
-    helptext = _get_helptext(main)
+    helptext = _get_helptext(main2)
     assert "--x {fixed}" in helptext
     assert "(fixed to: <class 'torch.nn" in helptext
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
new file mode 100644
index 00000000..b7c8b394
--- /dev/null
+++ b/tests/test_metadata.py
@@ -0,0 +1,320 @@
+import dataclasses
+from typing import Generic, TypeVar, Union
+
+import pytest
+from typing_extensions import Annotated
+
+import dcargs
+
+
+def test_avoid_subparser_with_default():
+    @dataclasses.dataclass
+    class DefaultInstanceHTTPServer:
+        y: int = 0
+
+    @dataclasses.dataclass
+    class DefaultInstanceSMTPServer:
+        z: int = 0
+
+    @dataclasses.dataclass
+    class DefaultInstanceSubparser:
+        x: int
+        bc: dcargs.conf.AvoidSubcommands[
+            Union[DefaultInstanceHTTPServer, DefaultInstanceSMTPServer]
+        ]
+
+    assert (
+        dcargs.cli(
+            DefaultInstanceSubparser,
+            args=["--x", "1", "bc:default-instance-http-server", "--bc.y", "5"],
+        )
+        == dcargs.cli(
+            DefaultInstanceSubparser,
+            args=["--x", "1", "--bc.y", "5"],
+            default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=3)),
+        )
+        == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5))
+    )
+    assert (
+        dcargs.cli(
+            DefaultInstanceSubparser,
+            args=["--x", "1", "bc:default-instance-http-server", "--bc.y", "8"],
+        )
+        == dcargs.cli(
+            DefaultInstanceSubparser,
+            args=["--bc.y", "8"],
+            default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=7)),
+        )
+        == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8))
+    )
+
+
+def test_avoid_subparser_with_default_recursive():
+    @dataclasses.dataclass
+    class DefaultInstanceHTTPServer:
+        y: int = 0
+
+    @dataclasses.dataclass
+    class DefaultInstanceSMTPServer:
+        z: int = 0
+
+    @dataclasses.dataclass
+    class DefaultInstanceSubparser:
+        x: int
+        bc: Union[DefaultInstanceHTTPServer, DefaultInstanceSMTPServer]
+
+    assert (
+        dcargs.cli(
+            DefaultInstanceSubparser,
+            args=["--x", "1", "bc:default-instance-http-server", "--bc.y", "5"],
+        )
+        == dcargs.cli(
+            dcargs.conf.AvoidSubcommands[DefaultInstanceSubparser],
+            args=["--x", "1", "--bc.y", "5"],
+            default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=3)),
+        )
+        == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5))
+    )
+    assert dcargs.cli(
+        DefaultInstanceSubparser,
+        args=["bc:default-instance-smtp-server", "--bc.z", "3"],
+        default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5)),
+    ) == DefaultInstanceSubparser(x=1, bc=DefaultInstanceSMTPServer(z=3))
+    assert (
+        dcargs.cli(
+            dcargs.conf.AvoidSubcommands[DefaultInstanceSubparser],
+            args=["--x", "1", "bc:default-instance-http-server", "--bc.y", "8"],
+        )
+        == dcargs.cli(
+            dcargs.conf.AvoidSubcommands[DefaultInstanceSubparser],
+            args=["--bc.y", "8"],
+            default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=7)),
+        )
+        == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8))
+    )
+
+
+def test_subparser_in_nested_with_metadata():
+    @dataclasses.dataclass
+    class A:
+        a: int
+
+    @dataclasses.dataclass
+    class B:
+        b: int
+        a: A = A(5)
+
+    @dataclasses.dataclass
+    class Nested2:
+        subcommand: Union[
+            Annotated[A, dcargs.conf.subcommand("command-a", default=A(7))],
+            Annotated[B, dcargs.conf.subcommand("command-b", default=B(9))],
+        ]
+
+    @dataclasses.dataclass
+    class Nested1:
+        nested2: Nested2
+
+    @dataclasses.dataclass
+    class Parent:
+        nested1: Nested1
+
+    assert dcargs.cli(
+        Parent,
+        args="nested1.nested2.subcommand:command-a".split(" "),
+    ) == Parent(Nested1(Nested2(A(7))))
+    assert dcargs.cli(
+        Parent,
+        args=(
+            "nested1.nested2.subcommand:command-a --nested1.nested2.subcommand.a 3".split(
+                " "
+            )
+        ),
+    ) == Parent(Nested1(Nested2(A(3))))
+
+    assert dcargs.cli(
+        Parent,
+        args="nested1.nested2.subcommand:command-b".split(" "),
+    ) == Parent(Nested1(Nested2(B(9))))
+    assert dcargs.cli(
+        Parent,
+        args=(
+            "nested1.nested2.subcommand:command-b --nested1.nested2.subcommand.b 7".split(
+                " "
+            )
+        ),
+    ) == Parent(Nested1(Nested2(B(7))))
+
+
+def test_subparser_in_nested_with_metadata_generic():
+    @dataclasses.dataclass
+    class A:
+        a: int
+
+    @dataclasses.dataclass
+    class B:
+        b: int
+        a: A = A(5)
+
+    T = TypeVar("T")
+
+    @dataclasses.dataclass
+    class Nested2(Generic[T]):
+        subcommand: T
+
+    @dataclasses.dataclass
+    class Nested1:
+        nested2: Nested2[
+            Union[
+                Annotated[A, dcargs.conf.subcommand("command-a", default=A(7))],
+                Annotated[B, dcargs.conf.subcommand("command-b", default=B(9))],
+            ]
+        ]
+
+    @dataclasses.dataclass
+    class Parent:
+        nested1: Nested1
+
+    assert dcargs.cli(
+        Parent,
+        args="nested1.nested2.subcommand:command-a".split(" "),
+    ) == Parent(Nested1(Nested2(A(7))))
+    assert dcargs.cli(
+        Parent,
+        args=(
+            "nested1.nested2.subcommand:command-a --nested1.nested2.subcommand.a 3".split(
+                " "
+            )
+        ),
+    ) == Parent(Nested1(Nested2(A(3))))
+
+    assert dcargs.cli(
+        Parent,
+        args="nested1.nested2.subcommand:command-b".split(" "),
+    ) == Parent(Nested1(Nested2(B(9))))
+    assert dcargs.cli(
+        Parent,
+        args=(
+            "nested1.nested2.subcommand:command-b --nested1.nested2.subcommand.b 7".split(
+                " "
+            )
+        ),
+    ) == Parent(Nested1(Nested2(B(7))))
+
+
+def test_subparser_in_nested_with_metadata_generic_alt():
+    @dataclasses.dataclass
+    class A:
+        a: int
+
+    @dataclasses.dataclass
+    class B:
+        b: int
+        a: A = A(5)
+
+    T = TypeVar("T")
+
+    @dataclasses.dataclass
+    class Nested2(Generic[T]):
+        subcommand: Union[
+            Annotated[T, dcargs.conf.subcommand("command-a", default=A(7))],
+            Annotated[B, dcargs.conf.subcommand("command-b", default=B(9))],
+        ]
+
+    @dataclasses.dataclass
+    class Nested1:
+        nested2: Nested2[A]
+
+    @dataclasses.dataclass
+    class Parent:
+        nested1: Nested1
+
+    assert dcargs.cli(
+        Parent,
+        args="nested1.nested2.subcommand:command-a".split(" "),
+    ) == Parent(Nested1(Nested2(A(7))))
+    assert dcargs.cli(
+        Parent,
+        args=(
+            "nested1.nested2.subcommand:command-a --nested1.nested2.subcommand.a 3".split(
+                " "
+            )
+        ),
+    ) == Parent(Nested1(Nested2(A(3))))
+
+    assert dcargs.cli(
+        Parent,
+        args="nested1.nested2.subcommand:command-b".split(" "),
+    ) == Parent(Nested1(Nested2(B(9))))
+    assert dcargs.cli(
+        Parent,
+        args=(
+            "nested1.nested2.subcommand:command-b --nested1.nested2.subcommand.b 7".split(
+                " "
+            )
+        ),
+    ) == Parent(Nested1(Nested2(B(7))))
+
+
+def test_flag():
+    """When boolean flags have no default value, they must be explicitly specified."""
+
+    @dataclasses.dataclass
+    class A:
+        x: bool
+
+    assert dcargs.cli(
+        A,
+        args=["--x"],
+        default=A(False),
+    ) == A(True)
+
+    assert dcargs.cli(
+        dcargs.conf.FlagConversionOff[A],
+        args=["--x", "True"],
+        default=A(False),
+    ) == A(True)
+
+
+def test_fixed():
+    """When boolean flags have no default value, they must be explicitly specified."""
+
+    @dataclasses.dataclass
+    class A:
+        x: dcargs.conf.Fixed[bool]
+
+    assert dcargs.cli(
+        A,
+        args=[],
+        default=A(True),
+    ) == A(True)
+
+    with pytest.raises(SystemExit):
+        assert dcargs.cli(
+            dcargs.conf.FlagConversionOff[A],
+            args=["--x", "True"],
+            default=A(False),
+        ) == A(True)
+
+
+def test_fixed_recursive():
+    """When boolean flags have no default value, they must be explicitly specified."""
+
+    @dataclasses.dataclass
+    class A:
+        x: bool
+
+    assert dcargs.cli(
+        A,
+        args=["--x"],
+        default=A(False),
+    ) == A(True)
+
+    with pytest.raises(SystemExit):
+        assert dcargs.cli(
+            dcargs.conf.Fixed[
+                dcargs.conf.FlagConversionOff[A],
+            ],
+            args=["--x", "True"],
+            default=A(False),
+        ) == A(True)
diff --git a/tests/test_missing.py b/tests/test_missing.py
index 74b0eb27..16cee168 100644
--- a/tests/test_missing.py
+++ b/tests/test_missing.py
@@ -29,7 +29,7 @@ class Args2:
     assert dcargs.cli(Args2, args=["--b", "7"]) == Args2(5, 7, 3)
 
 
-def test_missing_default_instance():
+def test_missing_default():
     @dataclasses.dataclass
     class Args2:
         a: int
@@ -40,16 +40,16 @@ class Args2:
         dcargs.cli(
             Args2,
             args=[],
-            default_instance=Args2(5, dcargs.MISSING, 3),
+            default=Args2(5, dcargs.MISSING, 3),
         )
     assert dcargs.cli(
         Args2,
         args=["--b", "7"],
-        default_instance=Args2(5, dcargs.MISSING, 3),
+        default=Args2(5, dcargs.MISSING, 3),
     ) == Args2(5, 7, 3)
 
 
-def test_missing_nested_default_instance():
+def test_missing_nested_default():
     @dataclasses.dataclass
     class Child:
         a: int = 5
@@ -64,10 +64,10 @@ class Parent:
         dcargs.cli(
             Parent,
             args=[],
-            default_instance=Parent(child=dcargs.MISSING),
+            default=Parent(child=dcargs.MISSING),
         )
     assert dcargs.cli(
         Parent,
         args=["--child.a", "5", "--child.b", "7", "--child.c", "3"],
-        default_instance=Parent(child=dcargs.MISSING),
+        default=Parent(child=dcargs.MISSING),
     ) == Parent(Child(5, 7, 3))
diff --git a/tests/test_nested.py b/tests/test_nested.py
index b52f25be..db1e5e1b 100644
--- a/tests/test_nested.py
+++ b/tests/test_nested.py
@@ -3,6 +3,7 @@
 
 import pytest
 from frozendict import frozendict  # type: ignore
+from typing_extensions import Annotated
 
 import dcargs
 
@@ -22,7 +23,22 @@ class Nested:
         dcargs.cli(Nested, args=["--x", "1"])
 
 
-def test_nested_default_instance():
+def test_nested_annotated():
+    @dataclasses.dataclass
+    class B:
+        y: int
+
+    @dataclasses.dataclass
+    class Nested:
+        x: int
+        b: Annotated[B, "this should be ignored"]
+
+    assert dcargs.cli(Nested, args=["--x", "1", "--b.y", "3"]) == Nested(x=1, b=B(y=3))
+    with pytest.raises(SystemExit):
+        dcargs.cli(Nested, args=["--x", "1"])
+
+
+def test_nested_default():
     @dataclasses.dataclass
     class B:
         y: int = 1
@@ -32,12 +48,12 @@ class Nested:
         x: int = 2
         b: B = B()
 
-    assert dcargs.cli(
-        Nested, args=[], default_instance=Nested(x=1, b=B(y=2))
-    ) == Nested(x=1, b=B(y=2))
+    assert dcargs.cli(Nested, args=[], default=Nested(x=1, b=B(y=2))) == Nested(
+        x=1, b=B(y=2)
+    )
 
 
-def test_nested_default():
+def test_nested_default_alternate():
     @dataclasses.dataclass
     class B:
         y: int = 3
@@ -50,7 +66,7 @@ class Nested:
     assert (
         Nested(x=1, b=B(y=3))
         == dcargs.cli(Nested, args=["--x", "1", "--b.y", "3"])
-        == dcargs.cli(Nested, args=[], default_instance=Nested(x=1, b=B(y=3)))
+        == dcargs.cli(Nested, args=[], default=Nested(x=1, b=B(y=3)))
     )
     assert dcargs.cli(Nested, args=["--x", "1"]) == Nested(x=1, b=B(y=3))
 
@@ -216,7 +232,7 @@ class DefaultSubparser:
         == dcargs.cli(
             DefaultSubparser,
             args=[],
-            default_instance=DefaultSubparser(x=1, bc=DefaultHTTPServer(y=8)),
+            default=DefaultSubparser(x=1, bc=DefaultHTTPServer(y=8)),
         )
         == DefaultSubparser(x=1, bc=DefaultHTTPServer(y=8))
     )
@@ -227,7 +243,7 @@ class DefaultSubparser:
         dcargs.cli(DefaultSubparser, args=["--x", "1", "c", "--bc.y", "3"])
 
 
-def test_subparser_with_default_instance():
+def test_subparser_with_default_alternate():
     @dataclasses.dataclass
     class DefaultInstanceHTTPServer:
         y: int = 0
@@ -249,25 +265,19 @@ class DefaultInstanceSubparser:
         == dcargs.cli(
             DefaultInstanceSubparser,
             args=[],
-            default_instance=DefaultInstanceSubparser(
-                x=1, bc=DefaultInstanceHTTPServer(y=5)
-            ),
+            default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5)),
         )
         == dcargs.cli(
             DefaultInstanceSubparser,
             args=["bc:default-instance-http-server"],
-            default_instance=DefaultInstanceSubparser(
-                x=1, bc=DefaultInstanceHTTPServer(y=5)
-            ),
+            default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5)),
         )
         == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5))
     )
     assert dcargs.cli(
         DefaultInstanceSubparser,
         args=["bc:default-instance-smtp-server", "--bc.z", "3"],
-        default_instance=DefaultInstanceSubparser(
-            x=1, bc=DefaultInstanceHTTPServer(y=5)
-        ),
+        default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5)),
     ) == DefaultInstanceSubparser(x=1, bc=DefaultInstanceSMTPServer(z=3))
     assert (
         dcargs.cli(
@@ -277,9 +287,7 @@ class DefaultInstanceSubparser:
         == dcargs.cli(
             DefaultInstanceSubparser,
             args=[],
-            default_instance=DefaultInstanceSubparser(
-                x=1, bc=DefaultInstanceHTTPServer(y=8)
-            ),
+            default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8)),
         )
         == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8))
     )
@@ -290,59 +298,6 @@ class DefaultInstanceSubparser:
         dcargs.cli(DefaultInstanceSubparser, args=["--x", "1", "c", "--bc.y", "3"])
 
 
-def test_avoid_subparser_with_default_instance():
-    @dataclasses.dataclass
-    class DefaultInstanceHTTPServer:
-        y: int = 0
-
-    @dataclasses.dataclass
-    class DefaultInstanceSMTPServer:
-        z: int = 0
-
-    @dataclasses.dataclass
-    class DefaultInstanceSubparser:
-        x: int
-        bc: Union[DefaultInstanceHTTPServer, DefaultInstanceSMTPServer]
-
-    assert (
-        dcargs.cli(
-            DefaultInstanceSubparser,
-            args=["--x", "1", "bc:default-instance-http-server", "--bc.y", "5"],
-        )
-        == dcargs.cli(
-            DefaultInstanceSubparser,
-            args=["--x", "1", "--bc.y", "5"],
-            default_instance=DefaultInstanceSubparser(
-                x=1, bc=DefaultInstanceHTTPServer(y=3)
-            ),
-            avoid_subparsers=True,
-        )
-        == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5))
-    )
-    assert dcargs.cli(
-        DefaultInstanceSubparser,
-        args=["bc:default-instance-smtp-server", "--bc.z", "3"],
-        default_instance=DefaultInstanceSubparser(
-            x=1, bc=DefaultInstanceHTTPServer(y=5)
-        ),
-    ) == DefaultInstanceSubparser(x=1, bc=DefaultInstanceSMTPServer(z=3))
-    assert (
-        dcargs.cli(
-            DefaultInstanceSubparser,
-            args=["--x", "1", "bc:default-instance-http-server", "--bc.y", "8"],
-        )
-        == dcargs.cli(
-            DefaultInstanceSubparser,
-            args=["--bc.y", "8"],
-            default_instance=DefaultInstanceSubparser(
-                x=1, bc=DefaultInstanceHTTPServer(y=7)
-            ),
-            avoid_subparsers=True,
-        )
-        == DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8))
-    )
-
-
 def test_optional_subparser():
     @dataclasses.dataclass
     class OptionalHTTPServer:
@@ -496,7 +451,7 @@ class MultipleSubparsers:
         dcargs.cli(
             MultipleSubparsers,
             args=[],
-            default_instance=MultipleSubparsers(
+            default=MultipleSubparsers(
                 Subcommand1(),
                 Subcommand2(),
                 Subcommand3(dcargs.MISSING),
@@ -508,7 +463,7 @@ class MultipleSubparsers:
             args=[
                 "a:subcommand1",
             ],
-            default_instance=MultipleSubparsers(
+            default=MultipleSubparsers(
                 Subcommand1(),
                 Subcommand2(),
                 Subcommand3(dcargs.MISSING),
@@ -518,7 +473,7 @@ class MultipleSubparsers:
         dcargs.cli(
             MultipleSubparsers,
             args=["a:subcommand1", "b:subcommand2"],
-            default_instance=MultipleSubparsers(
+            default=MultipleSubparsers(
                 Subcommand1(),
                 Subcommand2(),
                 Subcommand3(dcargs.MISSING),
@@ -528,7 +483,7 @@ class MultipleSubparsers:
         dcargs.cli(
             MultipleSubparsers,
             args=["a:subcommand1", "b:subcommand2", "c:subcommand3"],
-            default_instance=MultipleSubparsers(
+            default=MultipleSubparsers(
                 Subcommand1(),
                 Subcommand2(),
                 Subcommand3(dcargs.MISSING),
@@ -537,7 +492,7 @@ class MultipleSubparsers:
     assert dcargs.cli(
         MultipleSubparsers,
         args=["a:subcommand1", "b:subcommand2", "c:subcommand3", "--c.z", "3"],
-        default_instance=MultipleSubparsers(
+        default=MultipleSubparsers(
             Subcommand1(),
             Subcommand2(),
             Subcommand3(dcargs.MISSING),
@@ -546,7 +501,7 @@ class MultipleSubparsers:
     assert dcargs.cli(
         MultipleSubparsers,
         args=["a:subcommand1", "b:subcommand2", "c:subcommand2"],
-        default_instance=MultipleSubparsers(
+        default=MultipleSubparsers(
             Subcommand1(),
             Subcommand2(),
             Subcommand3(dcargs.MISSING),
diff --git a/tests/test_print_completion.py b/tests/test_print_completion.py
new file mode 100644
index 00000000..9f4c34d9
--- /dev/null
+++ b/tests/test_print_completion.py
@@ -0,0 +1,43 @@
+import contextlib
+import dataclasses
+import io
+from typing import Union
+
+import pytest
+
+import dcargs
+
+
+# https://github.com/brentyi/dcargs/issues/9
+@dataclasses.dataclass(frozen=True)
+class Subtype:
+    data: int = 1
+
+
+@dataclasses.dataclass(frozen=True)
+class TypeA:
+    subtype: Subtype = Subtype(1)
+
+
+@dataclasses.dataclass(frozen=True)
+class TypeB:
+    subtype: Subtype = Subtype(2)
+
+
+@dataclasses.dataclass(frozen=True)
+class Wrapper:
+    supertype: Union[TypeA, TypeB] = TypeA()
+
+
+def test_bash():
+    target = io.StringIO()
+    with pytest.raises(SystemExit), contextlib.redirect_stdout(target):
+        dcargs.cli(Wrapper, args=["--dcargs-print-completion", "bash"])
+    assert "# AUTOMATCALLY GENERATED by `shtab`" in target.getvalue()
+
+
+def test_zsh():
+    target = io.StringIO()
+    with pytest.raises(SystemExit), contextlib.redirect_stdout(target):
+        dcargs.cli(Wrapper, args=["--dcargs-print-completion", "zsh"])
+    assert "# AUTOMATCALLY GENERATED by `shtab`" in target.getvalue()
diff --git a/tests/test_union_from_mapping.py b/tests/test_union_from_mapping.py
new file mode 100644
index 00000000..14142f4b
--- /dev/null
+++ b/tests/test_union_from_mapping.py
@@ -0,0 +1,48 @@
+import dataclasses
+from typing import Optional
+
+import dcargs
+
+
+@dataclasses.dataclass
+class A:
+    x: int
+
+
+def test_union_from_mapping():
+    base_configs = {
+        "one": A(1),
+        "two": A(2),
+        "three": A(3),
+    }
+    ConfigUnion = dcargs.extras.subcommand_union_from_mapping(base_configs)
+
+    assert dcargs.cli(ConfigUnion, args="one".split(" ")) == A(1)
+    assert dcargs.cli(ConfigUnion, args="two".split(" ")) == A(2)
+    assert dcargs.cli(ConfigUnion, args="two --x 4".split(" ")) == A(4)
+    assert dcargs.cli(ConfigUnion, args="three".split(" ")) == A(3)
+
+
+def test_union_from_mapping_in_function():
+    base_configs = {
+        "one": A(1),
+        "two": A(2),
+        "three": A(3),
+    }
+
+    # Hack for mypy. Not needed for pyright.
+    ConfigUnion = A
+    ConfigUnion = dcargs.extras.subcommand_union_from_mapping(base_configs)  # type: ignore
+
+    def main(config: ConfigUnion, flag: bool = False) -> Optional[A]:
+        if flag:
+            return config
+        return None
+
+    assert dcargs.cli(main, args="--flag config:one".split(" ")) == A(1)
+    assert dcargs.cli(main, args="--flag config:one --config.x 3".split(" ")) == A(3)
+    assert dcargs.cli(main, args="config:one --config.x 1".split(" ")) is None
+
+    assert dcargs.cli(main, args="--flag config:two".split(" ")) == A(2)
+    assert dcargs.cli(main, args="--flag config:two --config.x 3".split(" ")) == A(3)
+    assert dcargs.cli(main, args="config:two --config.x 1".split(" ")) is None