Repository: coverahealth/dataspec

Issue:
Add functionality for merging mapping Specs
It is not possible to merge multiple mapping Specs together right now without defining a custom Spec. Using `s.all` would result in losing any keys whose Specs are defined in later Specs during conformation.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e961abe..9f6fb1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Add a `regex` validator to the `s.bytes` factory (#37)
 - Added `Spec.compose_conformer` to allow composition of new conformers with existing
   conformers (#65)
+- Added `s.merge` to allow seamless merging of mapping Specs (#70)
 
 ### Changed
 - **Breaking** `Spec.with_conformer` will now replace the default conformer applied
@@ -20,9 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   can use `Spec.compose_conformer`, which will compose your conformer after any
   existing conformers on a Spec instance and return a copy with that composition.
   (#65)
+- `s.all` and `s.and` now return the result of calling `s(tag, pred, conformer)` if
+  passed only one predicate (#72)
 
 ### Fixed
 - Fixed a bug where `s(None)` is not a valid alias for `s(type(None))` (#61)
+- Fixed a bug where it was possible to define duplicate keys in mapping Specs with
+  `s.opt(k)` (#74)
 
 ## [v0.2.5] - 2020-04-10
 ### Added
diff --git a/docs/usage.rst b/docs/usage.rst
index ae5a43b..8ac9279 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -339,6 +339,45 @@ returned by the mapping spec default conformer will not include any extra keys
 included in the input. Optional keys will be included in the conformed value if they
 appear in the input map.
 
+.. _merging_mapping_specs:
+
+Merging Mapping Specs
+^^^^^^^^^^^^^^^^^^^^^
+
+Occasionally, you may wish to declare your mapping Specs across two or more different
+Specs. It may be convenient to do so for composition of common keys across multiple
+Specs. In such cases, you may naturally turn to one of the builtin
+:ref:`combination_specs` to return a union of the input Specs. However, combination
+Specs composed of mapping Specs with disjoint or only partially intersecting key sets
+will end up producing unexpected results. Recall mapping Specs have a default conformer
+which drops keys not declared in the input Spec, so the chained conformation of
+:py:meth:`dataspec.SpecAPI.all` will drop keys potentially expected by later Specs.
+
+To merge mapping Specs, use :py:meth:`dataspec.SpecAPI.merge` instead.
+
+.. code-block:: python
+
+   s.merge(
+       {"id": int},
+       {
+           "id": lambda v: v > 0,
+           "first_name": str,
+           s.opt("middle_initial"): str,
+           "last_name": str,
+       },
+   )
+
+In the above Spec, ``id`` would be a required key, which must be an integer greater
+than zero. Specs for the remaining keys would match the Spec defined in the second
+input Spec.
+
+.. note::
+
+   Only mapping Specs may be merged. ``s.merge`` will throw a :py:class:`ValueError`
+   if you attempt to merge non-mapping type Specs. To combine mapping and non-mapping
+   Spec types, you should wrap the mapping Specs with ``s.merge`` and pass that to
+   ``s.all``.
+
 .. _tuple_specs:
 
 Tuple Specs
@@ -404,6 +443,14 @@ any ``n`` specs:
    The names ``any`` and ``all`` were chosen because ``or`` and ``and`` are not valid
    Python since they are reserved keywords.
 
+.. warning::
+
+   Using a :py:meth:`dataspec.SpecAPI.all` Spec to combine mapping Specs for maps with
+   disjoint or only partially intersecting keys will result in maps losing keys during
+   conformation and failing validation in later Specs.
+   Use :py:meth:`dataspec.SpecAPI.merge` to combine mapping Specs. Read more in
+   :ref:`merging_mapping_specs`.
+
 .. _utility_specs:
 
 Utility Specs
diff --git a/src/dataspec/__init__.py b/src/dataspec/__init__.py
index 229233e..b59eedb 100644
--- a/src/dataspec/__init__.py
+++ b/src/dataspec/__init__.py
@@ -11,8 +11,9 @@ from dataspec.base import (
     ValidationError,
     ValidatorFn,
     pred_to_validator,
+    tag_maybe,
 )
-from dataspec.factories import register_str_format, register_str_format_spec, tag_maybe
+from dataspec.factories import register_str_format, register_str_format_spec
 
 __all__ = [
     "INVALID",
diff --git a/src/dataspec/api.py b/src/dataspec/api.py
index b4bc254..9683eed 100644
--- a/src/dataspec/api.py
+++ b/src/dataspec/api.py
@@ -7,11 +7,12 @@ from dataspec.base import (
     SpecPredicate,
     Tag,
     ValidationError,
+    all_spec,
+    any_spec,
     make_spec,
+    merge_spec,
 )
 from dataspec.factories import (
-    all_spec,
-    any_spec,
     blankable_spec,
     bool_spec,
     bytes_spec,
@@ -62,7 +63,7 @@ def _fdef(
     """Wrap a function ``f`` and validate its arguments, keyword arguments, and return
     value with Specs, if any are given."""
     argspecs = s(argpreds) if argpreds else None
-    kwargspecs = s(kwargpreds) if kwargpreds else None
+    kwargspecs = s(kwargpreds) if kwargpreds else None  # type: ignore
     retspec = s(retpred) if retpred else None
 
     assert [argspecs, kwargspecs, retspec].count(
@@ -114,6 +115,7 @@ class SpecAPI:
     email = staticmethod(email_spec)
     every = staticmethod(every_spec)
     inst = staticmethod(datetime_spec)
+    merge = staticmethod(merge_spec)
     nilable = staticmethod(nilable_spec)
     num = staticmethod(num_spec)
     obj = staticmethod(obj_spec)
diff --git a/src/dataspec/base.py b/src/dataspec/base.py
index 65ac3fe..efe4154 100644
--- a/src/dataspec/base.py
+++ b/src/dataspec/base.py
@@ -3,7 +3,7 @@ import inspect
 import re
 import sys
 from abc import ABC, abstractmethod
-from collections import namedtuple
+from collections import defaultdict, namedtuple
 from enum import EnumMeta
 from itertools import chain
 from typing import (
@@ -11,6 +11,7 @@ from typing import (
     Callable,
     FrozenSet,
     Generic,
+    Hashable,
     Iterable,
     Iterator,
     List,
@@ -29,6 +30,10 @@ from typing import (
 
 import attr
 
+# In Python 3.6, you cannot inherit directly from Generic with slotted classes:
+# https://github.com/python-attrs/attrs/issues/313
+_USE_SLOTS_FOR_GENERIC = sys.version_info >= (3, 7)
+
 T = TypeVar("T")
 V = TypeVar("V")
 
@@ -48,12 +53,14 @@ INVALID = Invalid()
 
 
 Conformer = Callable[[T], Union[V, Invalid]]
+ObjectSpecKey = Union[str, "OptionalKey[str]"]
 PredicateFn = Callable[[Any], bool]
 ValidatorFn = Callable[[Any], Iterable["ErrorDetails"]]
 Tag = str
 
 SpecPredicate = Union[  # type: ignore
-    Mapping[Any, "SpecPredicate"],  # type: ignore
+    Mapping[Hashable, "SpecPredicate"],  # type: ignore
+    Mapping[ObjectSpecKey, "SpecPredicate"],  # type: ignore
     Tuple["SpecPredicate", ...],  # type: ignore
     List["SpecPredicate"],  # type: ignore
     FrozenSet[Any],
@@ -294,6 +301,15 @@ class Spec(ABC):
         return attr.evolve(self, tag=tag)
 
 
+def tag_maybe(
+    maybe_tag: Union[Tag, T], *args: T
+) -> Tuple[Optional[Tag], Tuple[T, ...]]:
+    """Return the Spec tag and the remaining arguments if a tag is given, else return
+    the arguments."""
+    tag = maybe_tag if isinstance(maybe_tag, str) else None
+    return tag, (cast("Tuple[T, ...]", (maybe_tag, *args)) if tag is None else args)
+
+
 @attr.s(auto_attribs=True, frozen=True, slots=True)
 class ValidatorSpec(Spec):
     """Validator Specs yield richly detailed errors from their validation functions and
@@ -321,7 +337,7 @@ class ValidatorSpec(Spec):
         tag: Tag,
         *preds: Union[ValidatorFn, "ValidatorSpec"],
         conformer: Optional[Conformer] = None,
-    ) -> "ValidatorSpec":
+    ) -> Spec:
         """Return a single Validator spec from the composition of multiple validator
         functions or ValidatorSpec instances."""
         assert len(preds) > 0, "At least on predicate must be specified"
@@ -378,27 +394,31 @@ class PredicateSpec(Spec):
             )
 
 
+CollSpecKwargs = Mapping[str, Union[bool, int, Type, None]]
+
+
 @attr.s(auto_attribs=True, frozen=True, slots=True)
 class CollSpec(Spec):
     tag: Tag
     _spec: Spec
     conformer: Optional[Conformer] = None
     _out_type: Optional[Type] = None
-    _validate_coll: Optional[ValidatorSpec] = None
+    _validate_coll: Optional[Spec] = None
 
     @classmethod  # noqa: MC0001
     def from_val(
         cls,
         tag: Optional[Tag],
-        sequence: Sequence[Union[SpecPredicate, Mapping[str, Any]]],
+        sequence: Sequence[Union[SpecPredicate, CollSpecKwargs]],
         conformer: Conformer = None,
-    ):
+    ) -> Spec:
         # pylint: disable=too-many-branches,too-many-locals
-        spec = make_spec(sequence[0])
-        validate_coll: Optional[ValidatorSpec] = None
+        spec = make_spec(cast(SpecPredicate, sequence[0]))
+        validate_coll: Optional[Spec] = None
 
+        kwargs: CollSpecKwargs
         try:
-            kwargs = sequence[1]
+            kwargs = cast(CollSpecKwargs, sequence[1])
             if not isinstance(kwargs, dict):
                 raise TypeError("Collection spec options must be a dict")
         except IndexError:
@@ -406,12 +426,12 @@ class CollSpec(Spec):
 
         validators = []
 
-        allow_str: bool = kwargs.get("allow_str", False)  # type: ignore
-        maxlength: Optional[int] = kwargs.get("maxlength", None)  # type: ignore
-        minlength: Optional[int] = kwargs.get("minlength", None)  # type: ignore
-        count: Optional[int] = kwargs.get("count", None)  # type: ignore
-        type_: Optional[Type] = kwargs.get("kind", None)  # type: ignore
-        out_type: Optional[Type] = kwargs.get("into", None)  # type: ignore
+        allow_str: bool = kwargs.get("allow_str", False)
+        maxlength: Optional[int] = kwargs.get("maxlength", None)
+        minlength: Optional[int] = kwargs.get("minlength", None)
+        count: Optional[int] = kwargs.get("count", None)
+        type_: Optional[Type] = kwargs.get("kind", None)
+        out_type: Optional[Type] = kwargs.get("into", None)
 
         if not allow_str and type_ is None:
 
@@ -516,9 +536,7 @@ class CollSpec(Spec):
             yield from _enrich_errors(self._spec.validate(e), self.tag, i)
 
 
-# In Python 3.6, you cannot inherit directly from Generic with slotted classes:
-# https://github.com/python-attrs/attrs/issues/313
-@attr.s(auto_attribs=True, frozen=True, slots=sys.version_info >= (3, 7))
+@attr.s(auto_attribs=True, frozen=True, slots=_USE_SLOTS_FOR_GENERIC)
 class OptionalKey(Generic[T]):
     key: T
 
@@ -526,23 +544,31 @@ class OptionalKey(Generic[T]):
 @attr.s(auto_attribs=True, frozen=True, slots=True)
 class DictSpec(Spec):
     tag: Tag
-    _reqkeyspecs: Mapping[Any, Spec] = attr.ib(factory=dict)
-    _optkeyspecs: Mapping[Any, Spec] = attr.ib(factory=dict)
+    _reqkeyspecs: Mapping[Hashable, Spec] = attr.ib(factory=dict)
+    _optkeyspecs: Mapping[Hashable, Spec] = attr.ib(factory=dict)
     conformer: Optional[Conformer] = None
 
     @classmethod
     def from_val(
         cls,
         tag: Optional[Tag],
-        kvspec: Mapping[str, SpecPredicate],
+        kvspec: Mapping[Hashable, SpecPredicate],
         conformer: Optional[Conformer] = None,
-    ):
+    ) -> Spec:
         reqkeys = {}
         optkeys: MutableMapping[Any, Spec] = {}
         for k, v in kvspec.items():
             if isinstance(k, OptionalKey):
+                if k.key in reqkeys:
+                    raise KeyError(
+                        f"Optional key '{k.key}' duplicates key already defined in required keys"
+                    )
                 optkeys[k.key] = make_spec(v)
             else:
+                if k in optkeys:
+                    raise KeyError(
+                        f"Required key '{k}' duplicates key already defined in optional keys"
+                    )
                 reqkeys[k] = make_spec(v)
 
         def conform_mapping(d: Mapping) -> Mapping:
@@ -589,25 +615,70 @@ class DictSpec(Spec):
             if k in d:
                 yield from _enrich_errors(vspec.validate(d[k]), self.tag, k)
 
+    # pylint: disable=protected-access
+    @classmethod
+    def merge(
+        cls,
+        tag: Optional[Tag],
+        *specs: "DictSpec",
+        conformer: Optional[Conformer] = None,
+    ) -> Spec:
+        assert len(specs) >= 2, "Must merge at least two Specs"
+
+        map_pred: MutableMapping[Hashable, List[SpecPredicate]] = defaultdict(list)
+        for spec in specs:
+            for k, pred in spec._reqkeyspecs.items():
+                map_pred[k].append(pred)
+
+            for k, pred in spec._optkeyspecs.items():
+                map_pred[OptionalKey(k)].append(pred)
+
+        return cls.from_val(
+            tag or f"merge-of-{'-'.join(spec.tag for spec in specs)}",
+            {k: all_spec(str(k), *v) for k, v in map_pred.items()},
+            conformer=conformer,
+        )
+
+
+@attr.s(auto_attribs=True, frozen=True, slots=True)
+class ObjectSpec(Spec):
+    tag: Tag
+    _reqattrspecs: Mapping[str, Spec] = attr.ib(factory=dict)
+    _optattrspecs: Mapping[str, Spec] = attr.ib(factory=dict)
+    conformer: Optional[Conformer] = None
 
-class ObjectSpec(DictSpec):
     @classmethod
     def from_val(
         cls,
         tag: Optional[Tag],
-        kvspec: Mapping[str, SpecPredicate],
+        kvspec: Mapping[ObjectSpecKey, SpecPredicate],
         conformer: Optional[Conformer] = None,
-    ):
-        """
-        Return a Spec for an arbitrary object instance.
+    ) -> Spec:
+        reqattrs = {}
+        optattrs: MutableMapping[str, Spec] = {}
+        for k, v in kvspec.items():
+            if isinstance(k, OptionalKey):
+                if k.key in reqattrs:
+                    raise KeyError(
+                        f"Optional attribute '{k.key}' duplicates key already defined in required attributes"
+                    )
+                optattrs[k.key] = make_spec(v)
+            else:
+                if k in optattrs:
+                    raise KeyError(
+                        f"Required attribute '{k}' duplicates key already defined in optional attributes"
+                    )
+                reqattrs[k] = make_spec(v)
 
-        Overwrite the default conformer provided for ``DictSpec`` s, since it does not
-        make sense for objects.
-        """
-        return super().from_val(tag, kvspec).with_conformer(conformer)
+        return cls(
+            tag or "obj",
+            reqattrspecs=reqattrs,
+            optattrspecs=optattrs,
+            conformer=conformer,
+        )
 
     def validate(self, o) -> Iterator[ErrorDetails]:  # pylint: disable=arguments-differ
-        for k, vspec in self._reqkeyspecs.items():
+        for k, vspec in self._reqattrspecs.items():
             if hasattr(o, k):
                 yield from _enrich_errors(vspec.validate(getattr(o, k)), self.tag, k)
             else:
@@ -619,7 +690,7 @@ class ObjectSpec(DictSpec):
                     path=[k],
                 )
 
-        for k, vspec in self._optkeyspecs.items():
+        for k, vspec in self._optattrspecs.items():
             if hasattr(o, k):
                 yield from _enrich_errors(vspec.validate(getattr(o, k)), self.tag, k)
 
@@ -672,6 +743,9 @@ class SetSpec(Spec):
         )
 
 
+_MUNGE_NAMES = re.compile(r"[\s|-]")
+
+
 @attr.s(auto_attribs=True, frozen=True, slots=True)
 class TupleSpec(Spec):
     tag: Tag
@@ -784,7 +858,206 @@ def compose_spec_conformers(
     )
 
 
-_MUNGE_NAMES = re.compile(r"[\s|-]")
+def all_spec(
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    conformer: Optional[Conformer] = None,
+) -> Spec:
+    """
+    Return a Spec which validates input values against all of the input Specs or
+    spec predicates.
+
+    For each Spec for which the input value is successfully validated, the value is
+    successively passed to the Spec's :py:meth:`dataspec.Spec.conform_valid` method.
+
+    The returned Spec's :py:meth:`dataspec.Spec.validate` method will emit a stream
+    of :py:class:`dataspec.ErrorDetails`` from the first failing constituent Spec.
+    :py:class:`dataspec.ErrorDetails` emitted from Specs after a failing Spec will
+    not be emitted, because the failing Spec's :py:meth:`dataspec.Spec.conform``
+    would not successfully conform the value.
+
+    The returned Spec's :py:meth:`dataspec.Spec.conform` method is the composition
+    of all of the input Spec's ``conform`` methods.
+
+    If no Specs or Spec predicates are given, a :py:class:`ValueError` will be raised.
+    If only one Spec or Spec predicate is provided, it will be passed to
+    :py:func:`dataspec.s` with the given ``tag`` and ``conformer`` and the value
+    returned without merging.
+
+    This method is not suitable for producing a union of mapping Specs. To merge
+    mapping Specs, use :py:meth:`dataspec.SpecAPI.merge` instead.
+
+    :param tag_or_pred: an optional tag for the resulting spec or the first Spec
+        or value which can be converted into a Spec
+    :param preds: zero or more Specs or values which can be converted into a Spec
+    :param conformer: an optional conformer which will be applied to the final
+        conformed value produced by the input Specs conformers
+    :return: a Spec
+    """
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if not preds:
+        raise ValueError("Must provide at least one Spec for 'all' Specs")
+
+    if len(preds) == 1:
+        return make_spec(*filter(None, (tag,)), preds[0], conformer=conformer)
+
+    specs = [make_spec(pred) for pred in preds]
+
+    def _all_valid(e) -> Iterator[ErrorDetails]:
+        """Validate e against successive conformations to spec in specs."""
+
+        for spec in specs:
+            errors = []
+            for error in spec.validate(e):
+                errors.append(error)
+            if errors:
+                yield from errors
+                return
+            e = spec.conform_valid(e)
+
+    return ValidatorSpec(
+        tag or "all",
+        _all_valid,
+        conformer=compose_spec_conformers(*specs, conform_final=conformer),
+    )
+
+
+def any_spec(
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    tag_conformed: bool = False,
+    conformer: Optional[Conformer] = None,
+) -> Spec:
+    """
+    Return a Spec which validates input values against any one of an arbitrary
+    number of input Specs.
+
+    The returned Spec validates input values against the input Specs in the order
+    they are passed into this function.
+
+    If the returned Spec fails to validate the input value, the
+    :py:meth:`dataspec.Spec.validate` method will emit a stream of
+    :py:class:`dataspec.ErrorDetails` from all of failing constituent Specs. If any of
+    the constituent Specs successfully validates the input value, then no
+    :py:class:`dataspec.ErrorDetails` will be emitted by the
+    :py:meth:`dataspec.Spec.validate` method.
+
+    The conformer for the returned Spec will select the conformer for the first
+    constituent Spec which successfully validates the input value. If a ``conformer``
+    is specified for this Spec, that conformer will be applied after the successful
+    Spec's conformer. If ``tag_conformed`` is specified, the final conformed value
+    from both conformers will be wrapped in a tuple, where the first element is the
+    tag of the successful Spec and the second element is the final conformed value.
+    If ``tag_conformed`` is not specified (which is the default), the conformer will
+    emit the conformed value directly.
+
+    If no Specs or Spec predicates are given, a :py:class:`ValueError` will be raised.
+    If only one Spec or Spec predicate is provided, it will be passed to
+    :py:func:`dataspec.s` with the given ``tag`` and ``conformer`` and the value
+    returned without merging.
+
+    :param tag_or_pred: an optional tag for the resulting spec or the first Spec
+        or value which can be converted into a Spec
+    :param preds: zero or more Specs or values which can be converted into a Spec
+    :param tag_conformed: if :py:obj:`True`, the conformed value will be wrapped in a
+        2-tuple where the first element is the successful spec and the second element
+        is the conformed value; if :py:obj:`False`, return only the conformed value
+    :param conformer: an optional conformer for the value
+    :return: a Spec
+    """
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if not preds:
+        raise ValueError("Must provide at least one Spec for 'any' Specs")
+
+    if len(preds) == 1:
+        return make_spec(*filter(None, (tag,)), preds[0], conformer=conformer)
+
+    specs = [make_spec(pred) for pred in preds]
+
+    def _any_valid(e) -> Iterator[ErrorDetails]:
+        errors = []
+        for spec in specs:
+            spec_errors = list(spec.validate(e))
+            if spec_errors:
+                errors.extend(spec_errors)
+            else:
+                return
+
+        yield from errors
+
+    def _conform_any(e):
+        for spec in specs:
+            spec_errors = list(spec.validate(e))
+            if spec_errors:
+                continue
+
+            conformed = spec.conform_valid(e)
+            assert conformed is not INVALID
+            if conformer is not None:
+                conformed = conformer(conformed)
+            if tag_conformed:
+                conformed = (spec.tag, conformed)
+            return conformed
+
+        return INVALID
+
+    return ValidatorSpec(tag or "any", _any_valid, conformer=_conform_any)
+
+
+def merge_spec(
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    conformer: Optional[Conformer] = None,
+) -> Spec:
+    """
+    Merge two or more mapping Specs into a single new Spec.
+
+    The returned Spec validates input values against a mapping Spec which is created
+    from the union of input mapping Specs. Mapping Specs will be merged in the order
+    they are provided. Individual key Specs whose keys appear more than one input Spec
+    will be merged as via :py:meth:`dataspec.SpecAPI.all` in the order they are passed
+    into this function.
+
+    If no Specs or Spec predicates are given, a :py:class:`ValueError` will be raised.
+    If only one Spec or Spec predicate is provided, it will be passed to
+    :py:func:`dataspec.s` with the given ``tag`` and ``conformer`` and the value returned
+    without merging. If any Specs or Spec predicates are provided which are not mapping
+    Specs or which cannot be coerced to mapping Specs, a :py:class:`TypeError` will be
+    raised.
+
+    The returned Spec's :py:meth:`dataspec.Spec.conform` method is a standard mapping
+    Spec default conformer. Keys not defined in the union of key sets will be dropped
+    during conformation. Values with more than one Spec defined in the input Specs
+    will be conformed as by :py:meth:`dataspec.SpecAPI.all` applied to all of their
+    input Specs in the order they were provided. Values with exactly one Spec will
+    use that Spec as given.
+
+    :param tag_or_pred: an optional tag for the resulting spec or the first Spec
+        or value which can be converted into a Spec
+    :param preds: zero or more mapping Specs or values which can be converted into a
+        mapping Spec
+    :param conformer: an optional conformer for the value
+    :return: a single mapping Spec which is the union of all input Specs
+    """
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if not preds:
+        raise ValueError("Must provide at least one mapping Spec to merge")
+
+    if len(preds) == 1:
+        return make_spec(*filter(None, (tag,)), preds[0], conformer=conformer)
+
+    specs = [make_spec(pred) for pred in preds]
+
+    for spec in specs:
+        if not isinstance(spec, DictSpec):
+            raise TypeError(f"Can only merge mapping spec types, not '{type(spec)}'")
+
+    return DictSpec.merge(
+        tag, *cast("Tuple[DictSpec, ...]", specs), conformer=conformer
+    )
 
 
 def _complement(pred: PredicateFn) -> PredicateFn:
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 3225790..0597c87 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -32,6 +32,7 @@ from dataspec.base import (
     ErrorDetails,
     Invalid,
     ObjectSpec,
+    ObjectSpecKey,
     OptionalKey,
     PredicateSpec,
     Spec,
@@ -39,137 +40,13 @@ from dataspec.base import (
     Tag,
     ValidatorFn,
     ValidatorSpec,
+    any_spec,
     compose_conformers,
-    compose_spec_conformers,
     make_spec,
     pred_to_validator,
+    tag_maybe,
 )
 
-T = TypeVar("T")
-
-
-def tag_maybe(
-    maybe_tag: Union[Tag, T], *args: T
-) -> Tuple[Optional[str], Tuple[T, ...]]:
-    """Return the Spec tag and the remaining arguments if a tag is given, else return
-    the arguments."""
-    tag = maybe_tag if isinstance(maybe_tag, str) else None
-    return tag, (cast("Tuple[T, ...]", (maybe_tag, *args)) if tag is None else args)
-
-
-def any_spec(
-    *preds: SpecPredicate,
-    tag_conformed: bool = False,
-    conformer: Optional[Conformer] = None,
-) -> Spec:
-    """
-    Return a Spec which validates input values against any one of an arbitrary
-    number of input Specs.
-
-    The returned Spec validates input values against the input Specs in the order
-    they are passed into this function.
-
-    If the returned Spec fails to validate the input value, the
-    :py:meth:`dataspec.Spec.validate` method will emit a stream of
-    :py:class:`dataspec.ErrorDetails` from all of failing constituent Specs. If any of
-    the constituent Specs successfully validates the input value, then no
-    :py:class:`dataspec.ErrorDetails` will be emitted by the
-    :py:meth:`dataspec.Spec.validate` method.
-
-    The conformer for the returned Spec will select the conformer for the first
-    contituent Spec which successfully validates the input value. If a ``conformer``
-    is specified for this Spec, that conformer will be applied after the successful
-    Spec's conformer. If ``tag_conformed`` is specified, the final conformed value
-    from both conformers will be wrapped in a tuple, where the first element is the
-    tag of the successful Spec and the second element is the final conformed value.
-    If ``tag_conformed`` is not specified (which is the default), the conformer will
-    emit the conformed value directly.
-
-    :param tag: an optional tag for the resulting spec
-    :param preds: one or more Specs or values which can be converted into a Spec
-    :param tag_conformed: if :py:obj:`True`, the conformed value will be wrapped in a
-        2-tuple where the first element is the successful spec and the second element
-        is the conformed value; if :py:obj:`False`, return only the conformed value
-    :param conformer: an optional conformer for the value
-    :return: a Spec
-    """
-    tag, preds = tag_maybe(*preds)  # pylint: disable=no-value-for-parameter
-    specs = [make_spec(pred) for pred in preds]
-
-    def _any_valid(e) -> Iterator[ErrorDetails]:
-        errors = []
-        for spec in specs:
-            spec_errors = list(spec.validate(e))
-            if spec_errors:
-                errors.extend(spec_errors)
-            else:
-                return
-
-        yield from errors
-
-    def _conform_any(e):
-        for spec in specs:
-            spec_errors = list(spec.validate(e))
-            if spec_errors:
-                continue
-
-            conformed = spec.conform_valid(e)
-            assert conformed is not INVALID
-            if conformer is not None:
-                conformed = conformer(conformed)
-            if tag_conformed:
-                conformed = (spec.tag, conformed)
-            return conformed
-
-        return INVALID
-
-    return ValidatorSpec(tag or "any", _any_valid, conformer=_conform_any)
-
-
-def all_spec(*preds: SpecPredicate, conformer: Optional[Conformer] = None) -> Spec:
-    """
-    Return a Spec which validates input values against all of the input Specs or
-    spec predicates.
-
-    For each Spec for which the input value is successfully validated, the value is
-    successively passed to the Spec's :py:meth:`dataspec.Spec.conform_valid` method.
-
-    The returned Spec's :py:meth:`dataspec.Spec.validate` method will emit a stream
-    of :py:class:`dataspec.ErrorDetails`` from the first failing constituent Spec.
-    :py:class:`dataspec.ErrorDetails` emitted from Specs after a failing Spec will
-    not be emitted, because the failing Spec's :py:meth:`dataspec.Spec.conform``
-    would not successfully conform the value.
-
-    The returned Spec's :py:meth:`dataspec.Spec.conform` method is the composition
-    of all of the input Spec's ``conform`` methods.
-
-    :param tag: an optional tag for the resulting spec
-    :param preds: one or more Specs or values which can be converted into a Spec
-    :param conformer: an optional conformer which will be applied to the final
-        conformed value produced by the input Specs conformers
-    :return: a Spec
-    """
-    tag, preds = tag_maybe(*preds)  # pylint: disable=no-value-for-parameter
-    specs = [make_spec(pred) for pred in preds]
-
-    def _all_valid(e) -> Iterator[ErrorDetails]:
-        """Validate e against successive conformations to spec in specs."""
-
-        for spec in specs:
-            errors = []
-            for error in spec.validate(e):
-                errors.append(error)
-            if errors:
-                yield from errors
-                return
-            e = spec.conform_valid(e)
-
-    return ValidatorSpec(
-        tag or "all",
-        _all_valid,
-        conformer=compose_spec_conformers(*specs, conform_final=conformer),
-    )
-
 
 def blankable_spec(
     *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
@@ -379,8 +256,8 @@ def default_spec(
     tag, preds = tag_maybe(*args)  # pylint: disable=no-value-for-parameter
     assert len(preds) == 1, "Only one predicate allowed"
     return any_spec(
-        tag or "default",  # type: ignore
-        preds[0],  # type: ignore
+        tag or "default",
+        preds[0],  # type: ignore[arg-type]  # noqa: F821
         every_spec(conformer=lambda _: default),
         conformer=conformer,
     )
@@ -1016,11 +893,14 @@ def obj_spec(
     assert len(preds) == 1, "Only one predicate allowed"
     return ObjectSpec.from_val(
         tag or "object",
-        cast(Mapping[str, SpecPredicate], preds[0]),
+        cast(Mapping[ObjectSpecKey, SpecPredicate], preds[0]),
         conformer=conformer,
     )
 
 
+T = TypeVar("T")
+
+
 def opt_key(k: T) -> OptionalKey:
     """Return ``k`` wrapped in a marker object indicating that the key is optional in
     associative specs."""


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index 5acc4f5..d1d9d34 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -1,6 +1,7 @@
 import random
 import re
 import sys
+import uuid
 from datetime import date
 from enum import Enum
 from typing import Optional, Type
@@ -248,6 +249,14 @@ class TestCollSpecConformation:
 
 
 class TestDictSpecValidation:
+    @pytest.mark.parametrize(
+        "pred", [{"id": str, s.opt("id"): int}, {s.opt("id"): int, "id": str},]
+    )
+    def test_dict_spec_definition(self, pred):
+        """Duplicate keys are prohibited between required and optional keys."""
+        with pytest.raises(KeyError):
+            s(pred)
+
     @pytest.fixture
     def dict_spec(self) -> Spec:
         return s(
@@ -411,6 +420,13 @@ class TestDictSpecConformation:
 
 
 class TestObjectSpecValidation:
+    @pytest.mark.parametrize(
+        "pred", [{"id": str, s.opt("id"): int}, {s.opt("id"): int, "id": str},]
+    )
+    def test_obj_spec_definition(self, pred):
+        with pytest.raises(KeyError):
+            s.obj(pred)
+
     @attr.s(auto_attribs=True, frozen=True, slots=True)
     class Person:
         id: str
@@ -686,6 +702,298 @@ class TestTupleSpecConformation:
         assert 29 == conformed.age == conformed[2]
 
 
+class TestAllSpecConstruction:
+    def test_all_spec_must_have_pred(self):
+        with pytest.raises(TypeError):
+            s.all()
+
+        with pytest.raises(ValueError):
+            s.all("with_tag")
+
+    def test_passthrough_spec(self):
+        assert s.all(str).is_valid("something")
+        assert s.all("with_tag", str).is_valid("something")
+
+
+class TestAllSpecValidation:
+    @pytest.fixture
+    def all_spec(self) -> Spec:
+        return s.all(
+            s.str(format_="uuid", conformer=uuid.UUID), lambda id_: id_.version == 4
+        )
+
+    @pytest.mark.parametrize(
+        "v",
+        [
+            "c5a28680-986f-4f0d-8187-80d1fbe22059",
+            "3BE59FF6-9C75-4027-B132-C9792D84547D",
+        ],
+    )
+    def test_all_validation(self, all_spec, v):
+        assert all_spec.is_valid(v)
+        assert uuid.UUID(v) == all_spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v",
+        [
+            "6281d852-ef4d-11e9-9002-4c327592fea9",
+            "0e8d7ceb-56e8-36d2-9b54-ea48d4bdea3f",
+            "10988ff4-136c-5ca7-ab35-a686a56c22c4",
+            "",
+            "5",
+            "abcde",
+            "ABCDe",
+            5,
+            3.14,
+            None,
+            {},
+            set(),
+            [],
+        ],
+    )
+    def test_all_failure(self, all_spec, v):
+        assert not all_spec.is_valid(v)
+        assert list(all_spec.validate(v))
+        assert INVALID is all_spec.conform(v)
+
+
+class TestAllSpecConformation:
+    class YesNo(Enum):
+        YES = "Yes"
+        NO = "No"
+
+    @pytest.fixture
+    def all_spec(self) -> Spec:
+        return s.all(s.str(conformer=str.title), self.YesNo)
+
+    @pytest.mark.parametrize(
+        "v,expected",
+        [
+            ("yes", YesNo.YES),
+            ("Yes", YesNo.YES),
+            ("yES", YesNo.YES),
+            ("YES", YesNo.YES),
+            ("no", YesNo.NO),
+            ("No", YesNo.NO),
+            ("nO", YesNo.NO),
+            ("NO", YesNo.NO),
+        ],
+    )
+    def test_all_spec_conformation(self, all_spec: Spec, v, expected):
+        assert expected == all_spec.conform(v)
+
+
+class TestAnySpecConstruction:
+    def test_any_spec_must_have_pred(self):
+        with pytest.raises(TypeError):
+            s.any()
+
+        with pytest.raises(ValueError):
+            s.any("with_tag")
+
+    def test_passthrough_spec(self):
+        assert s.any(str).is_valid("something")
+        assert s.any("with_tag", str).is_valid("something")
+
+
+class TestAnySpecValidation:
+    @pytest.fixture
+    def any_spec(self) -> Spec:
+        return s.any(s.is_num, s.is_str)
+
+    @pytest.mark.parametrize("v", ["5", 5, 3.14])
+    def test_any_validation(self, any_spec: Spec, v):
+        assert any_spec.is_valid(v)
+
+    @pytest.mark.parametrize("v", [None, {}, set(), []])
+    def test_any_validation_failure(self, any_spec: Spec, v):
+        assert not any_spec.is_valid(v)
+
+
+class TestAnySpecConformation:
+    @pytest.fixture
+    def spec(self) -> Spec:
+        return s.any(s.num("num"), s.str("numstr", regex=r"\d+", conformer=int))
+
+    @pytest.mark.parametrize("expected,v", [(5, "5"), (5, 5), (3.14, 3.14), (-10, -10)])
+    def test_conformation(self, spec: Spec, expected, v):
+        assert expected == spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
+    )
+    def test_conformation_failure(self, spec: Spec, v):
+        assert INVALID is spec.conform(v)
+        assert INVALID is spec.conform_valid(v)
+
+    @pytest.fixture
+    def tag_spec(self) -> Spec:
+        return s.any(
+            s.num("num"),
+            s.str("numstr", regex=r"\d+", conformer=int),
+            tag_conformed=True,
+        )
+
+    @pytest.mark.parametrize(
+        "expected,v",
+        [
+            (("numstr", 5), "5"),
+            (("num", 5), 5),
+            (("num", 3.14), 3.14),
+            (("num", -10), -10),
+        ],
+    )
+    def test_tagged_conformation(self, tag_spec: Spec, expected, v):
+        assert expected == tag_spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
+    )
+    def test_tagged_conformation_failure(self, tag_spec: Spec, v):
+        assert INVALID is tag_spec.conform(v)
+        assert INVALID is tag_spec.conform_valid(v)
+
+
+class TestAnySpecWithOuterConformation:
+    @pytest.fixture
+    def spec(self) -> Spec:
+        return s.any(
+            s.num("num"),
+            s.str("numstr", regex=r"\d+", conformer=int),
+            conformer=lambda v: v + 5,
+        )
+
+    @pytest.mark.parametrize(
+        "expected,v", [(10, "5"), (10, 5), (8.14, 3.14), (-5, -10)]
+    )
+    def test_conformation(self, spec: Spec, expected, v):
+        assert expected == spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
+    )
+    def test_conformation_failure(self, spec: Spec, v):
+        assert INVALID is spec.conform(v)
+        assert INVALID is spec.conform_valid(v)
+
+    @pytest.fixture
+    def tag_spec(self) -> Spec:
+        return s.any(
+            s.num("num"),
+            s.str("numstr", regex=r"\d+", conformer=int),
+            tag_conformed=True,
+            conformer=lambda v: v + 5,
+        )
+
+    @pytest.mark.parametrize(
+        "expected,v",
+        [
+            (("numstr", 10), "5"),
+            (("num", 10), 5),
+            (("num", 8.14), 3.14),
+            (("num", -5), -10),
+        ],
+    )
+    def test_tagged_conformation(self, tag_spec: Spec, expected, v):
+        assert expected == tag_spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
+    )
+    def test_tagged_conformation_failure(self, tag_spec: Spec, v):
+        assert INVALID is tag_spec.conform(v)
+        assert INVALID is tag_spec.conform_valid(v)
+
+
+class TestMergeSpecConstruction:
+    def test_merge_spec_must_have_pred(self):
+        with pytest.raises(TypeError):
+            s.merge()
+
+        with pytest.raises(ValueError):
+            s.merge("with_tag")
+
+    def test_passthrough_spec(self):
+        assert s.merge(str).is_valid("something")
+        assert s.merge("with_tag", str).is_valid("something")
+
+    def test_must_all_be_mapping_specs(self):
+        with pytest.raises(TypeError):
+            s.merge({"id": int}, s.str("name"))
+
+        with pytest.raises(TypeError):
+            s.merge("with_tag", s.num(min_=0), {"name": str})
+
+    def test_allow_overlapping_required_and_optional(self):
+        s.merge(
+            {"id": int, s.opt("name"): str},
+            {"id": lambda v: v > 0, s.opt("name"): lambda s: s},
+        )
+
+    def test_no_overlapping_required_and_optional(self):
+        with pytest.raises(KeyError):
+            s.merge(
+                {"id": int, "name": str}, {"id": lambda v: v > 0, s.opt("name"): str}
+            )
+
+
+class TestMergeSpecValidation:
+    @pytest.fixture
+    def merge_spec(self) -> Spec:
+        return s.merge(
+            {"id": int},
+            {
+                "id": lambda v: v > 0,
+                "first_name": str,
+                s.opt("middle_initial"): str,
+                "last_name": str,
+            },
+        )
+
+    @pytest.mark.parametrize(
+        "v",
+        [
+            {"id": 5, "first_name": "Samwise", "last_name": "Gamgee"},
+            {
+                "id": 500,
+                "first_name": "Samwise",
+                "middle_initial": "H",
+                "last_name": "Gamgee",
+            },
+        ],
+    )
+    def test_merge_validation(self, merge_spec, v):
+        assert merge_spec.is_valid(v)
+
+    @pytest.mark.parametrize(
+        "v",
+        [
+            "",
+            "5",
+            "abcde",
+            "ABCDe",
+            5,
+            3.14,
+            None,
+            {},
+            set(),
+            [],
+            {"id": -5, "first_name": "Samwise", "last_name": "Gamgee"},
+            {"id": 500, "middle_initial": "H", "last_name": "Gamgee"},
+            {
+                "id": 500,
+                "first_name": "Samwise",
+                "middle_initial": None,
+                "last_name": "Gamgee",
+            },
+        ],
+    )
+    def test_merge_failure(self, merge_spec, v):
+        assert not merge_spec.is_valid(v)
+        assert list(merge_spec.validate(v))
+        assert INVALID is merge_spec.conform(v)
+
+
 class TestTypeSpec:
     @pytest.mark.parametrize(
         "tp,vals",
diff --git a/tests/test_factories.py b/tests/test_factories.py
index ff211e2..6bad5b9 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -2,7 +2,6 @@ import re
 import sys
 import uuid
 from datetime import date, datetime, time, timezone
-from enum import Enum
 
 import pytest
 
@@ -20,182 +19,6 @@ except ImportError:
     phonenumbers = None
 
 
-class TestAllSpecValidation:
-    @pytest.fixture
-    def all_spec(self) -> Spec:
-        return s.all(
-            s.str(format_="uuid", conformer=uuid.UUID), lambda id_: id_.version == 4
-        )
-
-    @pytest.mark.parametrize(
-        "v",
-        [
-            "c5a28680-986f-4f0d-8187-80d1fbe22059",
-            "3BE59FF6-9C75-4027-B132-C9792D84547D",
-        ],
-    )
-    def test_all_validation(self, all_spec, v):
-        assert all_spec.is_valid(v)
-        assert uuid.UUID(v) == all_spec.conform(v)
-
-    @pytest.mark.parametrize(
-        "v",
-        [
-            "6281d852-ef4d-11e9-9002-4c327592fea9",
-            "0e8d7ceb-56e8-36d2-9b54-ea48d4bdea3f",
-            "10988ff4-136c-5ca7-ab35-a686a56c22c4",
-            "",
-            "5",
-            "abcde",
-            "ABCDe",
-            5,
-            3.14,
-            None,
-            {},
-            set(),
-            [],
-        ],
-    )
-    def test_all_failure(self, all_spec, v):
-        assert not all_spec.is_valid(v)
-        assert INVALID is all_spec.conform(v)
-
-
-class TestAllSpecConformation:
-    class YesNo(Enum):
-        YES = "Yes"
-        NO = "No"
-
-    @pytest.fixture
-    def all_spec(self) -> Spec:
-        return s.all(s.str(conformer=str.title), self.YesNo)
-
-    @pytest.mark.parametrize(
-        "v,expected",
-        [
-            ("yes", YesNo.YES),
-            ("Yes", YesNo.YES),
-            ("yES", YesNo.YES),
-            ("YES", YesNo.YES),
-            ("no", YesNo.NO),
-            ("No", YesNo.NO),
-            ("nO", YesNo.NO),
-            ("NO", YesNo.NO),
-        ],
-    )
-    def test_all_spec_conformation(self, all_spec: Spec, v, expected):
-        assert expected == all_spec.conform(v)
-
-
-class TestAnySpecValidation:
-    @pytest.fixture
-    def any_spec(self) -> Spec:
-        return s.any(s.is_num, s.is_str)
-
-    @pytest.mark.parametrize("v", ["5", 5, 3.14])
-    def test_any_validation(self, any_spec: Spec, v):
-        assert any_spec.is_valid(v)
-
-    @pytest.mark.parametrize("v", [None, {}, set(), []])
-    def test_any_validation_failure(self, any_spec: Spec, v):
-        assert not any_spec.is_valid(v)
-
-
-class TestAnySpecConformation:
-    @pytest.fixture
-    def spec(self) -> Spec:
-        return s.any(s.num("num"), s.str("numstr", regex=r"\d+", conformer=int))
-
-    @pytest.mark.parametrize("expected,v", [(5, "5"), (5, 5), (3.14, 3.14), (-10, -10)])
-    def test_conformation(self, spec: Spec, expected, v):
-        assert expected == spec.conform(v)
-
-    @pytest.mark.parametrize(
-        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
-    )
-    def test_conformation_failure(self, spec: Spec, v):
-        assert INVALID is spec.conform(v)
-        assert INVALID is spec.conform_valid(v)
-
-    @pytest.fixture
-    def tag_spec(self) -> Spec:
-        return s.any(
-            s.num("num"),
-            s.str("numstr", regex=r"\d+", conformer=int),
-            tag_conformed=True,
-        )
-
-    @pytest.mark.parametrize(
-        "expected,v",
-        [
-            (("numstr", 5), "5"),
-            (("num", 5), 5),
-            (("num", 3.14), 3.14),
-            (("num", -10), -10),
-        ],
-    )
-    def test_tagged_conformation(self, tag_spec: Spec, expected, v):
-        assert expected == tag_spec.conform(v)
-
-    @pytest.mark.parametrize(
-        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
-    )
-    def test_tagged_conformation_failure(self, tag_spec: Spec, v):
-        assert INVALID is tag_spec.conform(v)
-        assert INVALID is tag_spec.conform_valid(v)
-
-
-class TestAnySpecWithOuterConformation:
-    @pytest.fixture
-    def spec(self) -> Spec:
-        return s.any(
-            s.num("num"),
-            s.str("numstr", regex=r"\d+", conformer=int),
-            conformer=lambda v: v + 5,
-        )
-
-    @pytest.mark.parametrize(
-        "expected,v", [(10, "5"), (10, 5), (8.14, 3.14), (-5, -10)]
-    )
-    def test_conformation(self, spec: Spec, expected, v):
-        assert expected == spec.conform(v)
-
-    @pytest.mark.parametrize(
-        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
-    )
-    def test_conformation_failure(self, spec: Spec, v):
-        assert INVALID is spec.conform(v)
-        assert INVALID is spec.conform_valid(v)
-
-    @pytest.fixture
-    def tag_spec(self) -> Spec:
-        return s.any(
-            s.num("num"),
-            s.str("numstr", regex=r"\d+", conformer=int),
-            tag_conformed=True,
-            conformer=lambda v: v + 5,
-        )
-
-    @pytest.mark.parametrize(
-        "expected,v",
-        [
-            (("numstr", 10), "5"),
-            (("num", 10), 5),
-            (("num", 8.14), 3.14),
-            (("num", -5), -10),
-        ],
-    )
-    def test_tagged_conformation(self, tag_spec: Spec, expected, v):
-        assert expected == tag_spec.conform(v)
-
-    @pytest.mark.parametrize(
-        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
-    )
-    def test_tagged_conformation_failure(self, tag_spec: Spec, v):
-        assert INVALID is tag_spec.conform(v)
-        assert INVALID is tag_spec.conform_valid(v)
-
-
 @pytest.mark.parametrize(
     "v", [None, 25, 3.14, 3j, [], set(), frozenset(), (), "abcdef"]
 )