Repository: DisnakeCommunity/disnake-ext-components (language: python)

Issue:
component matching to callbacks
## Description

A way to assign a callback to always listen for components matching certain attributes.

eg, a button the button colour and custom_id, and the fact that it *is* a button

```py
@components.match_component(disnake.Button(custom_id="button!", color="..."))
```
```py
@components.match_component(component_type=disnake.ComponentType.button, custom_id="...")
```

This effectively allows a button callback. 

To make this even nicer, using the decorated function could have a method or function attached that creates a component with the specified parameters, as that would allow sending the created and matching button or select quite easily.


Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
index e1675c9..eeb9251 100644
--- a/.flake8
+++ b/.flake8
@@ -6,8 +6,9 @@ max-line-length = 110
 
 per-file-ignores =
     **/__init__.py: F401, F403
-    # Give examples the space they need
+    # Give examples and tests the space they need
     examples/*.py: E501
+    tests/*.py: E501
 
 accept-encodings = utf-8
 docstring-convention = numpy
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index d8c0081..e048841 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,5 +6,6 @@ __pycache__
 test.py
 *.ipynb
 
+tests/_*.py
 coverage.*
 .coverage
\ No newline at end of file
diff --git a/disnake_ext_components/abc.py b/disnake_ext_components/abc.py
index 7960be9..f51a9b8 100644
--- a/disnake_ext_components/abc.py
+++ b/disnake_ext_components/abc.py
@@ -1,10 +1,11 @@
 import abc
+import asyncio.coroutines
 import sys
 import typing as t
 
-import disnake
+from disnake.ext import commands
 
-from . import params, types_
+from . import params, types_, utils
 
 if sys.version_info >= (3, 10):
     from typing import ParamSpec
@@ -16,18 +17,34 @@
 T = t.TypeVar("T")
 ParentT = t.TypeVar("ParentT")
 P = ParamSpec("P")
-InteractionT = t.TypeVar("InteractionT", bound=disnake.Interaction)
 
 ListenerT = t.TypeVar("ListenerT", bound="BaseListener[t.Any, t.Any, t.Any]")
 
 
-class BaseListener(types_.partial[t.Awaitable[T]], abc.ABC, t.Generic[P, T, InteractionT]):
+class BaseListener(abc.ABC, t.Generic[P, T, types_.InteractionT]):
 
-    # These are just to conform to dpy listener spec
-    __name__: str
+    # Make asyncio.iscoroutinefunction believe this is a coroutine function...
+    _is_coroutine = asyncio.coroutines._is_coroutine  # type: ignore
+
+    # These are just to conform to dpy listener spec...
     __cog_listener__: t.Final[t.Literal[True]] = True
     __cog_listener_names__: t.List[types_.ListenerType]
 
+    parent: t.Optional[t.Any]
+    """The class on which this listener is defined, if any.
+    Used to set the `self` parameter on the listener.
+    """
+
+    callback: t.Callable[..., types_.Coro[T]]
+    """The callback function wrapped by this listener."""
+
+    name: t.Optional[str]
+    """The name is used to determine the custom id spec for the listener.
+    This can be customized in `~.__init__`. For most listeners, the name will equal the name of
+    the bound callback function.
+    If no name is provided, custom_id name validation will will be skipped.
+    """
+
     id_spec: str
     """The spec that inbound `custom_id`s should match. Also used to create new custom ids; see
     `~.build_custom_id`.
@@ -49,26 +66,53 @@ class BaseListener(types_.partial[t.Awaitable[T]], abc.ABC, t.Generic[P, T, Inte
     about their regex pattern(s) and converter(s).
     """
 
-    def __new__(
-        cls: t.Type[ListenerT],
-        func: t.Callable[..., t.Awaitable[T]],
-        **kwargs: t.Any,
-    ) -> ListenerT:
-        self = super().__new__(cls, func)
-        self.__name__ = func.__name__
-        return self
+    checks: t.List[types_.CheckCallback[types_.InteractionT]]
+    """Check functions that are called when the listener is invoked. All of these must pass for
+    the listener invocation to complete.
+    """
 
-    def __get__(self: ListenerT, instance: t.Any, _) -> ListenerT:
+    def __init__(
+        self,
+        callback: t.Callable[..., types_.Coro[T]],
+        *,
+        name: t.Optional[str] = None,
+        regex: t.Union[str, t.Pattern[str], None] = None,
+        sep: str = ":",
+    ) -> None:
+        self.checks = []
+        self.parent = None
+
+        self.callback = callback
+        self.name = name
+        self.__name__ = callback.__name__
+        self._signature = commands.params.signature(callback)  # type: ignore
+
+        if regex:
+            self.regex = utils.ensure_compiled(regex)
+            self.id_spec = utils.id_spec_from_regex(self.regex)
+            self.sep = None
+
+        else:
+            self.regex = None
+            self.id_spec = utils.id_spec_from_signature(self.name or "", sep, self._signature)
+            self.sep = sep
+
+    def __get__(self: ListenerT, instance: t.Optional[t.Any], _) -> ListenerT:
         """Abuse descriptor functionality to inject instance of the owner class as first arg."""
         # Inject instance of the owner class as the partial's first arg.
         # If need be, we could add support for classmethods by checking the
         # type of self.func and injecting the owner class instead where appropriate.
-        self.__setstate__((self.func, (instance,), {}, self.__dict__))  # type: ignore
+        self.parent = instance
         return self
 
+    async def __call__(self, *args: t.Any, **kwargs: t.Any) -> T:
+        if self.parent:
+            return await self.callback(self.parent, *args, **kwargs)
+        return await self.callback(*args, **kwargs)
+
     def error(
-        self, func: t.Callable[[ParentT, InteractionT, Exception], t.Any]
-    ) -> t.Callable[[ParentT, InteractionT, Exception], t.Any]:
+        self, func: t.Callable[[ParentT, types_.InteractionT, Exception], t.Any]
+    ) -> t.Callable[[ParentT, types_.InteractionT, Exception], t.Any]:
         """Register an error handler for this listener.
         Note: Not yet implemented.
         """
@@ -100,7 +144,9 @@ def parse_custom_id(self, custom_id: str) -> t.Tuple[str, ...]:
             return tuple(params.values())
 
         name, *params = custom_id.split(self.sep)
-        if name != self.__name__ or len(params) != len(self.params):
+        # If no name is set, skip name check. Otherwise, assure stored and provided name are equal.
+        # Also confirm the number of incoming params matches the number of params on the listener.
+        if (self.name and name != self.name) or (len(params) != len(self.params)):
             raise ValueError(f"Listener spec {self.id_spec} did not match custom_id {custom_id}.")
 
         return tuple(params)
@@ -110,7 +156,8 @@ async def build_custom_id(self, *args: P.args, **kwargs: P.kwargs) -> str:
         the values entered are valid according to the listener's typehints, the custom_id is
         guaranteed to be matched by the listener.
 
-        Note: No actual validation is done on the values entered.
+        Note: No actual validation is done on the values entered, though they are converted where
+        possible.
 
         Parameters
         ----------
@@ -139,11 +186,35 @@ async def build_custom_id(self, *args: P.args, **kwargs: P.kwargs) -> str:
 
             kwargs.update(args_as_kwargs)  # This is safe as we ensured there is no overlap.
 
-        # "Serialize" types to str...
-        deserialized_kwargs = {
-            param.name: await param.to_str(kwargs[param.name]) for param in self.params
+        # "Serialize" types to strings; empty string for None (optional)...
+        serialized_kwargs = {
+            param.name: "" if kwargs[param.name] is None else await param.to_str(kwargs[param.name])
+            for param in self.params
         }
 
         if self.regex:
-            return self.id_spec.format(**deserialized_kwargs)
-        return self.id_spec.format(sep=self.sep, **deserialized_kwargs)
+            custom_id = self.id_spec.format(**serialized_kwargs)
+        custom_id = self.id_spec.format(sep=self.sep, **serialized_kwargs)
+
+        if not custom_id:  # Fallback in case the listener has neither a name nor params.
+            return self.__name__
+        return custom_id
+
+    def add_check(self, callback: types_.CheckT) -> types_.CheckT:
+        """Add a check to the listener. Like `commands.check` checks, these checks must
+        take an interaction as their sole parameter and must return a boolean. Checks may
+        be coroutines, though this is not required. Checks are run when the listener is
+        called by an interaction event, and are bypassed when the listener is called manually.
+        All checks must pass for the interaction to go through and fire the listener callback.
+
+        Parameters
+        ----------
+        check: t.Callable[[:class:`disnake.Interaction`], MaybeCoro[:class:`bool`]]
+            The check to be added.
+
+        Returns:
+        t.Callable[[:class:`disnake.Interaction`], MaybeCoro[:class:`bool`]]
+            The callback of the check is returned unedited such that it can be used elsewhere.
+        """
+        self.checks.append(callback)
+        return callback
diff --git a/disnake_ext_components/converter.py b/disnake_ext_components/converter.py
index 32d36e0..255f1e7 100644
--- a/disnake_ext_components/converter.py
+++ b/disnake_ext_components/converter.py
@@ -6,12 +6,14 @@
 import disnake
 from disnake.ext import commands
 
+from . import types_
+
 __all__ = ["ALLOW_CONVERTER_FETCHING", "CONVERTER_MAP"]
 
 
 CollectionT = t.TypeVar("CollectionT", bound=t.Collection[t.Any])
 ConverterSig = t.Union[
-    t.Callable[..., t.Awaitable[t.Any]],
+    t.Callable[..., types_.Coro[t.Any]],
     t.Callable[..., t.Any],
 ]
 ChannelT = t.TypeVar("ChannelT", disnake.abc.GuildChannel, disnake.Thread)
@@ -39,7 +41,7 @@ class ALLOW_CONVERTER_FETCHING:  # There's probably a better way of doing this..
 def collection_converter(
     collection_type: t.Type[CollectionT],
     inner_converter: ConverterSig,
-) -> t.Callable[[t.Collection[str], disnake.Interaction, t.List[t.Any]], t.Awaitable[CollectionT]]:
+) -> t.Callable[[t.Collection[str], disnake.Interaction, t.List[t.Any]], types_.Coro[CollectionT]]:
     """Create a converter for a given collection type."""
 
     async def _convert_collection(
@@ -60,7 +62,7 @@ async def _convert_collection(
     return _convert_collection
 
 
-def make_channel_converter(type_: t.Type[ChannelT]) -> t.Callable[..., t.Awaitable[ChannelT]]:
+def make_channel_converter(type_: t.Type[ChannelT]) -> t.Callable[..., types_.Coro[ChannelT]]:
     """Create a channel converter for a given channel type."""
 
     async def _convert_channel(argument: str, inter: disnake.Interaction) -> ChannelT:
@@ -289,7 +291,7 @@ def make_flag_converter(type_: t.Type[FlagT]) -> t.Callable[..., FlagT]:
     """Create a flag converter for a given flag type."""
 
     def _convert_flag(argument: str, inter: disnake.Interaction) -> FlagT:
-        return type_._from_value(int(argument))
+        return type_._from_value(int(argument))  # pyright: ignore[reportUnknownMemberType]
 
     return _convert_flag
 
diff --git a/disnake_ext_components/deprecation.py b/disnake_ext_components/deprecation.py
new file mode 100644
index 0000000..13d3ab1
--- /dev/null
+++ b/disnake_ext_components/deprecation.py
@@ -0,0 +1,52 @@
+# Thanks to genshin.py for this
+
+import functools
+import inspect
+import typing
+import warnings
+
+__all__ = ["deprecated", "warn_deprecated"]
+
+CallableT = typing.TypeVar("CallableT", bound=typing.Callable[..., typing.Any])
+
+
+def warn_deprecated(
+    obj: typing.Any,
+    *,
+    alternative: typing.Optional[str] = None,
+    stack_level: int = 3,
+) -> None:
+    """Raise a deprecation warning."""
+    if inspect.isclass(obj) or inspect.isfunction(obj):
+        obj = f"{obj.__qualname__}"
+
+    message = f"{obj} is deprecated and will be removed in the following version."
+
+    if alternative is not None:
+        message += f" You can use '{alternative}' instead."
+
+    warnings.warn(message, category=DeprecationWarning, stacklevel=stack_level)
+
+
+def deprecated(alternative: typing.Optional[str] = None) -> typing.Callable[[CallableT], CallableT]:
+    """Mark a function as deprecated."""
+
+    def decorator(obj: CallableT) -> CallableT:
+        alternative_str = f"You can use `{alternative}` instead.\n" if alternative else ""
+
+        doc = (
+            "!!! warning\n"
+            f"    This function is deprecated and will be removed in an upcoming version.\n"
+            f"    {alternative_str}"
+            "\n\n"
+        ) + (inspect.getdoc(obj) or "")
+        obj.__doc__ = doc
+
+        @functools.wraps(obj)
+        def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+            warn_deprecated(obj, alternative=alternative, stack_level=3)
+            return obj(*args, **kwargs)
+
+        return typing.cast("CallableT", wrapper)
+
+    return decorator
diff --git a/disnake_ext_components/listener.py b/disnake_ext_components/listener.py
index 7d4dd25..adb68e8 100644
--- a/disnake_ext_components/listener.py
+++ b/disnake_ext_components/listener.py
@@ -1,12 +1,13 @@
 from __future__ import annotations
 
+import inspect
 import sys
 import typing as t
 
 import disnake
 from disnake.ext import commands
 
-from . import abc, params, types_, utils
+from . import abc, deprecation, params, types_, utils
 
 __all__ = [
     "button_listener",
@@ -15,6 +16,7 @@
     "SelectListener",
     "modal_listener",
     "ModalListener",
+    "match_component",
 ]
 
 
@@ -32,37 +34,54 @@
 
 ListenerT = t.TypeVar("ListenerT", bound="abc.BaseListener[t.Any, t.Any, t.Any]")
 
-InteractionT = t.TypeVar("InteractionT", disnake.MessageInteraction, disnake.ModalInteraction)
-ErrorHandlerT = t.Callable[[ParentT, InteractionT, Exception], t.Any]
+ComponentListener = t.Union[
+    "ButtonListener[t.Any, t.Any]",
+    "SelectListener[t.Any, t.Any]",
+]
+
+ButtonReference = t.Union[
+    disnake.Button,
+    disnake.ui.Button[t.Any],
+    types_.AbstractComponent,
+]
+SelectReference = t.Union[
+    disnake.SelectMenu,
+    disnake.ui.Select[t.Any],
+    types_.AbstractComponent,
+]
 
-# TODO: Make this more compact.
 
+# fmt: off
 ButtonListenerCallback = t.Union[
-    t.Callable[Concatenate[ParentT, disnake.MessageInteraction, P], t.Awaitable[T]],
-    t.Callable[Concatenate[disnake.MessageInteraction, P], t.Awaitable[T]],
+    t.Callable[Concatenate[ParentT, disnake.MessageInteraction, P], types_.Coro[T]],
+    t.Callable[Concatenate[disnake.MessageInteraction, P], types_.Coro[T]],
 ]
 
 SelectListenerCallback = t.Union[
-    t.Callable[Concatenate[ParentT, disnake.MessageInteraction, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[disnake.MessageInteraction, t.Any, P], t.Awaitable[T]],
+    t.Callable[Concatenate[ParentT, disnake.MessageInteraction, P], types_.Coro[T]],
+    t.Callable[Concatenate[ParentT, disnake.MessageInteraction, t.Any, P], types_.Coro[T]],
+
+    t.Callable[Concatenate[disnake.MessageInteraction, P], types_.Coro[T]],
+    t.Callable[Concatenate[disnake.MessageInteraction, t.Any, P], types_.Coro[T]],
 ]
 
-# flake8: noqa: E241
+# flake8: noqa: E501
 ModalListenerCallback = t.Union[
-    # fmt: off
-    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, t.Any, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, t.Any, t.Any, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, t.Any, t.Any, t.Any, t.Any, P], t.Awaitable[T]],
-
-    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, t.Any, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, t.Any, t.Any, t.Any, P], t.Awaitable[T]],
-    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, t.Any, t.Any, t.Any, t.Any, P], t.Awaitable[T]],
-    # fmt: on
+    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, P], types_.Coro[T]],
+    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, P], types_.Coro[T]],
+    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, t.Any, P], types_.Coro[T]],
+    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, t.Any, t.Any, P], types_.Coro[T]],
+    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, t.Any, t.Any, t.Any, P], types_.Coro[T]],
+    t.Callable[Concatenate[ParentT, disnake.ModalInteraction, t.Any, t.Any, t.Any, t.Any, t.Any, P], types_.Coro[T]],
+
+    t.Callable[Concatenate[disnake.ModalInteraction, P], types_.Coro[T]],
+    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, P], types_.Coro[T]],
+    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, t.Any, P], types_.Coro[T]],
+    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, t.Any, t.Any, P], types_.Coro[T]],
+    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, t.Any, t.Any, t.Any, P], types_.Coro[T]],
+    t.Callable[Concatenate[disnake.ModalInteraction, t.Any, t.Any, t.Any, t.Any, t.Any, P], types_.Coro[T]],
 ]
+# fmt: on
 
 
 class ButtonListener(abc.BaseListener[P, T, disnake.MessageInteraction]):
@@ -89,33 +108,25 @@ class ButtonListener(abc.BaseListener[P, T, disnake.MessageInteraction]):
         Under normal circumstances, this should not lead to conflicts. In case ':' is intentionally
         part of the `custom_id`s matched by the listener, this should be set to a different value
         to prevent conflicts.
+    reference: Optional[Union[:class:`disnake.Button`, :class:`disnake.ui.Button`, :class:`AbstractComponent`]
+        A reference component used to set default values in `~.build_component`.
     """
 
     __cog_listener_names__: t.List[types_.ListenerType] = [types_.ListenerType.BUTTON]
 
-    def __new__(
-        cls: t.Type[ListenerT],
-        func: ButtonListenerCallback[ParentT, P, T],
-        **kwargs: t.Any,
-    ) -> ListenerT:
-        return super().__new__(cls, func, **kwargs)
+    reference: types_.AbstractComponent
+    """A reference component used to set default values in `~.build_component`."""
 
     def __init__(
         self,
-        func: ButtonListenerCallback[ParentT, P, T],
+        callback: ButtonListenerCallback[ParentT, P, T],
         *,
+        name: t.Optional[str] = None,
         regex: t.Union[str, t.Pattern[str], None] = None,
         sep: str = ":",
+        reference: t.Optional[ButtonReference] = None,
     ) -> None:
-        self._signature = commands.params.signature(func)  # pyright: ignore
-        if regex:
-            self.regex = utils.ensure_compiled(regex)
-            self.id_spec = utils.id_spec_from_regex(self.regex)
-            self.sep = None
-        else:
-            self.regex = None
-            self.id_spec = utils.id_spec_from_signature(self.__name__, sep, self._signature)
-            self.sep = sep
+        super().__init__(callback, name=name, regex=regex, sep=sep)
 
         special_params, listener_params = utils.extract_listener_params(self._signature)
 
@@ -126,6 +137,19 @@ def __init__(
             )
 
         self.params = [params.ParamInfo.from_param(param) for param in listener_params]
+        self.reference = self._choose_optimal_reference(reference)
+
+    def _choose_optimal_reference(
+        self,
+        component: t.Optional[ButtonReference],
+    ) -> types_.AbstractComponent:
+        if component is not None:  # Manually provided takes highest priority
+            if isinstance(component, types_.AbstractComponent):
+                return component
+            return types_.AbstractComponent.from_component(component)
+
+        # Nothing of use was found, return an AbstractComponent that can match any button.
+        return types_.AbstractComponent(type=disnake.ComponentType.button)
 
     async def __call__(  # pyright: ignore
         self,
@@ -166,6 +190,9 @@ async def __call__(  # pyright: ignore
         except ValueError:
             return
 
+        if not await utils.assert_all_checks(self.checks, inter):
+            return
+
         converted: t.Dict[str, t.Any] = {}
         for param, arg in zip(self.params, custom_id_params):
             converted[param.name] = await param.convert(
@@ -177,6 +204,26 @@ async def __call__(  # pyright: ignore
 
         return await super().__call__(inter, **converted)
 
+    async def build_component(
+        self,
+        style: t.Optional[disnake.ButtonStyle] = None,
+        label: t.Optional[str] = None,
+        disabled: t.Optional[bool] = None,
+        url: t.Optional[str] = None,
+        emoji: t.Union[str, disnake.Emoji, disnake.PartialEmoji, None] = None,
+        *args: P.args,
+        **kwargs: P.kwargs,
+    ) -> disnake.ui.Button[t.Any]:
+        return self.reference.with_overrides(
+            style=style,
+            label=label,
+            disabled=disabled,
+            url=url,
+            emoji=emoji,
+            custom_id=await self.build_custom_id(*args, **kwargs),
+        ).as_component(disnake.ui.Button[t.Any])
+
+    @deprecation.deprecated("build_component")
     async def build_button(
         self,
         style: disnake.ButtonStyle = disnake.ButtonStyle.secondary,
@@ -187,21 +234,25 @@ async def build_button(
         *args: P.args,
         **kwargs: P.kwargs,
     ) -> disnake.ui.Button[t.Any]:
-        return disnake.ui.Button(
+        return await self.build_component(
             style=style,
             label=label,
             disabled=disabled,
-            custom_id=await self.build_custom_id(*args, **kwargs),
             url=url,
             emoji=emoji,
+            *args,
+            **kwargs,
         )
 
+    build_button.__doc__ = build_component.__doc__
+
 
 def button_listener(
     *,
     regex: t.Union[str, t.Pattern[str], None] = None,
     sep: str = ":",
     bot: t.Optional[commands.Bot] = None,
+    reference: t.Optional[ButtonReference] = None,
 ) -> t.Callable[[ButtonListenerCallback[ParentT, P, T]], ButtonListener[P, T]]:
     """Create a new :class:`ButtonListener` from a decorated function. The :class:`ButtonListener`
     will take care of regex-matching and persistent data stored in the `custom_id` of the
@@ -236,7 +287,7 @@ def button_listener(
     def wrapper(
         func: ButtonListenerCallback[ParentT, P, T],
     ) -> ButtonListener[P, T]:
-        listener = ButtonListener[P, T](func, regex=regex, sep=sep)
+        listener = ButtonListener[P, T](func, regex=regex, sep=sep, reference=reference)
 
         if bot is not None:
             bot.add_listener(listener, types_.ListenerType.BUTTON)
@@ -270,48 +321,70 @@ class SelectListener(abc.BaseListener[P, T, disnake.MessageInteraction]):
         Under normal circumstances, this should not lead to conflicts. In case ':' is intentionally
         part of the `custom_id`s matched by the listener, this should be set to a different value
         to prevent conflicts.
+    reference: Optional[Union[:class:`disnake.SelectMenu`, :class:`disnake.ui.Select`, :class:`AbstractComponent`]
+        A reference component used to set default values in `~.build_component`.
     """
 
     __cog_listener_names__: t.List[types_.ListenerType] = [types_.ListenerType.SELECT]
 
-    select_param: params.ParamInfo
+    select_param: t.Optional[params.ParamInfo]
     """The parameter with which the user-selected value(s) will be parsed. The values will be
     converted to match the type annotation of this parameter.
     """
 
-    def __new__(
-        cls: t.Type[ListenerT],
-        func: SelectListenerCallback[ParentT, P, T],
-        **kwargs: t.Any,
-    ) -> ListenerT:
-        return super().__new__(cls, func, **kwargs)
+    reference: types_.AbstractComponent
+    """A reference component used to set default values in `~.build_component`."""
 
     def __init__(
         self,
-        func: SelectListenerCallback[ParentT, P, T],
+        callback: SelectListenerCallback[ParentT, P, T],
         *,
+        name: t.Optional[str] = None,
         regex: t.Union[str, t.Pattern[str], None] = None,
         sep: str = ":",
+        reference: t.Optional[SelectReference] = None,
     ) -> None:
-        self._signature = commands.params.signature(func)  # pyright: ignore
-        if regex:
-            self.regex = utils.ensure_compiled(regex)
-            self.id_spec = utils.id_spec_from_regex(self.regex)
-            self.sep = None
-        else:
-            self.regex = None
-            self.id_spec = utils.id_spec_from_signature(self.__name__, sep, self._signature)
-            self.sep = sep
+        super().__init__(callback, name=name, regex=regex, sep=sep)
 
         special_params, listener_params = utils.extract_listener_params(self._signature)
         self.params = [params.ParamInfo.from_param(param) for param in listener_params]
 
-        if len(special_params) != 1:
+        if len(special_params) > 1:
             raise TypeError(
-                f"A `{type(self).__name__}` must have exactly one parameter before the "
-                f"keyword-only argument separator (`*,`), got {len(special_params)}."
+                f"A `{type(self).__name__}` must have exactly zero or one parameter before "
+                f"the keyword-only argument separator (`*,`), got {len(special_params)}."
             )
-        self.select_param = params.ParamInfo.from_param(special_params[0])
+
+        if special_params:
+            self.select_param = params.ParamInfo.from_param(param := special_params[0])
+            self.reference = self._choose_optimal_reference(reference, param)
+
+        else:
+            self.select_param = None
+            self.reference = self._choose_optimal_reference(reference, None)
+
+    def _choose_optimal_reference(
+        self,
+        component: t.Optional[SelectReference],
+        param: t.Optional[inspect.Parameter],
+    ) -> types_.AbstractComponent:
+        if component is not None:  # Manually provided takes highest priority
+            if isinstance(component, types_.AbstractComponent):
+                return component
+            return types_.AbstractComponent.from_component(component)
+
+        if param is not None and isinstance(default := param.default, types_.AbstractComponent):
+            if not default.get("options") and types_.get_origin(param.annotation) is t.Literal:
+                # No options were defined in the AbstractComponent but the parameter was
+                # annotated as literal, thus we should infer the options from the parameter.
+                return default.with_overrides(
+                    options=[str(arg) for arg in types_.get_args(param.annotation)]
+                )
+
+            return default
+
+        # Nothing of use was found, return an AbstractComponent that can match any select.
+        return types_.AbstractComponent(type=disnake.ComponentType.select)
 
     async def __call__(  # pyright: ignore
         self,
@@ -352,6 +425,9 @@ async def __call__(  # pyright: ignore
         except ValueError:
             return
 
+        if not await utils.assert_all_checks(self.checks, inter):
+            return
+
         # First convert custom_id params...
         converted: t.Dict[str, t.Any] = {}
         for param, arg in zip(self.params, custom_id_params):
@@ -362,13 +438,18 @@ async def __call__(  # pyright: ignore
                 skip_validation=bool(self.regex),
             )
 
+        # User didn't supply select params, can still be accessed through inter.values; return.
+        if self.select_param is None:
+            return await super().__call__(inter, **converted)
+
+        # User did supply select params, convert inter.values and provide it to the param.
         converted_values = await self.select_param.convert(
             inter.values, inter=inter, converted=converted
         )
 
         return await super().__call__(inter, converted_values, **converted)
 
-    async def build_select(
+    async def build_component(
         self,
         placeholder: t.Optional[str] = None,
         min_values: t.Optional[int] = None,
@@ -380,38 +461,59 @@ async def build_select(
     ) -> disnake.ui.Select[t.Any]:
         """Build a :class:`disnake.ui.Select` that matches this listener.
 
-        By default, this will create a Select with custom_id based on the custom_id parameters.
-        All other parameters use the normal Select defaults, except this defaults max_options to
+        By default, this will create a select with custom_id based on the custom_id parameters.
+        All other parameters use the normal select defaults, except this defaults max_options to
         ``len(options)``. These values can be overwritten by setting the parameter default to a
-        :func:`.SelectValue`, and call it with the parameters you wish to set on the TextInput.
+        :func:`.SelectValue`, and call it with the parameters you wish to set on the select.
 
         Parameters
         ----------
         **kwargs: Any
-            The keyword-only parameters of the listener to store on the Select's custom_id.
+            The keyword-only parameters of the listener to store on the select's custom_id.
 
         Returns:
         :class:`disnake.ui.Select`
-            The newly created Select.
+            The newly created select.
         """
-        # We need the underlying `inspect.Parameter` here...
-        param = self.select_param.param
+        if self.select_param:
+            # We need the underlying `inspect.Parameter` here...
+            param = self.select_param.param
 
-        # Parse options from `typing.Literal` if none were provided.
-        if options is None and types_.get_origin(param.annotation) is t.Literal:
-            options = [str(arg) for arg in types_.get_args(param.annotation)]
+            # Parse options from `typing.Literal` if none were provided.
+            if options is None and types_.get_origin(param.annotation) is t.Literal:
+                options = [str(arg) for arg in types_.get_args(param.annotation)]
 
-        # Get or create the parameter's SelectValue and .
-        if not isinstance(select_value := param.default, params._SelectValue):
-            select_value = params._SelectValue()
+        return self.reference.with_overrides(
+            placeholder=placeholder,
+            min_values=min_values,
+            max_values=max_values,
+            options=options,
+            disabled=disabled,
+            custom_id=await self.build_custom_id(*args, **kwargs),
+        ).as_component(disnake.ui.Select[t.Any])
 
-        return select_value.with_overrides(
+    @deprecation.deprecated("build_component")
+    async def build_select(
+        self,
+        placeholder: t.Optional[str] = None,
+        min_values: t.Optional[int] = None,
+        max_values: t.Optional[int] = None,
+        options: t.Union[t.List[disnake.SelectOption], t.List[str], t.Dict[str, str], None] = None,
+        disabled: t.Optional[bool] = None,
+        *args: P.args,
+        **kwargs: P.kwargs,
+    ) -> disnake.ui.Select[t.Any]:
+        return await self.build_component(
             placeholder=placeholder,
             min_values=min_values,
             max_values=max_values,
             options=options,
             disabled=disabled,
-        ).build(custom_id=await self.build_custom_id(*args, **kwargs))
+            *args,
+            **kwargs,
+        )
+
+    build_select.__doc__ = build_component.__doc__
 
 
 def select_listener(
@@ -419,7 +521,8 @@ def select_listener(
     regex: t.Union[str, t.Pattern[str], None] = None,
     sep: str = ":",
     bot: t.Optional[commands.Bot] = None,
-) -> t.Callable[[SelectListenerCallback[ParentT, P, T]], SelectListener[P, T],]:
+    reference: t.Optional[SelectReference] = None,
+) -> t.Callable[[SelectListenerCallback[ParentT, P, T]], SelectListener[P, T]]:
     """Create a new :class:`SelectListener` from a decorated function. The :class:`SelectListener`
     will take care of regex-matching and persistent data stored in the `custom_id` of the
     :class:`disnake.ui.Select`.
@@ -457,7 +560,7 @@ def select_listener(
     def wrapper(
         func: SelectListenerCallback[ParentT, P, T],
     ) -> SelectListener[P, T]:
-        listener = SelectListener[P, T](func, regex=regex, sep=sep)
+        listener = SelectListener[P, T](func, regex=regex, sep=sep, reference=reference)
 
         if bot is not None:
             bot.add_listener(listener, types_.ListenerType.SELECT)
@@ -476,29 +579,15 @@ class ModalListener(abc.BaseListener[P, T, disnake.ModalInteraction]):
     converted to match the type annotations of these parameters.
     """
 
-    def __new__(
-        cls: t.Type[ListenerT],
-        func: ModalListenerCallback[ParentT, P, T],
-        **kwargs: t.Any,
-    ) -> ListenerT:
-        return super().__new__(cls, func, **kwargs)
-
     def __init__(
         self,
-        func: ModalListenerCallback[ParentT, P, T],
+        callback: ModalListenerCallback[ParentT, P, T],
         *,
+        name: t.Optional[str] = None,
         regex: t.Union[str, t.Pattern[str], None] = None,
         sep: str = ":",
     ) -> None:
-        self._signature = commands.params.signature(func)  # pyright: ignore
-        if regex:
-            self.regex = utils.ensure_compiled(regex)
-            self.id_spec = utils.id_spec_from_regex(self.regex)
-            self.sep = None
-        else:
-            self.regex = None
-            self.id_spec = utils.id_spec_from_signature(self.__name__, sep, self._signature)
-            self.sep = sep
+        super().__init__(callback, name=name, regex=regex, sep=sep)
 
         special_params, listener_params = utils.extract_listener_params(self._signature)
 
@@ -548,6 +637,9 @@ async def __call__(  # pyright: ignore
         except ValueError:
             return
 
+        if not await utils.assert_all_checks(self.checks, inter):
+            return
+
         converted: t.Dict[str, t.Any] = {}
         for param, arg in zip(self.params, custom_id_params):
             converted[param.name] = await param.convert(
@@ -566,7 +658,7 @@ async def __call__(  # pyright: ignore
 
         return await super().__call__(inter, **converted)
 
-    async def build_modal(  # TODO: Update with new ModalValue functionality.
+    async def build_component(  # TODO: Update with new ModalValue functionality.
         self,
         title: str,
         components: t.Optional[t.List[disnake.ui.TextInput]] = None,  # TODO: Disnake 2.6 typing.
@@ -647,7 +739,7 @@ def modal_listener(
     regex: t.Union[str, t.Pattern[str], None] = None,
     sep: str = ":",
     bot: t.Optional[commands.Bot] = None,
-) -> t.Callable[[ModalListenerCallback[ParentT, P, T]], ModalListener[P, T],]:
+) -> t.Callable[[ModalListenerCallback[ParentT, P, T]], ModalListener[P, T]]:
     """Create a new :class:`ModalListener` from a decorated function. The ModalListener will take
     care of regex-matching and persistent data stored in the custom_id of the :class:`disnake.ui.Modal`.
 
@@ -715,3 +807,168 @@ def wrapper(
         return listener
 
     return wrapper
+
+
+@t.overload
+def match_component(
+    component: t.Union[disnake.Button, disnake.ui.Button[t.Any]],
+    /,
+    *,
+    bot: t.Optional[commands.Bot] = None,
+) -> t.Callable[[ButtonListenerCallback[ParentT, P, T]], ButtonListener[P, T]]:
+    ...
+
+
+@t.overload
+def match_component(
+    *,
+    component_type: t.Literal[disnake.ComponentType.button],
+    style: disnake.ButtonStyle = ...,
+    custom_id: str = ...,
+    disabled: bool = ...,
+    label: str = ...,
+    emoji: t.Union[disnake.PartialEmoji, disnake.Emoji, str] = ...,
+    bot: t.Optional[commands.Bot] = None,
+) -> t.Callable[[ButtonListenerCallback[ParentT, P, T]], ButtonListener[P, T]]:
+    ...
+
+
+@t.overload
+def match_component(
+    component: t.Union[disnake.SelectMenu, disnake.ui.Select[t.Any]],
+    /,
+    *,
+    bot: t.Optional[commands.Bot] = None,
+) -> t.Callable[[SelectListenerCallback[ParentT, P, T]], SelectListener[P, T]]:
+    ...
+
+
+@t.overload
+def match_component(
+    *,
+    component_type: t.Literal[disnake.ComponentType.select],
+    custom_id: str = ...,
+    placeholder: str = ...,
+    min_values: int = ...,
+    max_values: int = ...,
+    disabled: bool = ...,
+    options: t.List[disnake.SelectOption] = ...,
+    label: str = ...,
+    bot: t.Optional[commands.Bot] = None,
+) -> t.Callable[[SelectListenerCallback[ParentT, P, T]], SelectListener[P, T]]:
+    ...
+
+
+def match_component(
+    component: t.Optional[
+        t.Union[
+            disnake.Button,
+            disnake.ui.Button[t.Any],
+            disnake.SelectMenu,
+            disnake.ui.Select[t.Any],
+        ]
+    ] = None,
+    /,
+    *,
+    component_type: t.Optional[disnake.ComponentType] = None,
+    bot: t.Optional[commands.Bot] = None,
+    **kwargs: t.Any,
+) -> t.Callable[[t.Callable[..., t.Any]], ComponentListener]:
+    """Create a listener that listens for components that match the provided one.
+    A component can be provided either as an actual component, or as keyword-arguments with the
+    necessary information to build a component. Note that these are mutually exclusive.
+
+    This will generate a fully qualified listener based on the parameters entered. From there, one
+    can easily create matching components using the `~.build_component` methods.
+
+    Parameters
+    ----------
+    component: Union[:class:`disnake.Button`, :class:`disnake.ui.Button`, :class:`disnake.SelectMenu`, :class:`disnake.ui.Select`]
+        The component to match. As this passes a fully qualified component with all its
+        parameters set, this will make the listener look for an *exact* match of the passed
+        component.
+
+        Note that passing components is mutually exclusive with passing any keyword arguments
+        outside of `bot`.
+    component_type: :class:`disnake.ComponentType`
+        The type of component the listener is for. If using keyword args to provide a component to
+        match, this parameter is required.
+
+        Note that passing keyword arguments is mutually exclusive with passing a concrete component.
+    **kwargs: Any
+        Any other parameters that can be passed to the desired component type.
+    bot: Optional[:class:`commands.Bot`]
+        Useful when defining this listener in the main file. This can be used to automatically
+        register the listener to the bot. This is automatically taken care of inside of cogs.
+
+    Raises
+    ------
+    ValueError
+        Either both or neither of `component` and `component_type` were passed. Please make sure
+        to pass strictly one of these parameters. Furthermore, make sure to not combine a
+        concrete component with further kwargs.
+    TypeError
+        The passed component is not of compatible type, or the passed component_type is not
+        among `disnake.ComponentType.button` or `disnake.ComponentType.select`
+
+    Returns
+    -------
+    Union[:class:`ButtonListener`, :class:`SelectListener`]
+        A component listener with a component matching check registered. The listener will match
+        the type of the provided component.
+    """
+    if component is not None and (component_type is not None or kwargs):
+        raise ValueError(
+            "Please provide exactly one of `component` or `component_type` and its kwargs."
+        )
+
+    if component is not None:
+        if isinstance(component, (disnake.Button, disnake.ui.Button)):
+            listener_class = ButtonListener
+        elif isinstance(
+            component, (disnake.SelectMenu, disnake.ui.Select)
+        ):  # pyright: ignore  # Valid redundancy imo.
+            listener_class = SelectListener
+        else:
+            raise TypeError(
+                "Expected `component` to be an instance of disnake.Button, disnake.ui.Button, "
+                f"disnake.SelectMenu or disnake.ui.Select; got {type(component).__name__}."
+            )
+
+    elif component_type is not None:
+        if component_type is disnake.ComponentType.button:
+            listener_class = ButtonListener
+        elif component_type is disnake.ComponentType.select:
+            listener_class = SelectListener
+        else:
+            raise TypeError(
+                "Expected `component_type` to be either disnake.ComponentType.button or "
+                f"disnake.ComponentType.select; got {component_type.name}."
+            )
+
+    else:
+        raise ValueError(
+            "Please provide exactly one of `component` or `component_type` and its kwargs."
+        )
+
+    if component_type:
+        kwargs["type"] = component_type
+
+    def wrapper(callback: t.Callable[..., t.Any]) -> ComponentListener:
+        if component is not None:
+            reference = types_.AbstractComponent.from_component(component)
+            name = component.custom_id
+        else:
+            reference = types_.AbstractComponent(**kwargs)
+            name = kwargs.get("custom_id")
+
+        listener = listener_class(callback, name=name, reference=reference)
+        listener.add_check(utils.build_component_matching_check(reference))
+
+        if bot:
+            for listener_type in listener.__cog_listener_names__:
+                bot.add_listener(listener, listener_type)
+
+        return listener
+
+    return wrapper
diff --git a/disnake_ext_components/params.py b/disnake_ext_components/params.py
index 010a4f5..d0f366c 100644
--- a/disnake_ext_components/params.py
+++ b/disnake_ext_components/params.py
@@ -48,6 +48,7 @@
     disnake.abc.GuildChannel: patterns.SNOWFLAKE,
     disnake.Guild:            patterns.SNOWFLAKE,
     disnake.Message:          patterns.SNOWFLAKE,
+    disnake.Permissions:      patterns.STRICTINT,
     # disnake.Emoji:            ID,  # temporarily(?) disabled
     # fmt: on
 }
@@ -429,7 +430,6 @@ def with_overrides(
         options: t.Union[t.List[disnake.SelectOption], t.List[str], t.Dict[str, str], None] = None,
         disabled: t.Optional[bool] = None,
     ) -> _SelectValue:
-        print(options)
         return type(self)(
             placeholder=self.placeholder if placeholder is None else placeholder,
             min_values=self.min_values if min_values is None else min_values,
@@ -458,8 +458,9 @@ def SelectValue(
     options: t.Union[t.List[disnake.SelectOption], t.List[str], t.Dict[str, str], None] = None,
     disabled: bool = False,
 ) -> t.Any:
-    return _SelectValue(
-        placeholder,
+    return types_.AbstractComponent(
+        type=disnake.ComponentType.select,
+        placeholder=placeholder,
         min_values=min_values,
         max_values=max_values,
         options=options,
diff --git a/disnake_ext_components/types_.py b/disnake_ext_components/types_.py
index cfb2ea9..be578fe 100644
--- a/disnake_ext_components/types_.py
+++ b/disnake_ext_components/types_.py
@@ -1,17 +1,17 @@
 from __future__ import annotations
 
 import enum
-import functools
 import re
 import sys
 import typing as t
 
+import disnake
+
 __all__ = [
     "Annotated",
     "get_args",
     "get_origin",
     "ListenerType",
-    "partial",
     "Converted",
 ]
 
@@ -20,33 +20,27 @@
 _T_co = t.TypeVar("_T_co", covariant=True)
 _T_contra = t.TypeVar("_T_contra", contravariant=True)
 
-MaybeAwaitable = t.Union[t.Awaitable[_T], _T]
+Coro = t.Coroutine[t.Any, t.Any, _T]
+MaybeCoro = t.Union[Coro[_T], _T]
 MaybeSequence = t.Union[t.Sequence[_T], _T]
 
+InteractionT = t.TypeVar("InteractionT", disnake.MessageInteraction, disnake.ModalInteraction)
+MessageComponentT = t.TypeVar(
+    "MessageComponentT",
+    bound=t.Union[
+        disnake.ui.Button[t.Any],
+        disnake.ui.Select[t.Any],
+    ],
+)
+
+CheckCallback = t.Callable[[InteractionT], MaybeCoro[bool]]
+CheckT = t.TypeVar("CheckT", bound=CheckCallback[t.Any])
 
 if sys.version_info >= (3, 10):
     from typing import Annotated, get_args, get_origin
 else:
     from typing_extensions import Annotated, get_args, get_origin
 
-if sys.version_info >= (3, 9):
-    partial = functools.partial
-
-else:
-
-    class partial(functools.partial, t.Generic[_T]):  # pyright: ignore
-        """This intermediary class is needed to have type-checking work properly between Python
-        versions 3.8 through 3.10. Since `functools.partial` became a generic in Python 3.9,
-        type-checkers expect this in versions 3.9 and up, whereas it would raise in version 3.8.
-
-        To get around this, for version 3.8 specifically, we create this intermediary class to
-        make `functools.partial` behave like its version 3.9+ counterpart, where the return type
-        can be set as the generic type specifier.
-        """
-
-        def __call__(self, *args: t.Any, **kwargs: t.Any) -> _T:
-            return t.cast(_T, super().__call__(*args, **kwargs))
-
 
 class ListenerType(str, enum.Enum):
     """A string enum that contains all listener types."""
@@ -130,13 +124,13 @@ async def listener(
 
     # TODO: Should probably rename these.
 
-    converter_to: t.Callable[..., MaybeAwaitable[t.Any]]
+    converter_to: t.Callable[..., MaybeCoro[t.Any]]
     """The custom converter function used to convert input from :class:`str` to the return type
     of the function. Make sure that this function can convert anything matched by the provided
     regex pattern.
     """
 
-    converter_from: t.Callable[..., MaybeAwaitable[t.Any]]
+    converter_from: t.Callable[..., MaybeCoro[t.Any]]
     """The custom converter function used to convert back to :class:`str`. This is used to ensure
     the value is inserted into the custom_id in such a manner that it can be matched anew. Make
     sure that whatever is returned by this function can be matched by the provided regex pattern.
@@ -145,8 +139,8 @@ async def listener(
     def __init__(
         self,
         regex: t.Pattern[str],
-        converter_to: t.Callable[..., MaybeAwaitable[t.Any]],
-        converter_from: t.Callable[..., MaybeAwaitable[t.Any]],
+        converter_to: t.Callable[..., MaybeCoro[t.Any]],
+        converter_from: t.Callable[..., MaybeCoro[t.Any]],
     ):
         self.regex = regex
         self.converter_to = converter_to
@@ -158,3 +152,124 @@ def __repr__(self):
             f"converter_to={self.converter_to.__name__}(), "
             f"converter_from={self.converter_from.__name__}()]"
         )
+
+
+class SelectOption(disnake.SelectOption):
+    __slots__ = ()
+
+    def __eq__(self, other: t.Any) -> bool:
+        if not isinstance(other, disnake.SelectOption):
+            return False
+
+        for slot in disnake.SelectOption.__slots__:
+            value = getattr(self, slot)
+            other_value = getattr(other, slot)
+
+            if value is disnake.utils.MISSING and other_value is not disnake.utils.MISSING:
+                return False
+
+            if other_value != value:
+                return False
+
+        return True
+
+    @classmethod
+    def _convert(cls, other: disnake.SelectOption):
+        return cls(**{slot: getattr(other, slot) for slot in disnake.SelectOption.__slots__})
+
+
+def _parse_select_options(
+    options: t.Union[t.List[disnake.SelectOption], t.List[str], t.Dict[str, str]]
+) -> t.List[SelectOption]:
+    # Had to yoink this from disnake as the `ui.select` module is shadowed by the decorator...
+    # Gave me the opportunity to work with custom SelectOptions that support comparison though.
+
+    if isinstance(options, dict):
+        return [SelectOption(label=key, value=val) for key, val in options.items()]
+
+    return [
+        SelectOption._convert(opt)
+        if isinstance(opt, disnake.SelectOption)
+        else SelectOption(label=opt)
+        for opt in options
+    ]
+
+
+class AbstractComponent:
+    __sentinel = object()
+
+    __slots__: t.Tuple[t.Any] = tuple(
+        set(disnake.Component.__slots__)
+        | set(disnake.Button.__slots__)
+        | set(disnake.SelectMenu.__slots__)
+    )
+
+    def __init__(self, **kwargs: t.Any):
+        # Handle special cases...
+        if "emoji" in kwargs and isinstance(emoji := kwargs["emoji"], str):
+            kwargs["emoji"] = disnake.PartialEmoji.from_str(emoji)
+
+        if "options" in kwargs:
+            kwargs["options"] = _parse_select_options(kwargs["options"] or [])
+
+        for k, v in kwargs.items():
+            setattr(self, k, v)
+
+    @classmethod
+    def from_component(
+        cls,
+        component: t.Union[
+            disnake.Button,
+            disnake.ui.Button[t.Any],
+            disnake.SelectMenu,
+            disnake.ui.Select[t.Any],
+        ],
+    ) -> AbstractComponent:
+        self = cls()
+        for slot in cls.__slots__:
+            value = getattr(component, slot, cls.__sentinel)
+            if value is not cls.__sentinel:
+                setattr(self, slot, value)
+
+        # Ensure custom SelectOptions
+        options: t.Any = getattr(self, "options", self.__sentinel)
+        if options is not self.__sentinel:
+            setattr(self, "options", _parse_select_options(options))
+
+        return self
+
+    def __iter__(self) -> t.Generator[t.Tuple[str, t.Any], None, None]:
+        for slot in self.__slots__:
+            value = getattr(self, slot, self.__sentinel)
+            if value is not self.__sentinel:
+                yield slot, value
+
+    def __eq__(self, other: t.Union[disnake.Button, disnake.SelectMenu]) -> bool:  # type: ignore
+        return not any(value != getattr(other, slot, self.__sentinel) for slot, value in self)
+
+    def __repr__(self):
+        return f"AbstractComponent({', '.join(f'{k}={v}' for k, v in self)})"
+
+    def get(self, key: str) -> t.Optional[t.Any]:
+        return getattr(self, key, None)
+
+    def copy(self) -> AbstractComponent:
+        return AbstractComponent(**dict(self))
+
+    def with_overrides(self, **kwargs: t.Any):
+        copy = self.copy()
+        copy.__init__(**{k: v for k, v in kwargs.items() if v is not None})
+        return copy
+
+    def as_component(self, template: t.Type[MessageComponentT]) -> MessageComponentT:
+        kwargs = dict(self)
+        type_ = kwargs.pop("type", self.__sentinel)
+
+        component = template(**kwargs)
+        if component.type is not type_:
+            raise ValueError(
+                f"This AbstractComponent is of type {type_}, "
+                f"and is therefore incompatible with {template.__name__}."
+            )
+
+        return component
diff --git a/disnake_ext_components/utils.py b/disnake_ext_components/utils.py
index 80982ad..2023343 100644
--- a/disnake_ext_components/utils.py
+++ b/disnake_ext_components/utils.py
@@ -5,6 +5,8 @@
 import disnake
 from disnake.ext import commands
 
+from . import types_
+
 __all__ = [
     "id_spec_from_signature",
     "id_spec_from_regex",
@@ -22,6 +24,11 @@ def id_spec_from_signature(name: str, sep: str, signature: inspect.Signature) ->
         The name of the listener function to which the signature belongs.
     signature: :class:`inspect.Signature`
         The function signature of the listener function.
+
+    Returns
+    -------
+    :class:`str`
+        The custom_id spec that was built from the provided function signature.
     """
     _, custom_id_params = extract_listener_params(signature)
     if not custom_id_params:
@@ -38,6 +45,11 @@ def id_spec_from_regex(regex: t.Pattern[str]) -> str:
     ----------
     regex: :class:`re.Pattern`
         The regex pattern that is to be deconstructed.
+
+    Returns
+    -------
+    :class:`str`
+        The custom_id spec that was extracted from the regex pattern.
     """
     return re.sub(r"\(\?P<(.+?)>.*?\)", lambda m: f"{{{m[1]}}}", regex.pattern)
 
@@ -71,7 +83,7 @@ def extract_listener_params(
         - The second tuple contains all remaining parameters, which are parsed from the `custom_id`.
     """
     param_iter = iter(signature.parameters.values())
-    for param in param_iter:
+    for pos, param in enumerate(param_iter):
         if commands.params.issubclass_(param.annotation, disnake.Interaction):
             break
     else:
@@ -80,6 +92,9 @@ def extract_listener_params(
             "Please make sure the interaction parameter is properly annotated in the listener."
         )
 
+    if pos > 1:
+        raise TypeError("The listener callback's `self` parameter must be the first parameter.")
+
     special_params: t.List[inspect.Parameter] = []
     for param in param_iter:
 
@@ -109,5 +124,83 @@ def ensure_compiled(
         compiled, it is returned as-is.
     flags: :class:`re.RegexFlag`
         Any flags to apply to compilation. By default this has the same behaviour as `re.compile`.
+
+    Returns
+    -------
+    :class:`re.Pattern`
+        The compiled regex pattern.
     """
     return re.compile(pattern, flags) if isinstance(pattern, str) else pattern
+
+
+async def assert_all_checks(
+    checks: t.Sequence[types_.CheckCallback[types_.InteractionT]],
+    inter: types_.InteractionT,
+) -> bool:
+    """Ensure all checks for a given listener pass.
+
+    Parameters
+    ----------
+    checks: Sequence[Callable[[:class:`disnake.Interaction`], MaybeCoro[:class:`bool`]]]
+        The checks that should be run for the listener.
+    inter: :class:`disnake.Interaction`
+        The interaction to supply to the checks.
+
+    Returns
+    -------
+    :class:`bool`
+        Whether all checks succeeded or not.
+    """
+    for check in checks:
+        result = check(inter)
+        if inspect.isawaitable(result):
+            result = await result
+
+        if result is False:
+            return False
+
+    return True
+
+
+def build_component_matching_check(
+    component: t.Union[
+        disnake.ui.Button[t.Any],
+        disnake.ui.Select[t.Any],
+        types_.AbstractComponent,
+        None,
+    ] = None,
+    /,
+    **kwargs: t.Any,
+) -> t.Callable[[disnake.MessageInteraction], bool]:
+    """Build a check function to compare a component with the incoming interaction's component.
+    Takes either a component, or kwargs that build a component. A component will look for an exact
+    match, whereas kwargs will look for a "superset" of the provided kwargs.
+
+    Parameters
+    ----------
+    component: Union[:class:`disnake.ui.Button`, :class:`disnake.ui.Select` :class:`.types_.AbstractComponent`]
+        The component to match.
+    kwargs: Any
+        The parameters that make up a (partial) component.
+
+    Returns
+    -------
+    Callable[[:class:`disnake.MessageInteraction`], :class:`bool`]
+        The check function. Takes a message interaction and returns a bool depending on whether the
+        component matches.
+    """  # noqa: E501
+    if component is not None:
+        if kwargs:
+            raise ValueError("Please provide either a component or kwargs.")
+
+        if isinstance(component, types_.AbstractComponent):
+            check_component = component
+        else:
+            check_component = types_.AbstractComponent.from_component(component)
+    else:
+        check_component = types_.AbstractComponent(**kwargs)
+
+    def check(inter: disnake.MessageInteraction) -> bool:
+        return check_component == inter.component
+
+    return check
diff --git a/examples/component_matching.py b/examples/component_matching.py
new file mode 100644
index 0000000..37a12b0
--- /dev/null
+++ b/examples/component_matching.py
@@ -0,0 +1,35 @@
+import disnake
+from disnake.ext import commands, components
+
+
+class ComponentMatchingExample(commands.Cog):
+    def __init__(self, bot: commands.Bot):
+        self.bot = bot
+
+    @components.match_component(component_type=disnake.ComponentType.button, label="simple_delete")
+    async def simple_delete_listener(self, inter: disnake.MessageInteraction):
+        """Check if the author has sufficient permissions. If so, delete the message."""
+        await inter.response.defer()
+        print(inter.component.custom_id)
+
+        if (
+            # DMs...
+            isinstance(inter.channel, disnake.PartialMessageable)
+            or isinstance(inter.author, disnake.User)
+            # Author has manage_message permissions...
+            or inter.channel.permissions_for(inter.author).manage_messages
+        ):
+            await inter.delete_original_message()
+            return
+
+        await inter.followup.send("You are not allowed to take this action!", ephemeral=True)
+
+    @commands.slash_command()
+    async def send_a_thing(self, inter: disnake.CommandInteraction):
+        await inter.response.send_message(
+            "Here's a thing.", components=await self.simple_delete_listener.build_component()
+        )
+
+
+def setup(bot: commands.Bot):
+    bot.add_cog(ComponentMatchingExample(bot))


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index ba30ae8..e668f69 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,54 @@
+import typing as t
 from unittest import mock
 
 import disnake
 import pytest
 
+import disnake_ext_components as components
+
+
+@pytest.fixture
+def msg_inter():
+    return mock.Mock(spec=disnake.MessageInteraction)
+
 
 @pytest.fixture
-def inter():
+def modal_inter():
     return mock.Mock(spec=disnake.MessageInteraction)
+
+
+@pytest.fixture
+def button_listener_callback():
+    async def button_listener_callback(inter: disnake.MessageInteraction, *, foo: int, bar: str):
+        ...
+
+    return button_listener_callback
+
+
+@pytest.fixture
+def select_listener_callback():
+    async def select_listener_callback(
+        inter: disnake.MessageInteraction,
+        selected: t.List[str] = components.SelectValue("bean"),
+        *,
+        foo: int,
+        bar: str,
+    ):
+        ...
+
+    return select_listener_callback
+
+
+@pytest.fixture
+def modal_listener_callback():
+    async def modal_listener_callback(
+        inter: disnake.MessageInteraction,
+        field_A: t.List[str] = components.ModalValue("bean"),
+        field_B: t.List[str] = components.ParagraphModalValue("bean"),
+        *,
+        foo: int,
+        bar: str,
+    ):
+        ...
+
+    return modal_listener_callback
diff --git a/tests/test_checks.py b/tests/test_checks.py
new file mode 100644
index 0000000..b7514f7
--- /dev/null
+++ b/tests/test_checks.py
@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+import typing as t
+
+import disnake
+import pytest
+
+import disnake_ext_components as components
+
+
+def dummy_sync_check(inter: disnake.MessageInteraction) -> bool:
+    return inter.component.custom_id == "success"
+
+
+async def dummy_async_check(inter: disnake.MessageInteraction) -> bool:
+    return inter.component.custom_id == "success"
+
+
+@pytest.fixture
+def listener():
+    @components.button_listener()
+    async def dummy_listener(inter: disnake.MessageInteraction):
+        ...
+
+    return dummy_listener
+
+
+# abc.BaseListener.add_check
+
+
+@pytest.mark.asyncio
+async def test_add_check(listener: components.SelectListener[t.Any, t.Any]):
+
+    listener.add_check(dummy_sync_check)
+
+    assert listener.checks[0] is dummy_sync_check
+
+    listener.add_check(dummy_async_check)
+
+    assert listener.checks[1] is dummy_async_check
+
+
+# utils.assert_all_checks
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "custom_id, expected, checks",
+    (
+        ("success", True, [dummy_sync_check]),
+        ("failure", False, [dummy_async_check]),
+        ("success", True, [dummy_sync_check, dummy_async_check]),
+        ("failure", False, [dummy_sync_check, dummy_async_check]),
+    ),
+)
+async def test_sync_check(
+    listener: components.SelectListener[t.Any, t.Any],
+    msg_inter: disnake.MessageInteraction,
+    custom_id: str,
+    expected: bool,
+    checks: t.Sequence[t.Callable[..., t.Any]],
+):
+    for check in checks:
+        listener.add_check(check)
+
+    msg_inter.component.custom_id = custom_id
+    assert expected is await components.utils.assert_all_checks(listener.checks, msg_inter)
+
+
+# utils.build_component_matching_check
+
+
+b = disnake.ui.Button[t.Any]
+s = disnake.ui.Select[t.Any]
+bs = disnake.ButtonStyle
+
+
+@pytest.mark.parametrize(
+    "componentA, componentB, kwargsA, kwargsB, expected",
+    (
+        # Check all individual params...
+        # Note that custom_id must be set for comparison, otherwise disnake will randomly
+        # generate them, and they won't match.
+        (b, b, {"custom_id": "abc"},                       {"custom_id": "abc"},                            True),
+        (s, s, {"custom_id": "abc"},                       {"custom_id": "abc"},                            True),
+        (b, s, {"custom_id": "abc"},                       {"custom_id": "abc"},                            False),
+
+        (b, b, {"custom_id": "abc", "disabled": True},     {"custom_id": "abc", "disabled": True},          True),
+        (s, s, {"custom_id": "abc", "disabled": True},     {"custom_id": "abc", "disabled": True},          True),
+        (b, s, {"custom_id": "abc", "disabled": True},     {"custom_id": "abc", "disabled": True},          False),
+
+        (b, b, {"custom_id": "abc", "label": "abc"},       {"custom_id": "abc", "label": "abc"},            True),
+        (b, b, {"custom_id": "abc", "label": "abc"},       {"custom_id": "abc", "label": "def"},            False),
+
+        (b, b, {"custom_id": "abc", "emoji": "abc"},       {"custom_id": "abc", "emoji": "abc"},            True),
+        (b, b, {"custom_id": "abc", "emoji": "abc"},       {"custom_id": "abc", "emoji": "def"},            False),
+
+        (b, b, {"custom_id": "abc", "style": bs.red},      {"custom_id": "abc", "style": bs.red},           True),
+        (b, b, {"custom_id": "abc", "style": bs.red},      {"custom_id": "abc", "style": bs.gray},          False),
+
+        (s, s, {"custom_id": "abc", "max_values": 6},      {"custom_id": "abc", "max_values": 6},           True),
+        (s, s, {"custom_id": "abc", "max_values": 6},      {"custom_id": "abc", "max_values": 9},           False),
+
+        (s, s, {"custom_id": "abc", "min_values": 6},      {"custom_id": "abc", "min_values": 6},           True),
+        (s, s, {"custom_id": "abc", "min_values": 6},      {"custom_id": "abc", "min_values": 9},           False),
+
+        (s, s, {"custom_id": "abc", "placeholder": "abc"}, {"custom_id": "abc", "placeholder": "abc"},      True),
+        (s, s, {"custom_id": "abc", "placeholder": "abc"}, {"custom_id": "abc", "placeholder": "def"},      False),
+
+        (s, s, {"custom_id": "abc", "options": ["abc"]},   {"custom_id": "abc", "options": ["abc"]},        True),
+        (s, s, {"custom_id": "abc", "options": ["abc"]},   {"custom_id": "abc", "options": ["def"]},        False),
+        (s, s, {"custom_id": "abc", "options": ["abc"]},   {"custom_id": "abc", "options": ["abc", "def"]}, False),
+        (s, s, {"custom_id": "abc", "options": ["abc"]},   {"custom_id": "abc", "options": {"abc": "def"}}, False),
+
+        # Confirm that supersets aren't allowed...
+        (b, b, {"custom_id": "abc"},                       {"custom_id": "abc", "label": "abc"},            False),
+        (s, s, {"custom_id": "abc"},                       {"custom_id": "abc", "placeholder": "abc"},      False),
+    ),  # fmt: skip
+)
+def test_build_component_matching_check_component(
+    componentA: t.Union[t.Type[b], t.Type[s]],
+    componentB: t.Union[t.Type[b], t.Type[s]],
+    kwargsA: t.Dict[str, t.Any],
+    kwargsB: t.Dict[str, t.Any],
+    expected: bool,
+    msg_inter: disnake.MessageInteraction,
+):
+    actualA = componentA(**kwargsA)
+    actualB = componentB(**kwargsB)
+    check = components.utils.build_component_matching_check(actualA)
+
+    msg_inter.component = actualB._underlying  # type: ignore
+
+    assert check(msg_inter) is expected
+
+
+@pytest.mark.parametrize(
+    "kwargs, component, expected",
+    (
+        # Check all individual params...
+        # Note that custom_id must be set for comparison, otherwise disnake will randomly
+        # generate them, and they won't match.
+        ({"custom_id": "abc"},                       b(custom_id="abc"),                         True),
+        ({"custom_id": "abc"},                       s(custom_id="abc"),                         True),
+        ({"custom_id": "abc"},                       b(custom_id="def"),                         False),
+
+        ({"custom_id": "abc", "disabled": True},     b(custom_id="abc", disabled=True),          True),
+        ({"custom_id": "abc", "disabled": True},     s(custom_id="abc", disabled=True),          True),
+        ({"custom_id": "abc", "disabled": True},     b(custom_id="abc", disabled=False),         False),
+
+        ({"custom_id": "abc", "label": "abc"},       b(custom_id="abc", label="abc"),            True),
+        ({"custom_id": "abc", "label": "abc"},       b(custom_id="abc", label="def"),            False),
+
+        ({"custom_id": "abc", "emoji": "abc"},       b(custom_id="abc", emoji="abc"),            True),
+        ({"custom_id": "abc", "emoji": "abc"},       b(custom_id="abc", emoji="def"),            False),
+
+        ({"custom_id": "abc", "style": bs.red},      b(custom_id="abc", style=bs.red),           True),
+        ({"custom_id": "abc", "style": bs.red},      b(custom_id="abc", style=bs.gray),          False),
+
+        ({"custom_id": "abc", "max_values": 6},      s(custom_id="abc", max_values=6),           True),
+        ({"custom_id": "abc", "max_values": 6},      s(custom_id="abc", max_values=9),           False),
+
+        ({"custom_id": "abc", "min_values": 6},      s(custom_id="abc", min_values=6),           True),
+        ({"custom_id": "abc", "min_values": 6},      s(custom_id="abc", min_values=9),           False),
+
+        ({"custom_id": "abc", "placeholder": "abc"}, s(custom_id="abc", placeholder="abc"),      True),
+        ({"custom_id": "abc", "placeholder": "abc"}, s(custom_id="abc", placeholder="def"),      False),
+
+        ({"custom_id": "abc", "options": ["abc"]},   s(custom_id="abc", options=["abc"]),        True),
+        ({"custom_id": "abc", "options": ["abc"]},   s(custom_id="abc", options=["def"]),        False),
+        ({"custom_id": "abc", "options": ["abc"]},   s(custom_id="abc", options=["abc", "def"]), False),
+        ({"custom_id": "abc", "options": ["abc"]},   s(custom_id="abc", options={"abc": "def"}), False),
+
+        # Confirm that supersets are allowed...
+        ({"custom_id": "abc"},                       b(custom_id="abc", label="abc"),            True),
+        ({"custom_id": "abc"},                       s(custom_id="abc", placeholder="abc"),      True),
+    ),  # fmt: skip
+)
+def test_build_component_matching_check_kwargs(  # This should match 'supersets'.
+    kwargs: t.Dict[str, t.Any],
+    component: t.Union[b, s],
+    expected: bool,
+    msg_inter: disnake.MessageInteraction,
+):
+    check = components.utils.build_component_matching_check(**kwargs)
+    msg_inter.component = component._underlying  # type: ignore
+
+    assert check(msg_inter) is expected
+
+
+# listener.match_component
+
+# This may need to be moved to an eventual test_listeners.py.
+# Note that a listener just returns early if it fails to match (for now?).
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "componentA, componentB, expected",
+    (
+        (b(label="abc", custom_id="def"), b(label="abc", custom_id="def"), True),
+        (b(label="abc", custom_id="def"), b(label="abc", custom_id="abc"), None),
+        (s(custom_id="def"), s(custom_id="def"), True),
+        (s(custom_id="def"), s(custom_id="abc"), None),
+    ),
+)
+async def test_match_component_with_component(
+    componentA: t.Union[b, s],
+    componentB: t.Union[b, s],
+    expected: bool,
+    msg_inter: disnake.MessageInteraction,
+):
+    @components.match_component(componentA)
+    async def listener(inter: disnake.MessageInteraction) -> bool:
+        return True
+
+    msg_inter.component = componentB._underlying  # type: ignore
+
+    assert await listener(msg_inter) is expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "kwargs, component, expected",
+    (
+        ({"custom_id": "abc", "component_type": disnake.ComponentType.button}, b(custom_id="abc"), True),
+        ({"custom_id": "abc", "component_type": disnake.ComponentType.button}, s(custom_id="abc"), None),
+        ({"custom_id": "abc", "component_type": disnake.ComponentType.select}, b(custom_id="abc"), None),
+        ({"custom_id": "abc", "component_type": disnake.ComponentType.select}, s(custom_id="abc"), True),
+    ),  # fmt: skip
+)
+async def test_match_component_with_kwargs(
+    kwargs: t.Dict[str, t.Any],
+    component: t.Union[b, s],
+    expected: bool,
+    msg_inter: disnake.MessageInteraction,
+):
+    @components.match_component(**kwargs)
+    async def listener(inter: disnake.MessageInteraction) -> bool:
+        return True
+
+    msg_inter.component = component._underlying  # type: ignore
+
+    assert await listener(msg_inter) is expected
diff --git a/tests/test_component_build.py b/tests/test_component_build.py
new file mode 100644
index 0000000..7bdf42e
--- /dev/null
+++ b/tests/test_component_build.py
@@ -0,0 +1,66 @@
+import typing as t
+
+import disnake
+import pytest
+
+import disnake_ext_components as components
+
+
+b = disnake.ui.Button[t.Any]
+s = disnake.ui.Select[t.Any]
+ct = disnake.ComponentType
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "component, overrides, expected",
+    (
+        # Note that we *need* to set custom ids here for validation, as disnake will otherwise
+        # generate random ones for us. This will cause a mismatch between the created component
+        # and the one with which we wish to validate it.
+        (b(custom_id="abc"),                    {},                     b(custom_id="abc")),
+        (s(custom_id="abc"),                    {},                     s(custom_id="abc")),
+        (b(custom_id="abc"),                    {"label": "def"},       b(custom_id="abc", label="def")),
+        (s(custom_id="abc"),                    {"placeholder": "def"}, s(custom_id="abc", placeholder="def")),
+        # Ensure overwriting attributes works..
+        (b(custom_id="abc", label="abc"),       {"label": "def"},       b(custom_id="abc", label="def")),
+        (s(custom_id="abc", placeholder="abc"), {"placeholder": "def"}, s(custom_id="abc", placeholder="def")),
+    )  # fmt: skip
+)
+async def test_build_from_component(
+    component: t.Union[b, s],
+    overrides: t.Dict[str, t.Any],
+    expected: t.Union[b, s],
+):
+    @components.match_component(component)
+    async def listener(inter: disnake.MessageInteraction):
+        ...
+
+    built = await listener.build_component(**overrides)
+    assert built.to_component_dict() == expected.to_component_dict()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "kwargs, overrides, expected",
+    (
+        ({"component_type": ct.button, "custom_id": "abc"}, {},                     b(custom_id="abc")),
+        ({"component_type": ct.select, "custom_id": "abc"}, {},                     s(custom_id="abc")),
+        ({"component_type": ct.button, "custom_id": "abc"}, {"label": "def"},       b(custom_id="abc", label="def")),
+        ({"component_type": ct.select, "custom_id": "abc"}, {"placeholder": "def"}, s(custom_id="abc", placeholder="def")),
+        # # Ensure overwriting attributes works..
+        ({"component_type": ct.button, "custom_id": "abc", "label": "abc"},       {"label": "def"},       b(custom_id="abc", label="def")),
+        ({"component_type": ct.select, "custom_id": "abc", "placeholder": "abc"}, {"placeholder": "def"}, s(custom_id="abc", placeholder="def")),
+    )  # fmt: skip
+)
+async def test_build_from_kwargs(
+    kwargs: t.Dict[str, t.Any],
+    overrides: t.Dict[str, t.Any],
+    expected: t.Union[b, s],
+):
+    @components.match_component(**kwargs)
+    async def listener(inter: disnake.MessageInteraction):
+        ...
+
+    built = await listener.build_component(**overrides)
+    assert built.to_component_dict() == expected.to_component_dict()
diff --git a/tests/test_invoke.py b/tests/test_invoke.py
deleted file mode 100644
index 6fa7414..0000000
--- a/tests/test_invoke.py
+++ /dev/null
@@ -1,86 +0,0 @@
-import disnake
-import pytest
-from disnake.ext import commands
-
-import disnake_ext_components as components
-
-
-@pytest.fixture
-def default_listener():
-    class A(commands.Cog):
-        @components.button_listener(sep="<>")
-        async def default_listener(self, inter: disnake.MessageInteraction, int_: int):
-            return int_
-
-    return A().default_listener
-
-
-@pytest.fixture
-def regex_listener():
-    class A(commands.Cog):
-        @components.button_listener(regex=r"custom_regex:(?P<int_>.+)")
-        async def regex_listener(self, inter: disnake.MessageInteraction, int_: int):
-            return int_
-
-    return A().regex_listener
-
-
-@pytest.mark.asyncio
-async def test_invoke_default_pass(default_listener, inter):
-    """Test a default listener with correct invocations."""
-
-    x = "passed_as_is"
-    inter.component.custom_id = "should_get_skipped_anyways"
-    assert await default_listener(inter, x) is x
-
-    inter.component.custom_id = "default_listener<>123"
-    assert await default_listener(inter) == 123
-
-
-@pytest.mark.asyncio
-async def test_invoke_default_ignore(default_listener, inter):
-    """Test a default listener invocation that is ignored becuase of a name mismatch."""
-
-    inter.component.custom_id = "blatantly_wrong"
-    assert await default_listener(inter) is None
-
-
-@pytest.mark.asyncio
-async def test_invoke_default_fail(default_listener, inter):
-    """Test a default listener invocation that fails during param conversion."""
-
-    inter.component.custom_id = "default_listener<>abc"
-    with pytest.raises(components.exceptions.ConversionError) as exc_info:
-        await default_listener(inter)
-
-    assert isinstance(exc_info.value.errors[0], components.exceptions.MatchFailure)
-
-
-@pytest.mark.asyncio
-async def test_invoke_regex_pass(regex_listener, inter):
-    """Test a custom regex listener with correct invocations."""
-
-    x = "passed_as_is"
-    inter.component.custom_id = "should_get_skipped_anyways"
-    assert await regex_listener(inter, x) is x
-
-    inter.component.custom_id = "custom_regex:123"
-    assert await regex_listener(inter) == 123
-
-
-@pytest.mark.asyncio
-async def test_invoke_regex_ignore(regex_listener, inter):
-    """Test a custom regex listener invocation that is ignored because of a name mismatch."""
-    inter.component.custom_id = "__custom_regex:123"
-    assert await regex_listener(inter) is None
-
-
-@pytest.mark.asyncio
-async def test_invoke_regex_fail(regex_listener, inter):
-    """Test a custom regex listener invocation that fails during param conversion."""
-
-    inter.component.custom_id = "custom_regex:abc"
-    with pytest.raises(components.exceptions.ConversionError) as exc_info:
-        await regex_listener(inter)
-
-    assert isinstance(exc_info.value.errors[0], ValueError)
diff --git a/tests/test_params.py b/tests/test_params.py
new file mode 100644
index 0000000..105e5d8
--- /dev/null
+++ b/tests/test_params.py
@@ -0,0 +1,329 @@
+import datetime
+import inspect
+import typing as t
+
+import disnake
+import pytest
+
+import disnake_ext_components as components
+
+ParsedParams = t.Tuple[t.Tuple[inspect.Parameter, ...], t.Tuple[inspect.Parameter, ...]]
+
+
+utc = datetime.timezone.utc
+
+
+@pytest.fixture
+def button_params(button_listener_callback: t.Callable[..., t.Any]) -> ParsedParams:
+    sig = inspect.signature(button_listener_callback)
+    return components.utils.extract_listener_params(sig)
+
+
+def param_from_annotation(
+    annotation: t.Any,
+    # *,
+    default: t.Any = inspect._empty,
+) -> inspect.Parameter:
+    """Create a fake parameter to validate against."""
+    return inspect.Parameter(
+        "x", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation, default=default
+    )
+
+
+# params.ParamInfo.name
+
+
+def test_paraminfo_name():
+    def something(foo: str, bar: str, baz: str, bat: str):
+        ...
+
+    sig = inspect.signature(something)
+    assert all(
+        name == components.params.ParamInfo.from_param(param).name
+        for name, param in sig.parameters.items()
+    )
+
+
+# params.ParamInfo.convert
+
+
+@pytest.mark.asyncio
+async def test_paraminfo_convert_single():
+    param = param_from_annotation(str)
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    assert await paraminfo.convert("abc") == "abc"
+    assert await paraminfo.convert("123") == "123"
+
+
+@pytest.mark.asyncio
+async def test_paraminfo_convert_multi():
+    param = param_from_annotation(t.List[str])
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    assert await paraminfo.convert(["abc", "def"]) == ["abc", "def"]
+
+
+@pytest.mark.asyncio
+async def test_paraminfo_convert_fail_single():
+    param = param_from_annotation(int)
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    with pytest.raises(components.ConversionError):
+        await paraminfo.convert("abc")
+
+
+@pytest.mark.asyncio
+async def test_paraminfo_convert_fail_multi():
+    param = param_from_annotation(t.List[int])
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    with pytest.raises(components.ConversionError) as exc_info:
+        await paraminfo.convert(["123", "def"])
+
+    # For good measure, ensure the first one matched and only the second one errored.
+    assert len(exc_info.value.errors) == 1
+    assert isinstance(exc := exc_info.value.errors[0], components.MatchFailure)
+    assert exc.message == f"Input 'def' did not match r'{components.patterns.INT.pattern}'."
+
+
+@pytest.mark.asyncio
+async def test_paraminfo_convert_skip_validation():
+    param = param_from_annotation(int)
+    paraminfo = components.params.ParamInfo.from_param(param, validate=False)
+
+    assert await paraminfo.convert("123") == 123
+
+    # Confirm that, when converting an incorrect value, it errors on conversion instead
+    # of during matching.
+    with pytest.raises(components.ConversionError) as exc_info:
+        await paraminfo.convert("abc")
+
+    assert len(exc_info.value.errors) == 1
+    assert exc_info.value.errors[0].args == ("invalid literal for int() with base 10: 'abc'",)
+
+
+# params.ParamInfo | empty
+
+
+@pytest.mark.asyncio
+async def test_str_paraminfo():
+    param = param_from_annotation(inspect.Parameter.empty)
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    assert param == paraminfo.param
+
+    assert await paraminfo.convert("123") == "123"
+    assert await paraminfo.convert("True") == "True"
+
+
+# params.ParamInfo | t.Optional
+
+
+@pytest.mark.asyncio
+async def test_optional_paraminfo_no_default():
+    param = param_from_annotation(t.Optional[int])
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    # x: Optional[int] -> x: Optional[int] = None
+    assert param.replace(default=None) == paraminfo.param
+
+    assert paraminfo.default is None
+    assert paraminfo.optional is True
+
+    assert await paraminfo.convert("1234") == 1234
+    assert await paraminfo.convert("abcd") is None
+    assert await paraminfo.convert("") is None
+
+
+@pytest.mark.asyncio
+async def test_optional_paraminfo_with_default():
+    default = 3
+    param = param_from_annotation(t.Optional[int], default=default)
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    # x: Optional[int] = 3 -> x: Optional[int] = 3  # ...ensure this isn't overwritten
+    assert param == paraminfo.param
+
+    assert paraminfo.default == 3
+    assert paraminfo.optional is True
+
+    assert await paraminfo.convert("1234") == 1234
+    assert await paraminfo.convert("abcd") == default
+    assert await paraminfo.convert("") == default
+
+
+# params.ParamInfo | t.Union
+
+
+@pytest.mark.asyncio
+async def test_union_paraminfo():
+    param = param_from_annotation(t.Union[int, bool])
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    assert param == paraminfo.param
+
+    assert await paraminfo.convert("t") is True
+    assert await paraminfo.convert("6") == 6
+
+    # int before bool -> int takes priority
+    assert 1 == await paraminfo.convert("1") is not True
+
+    # Ensure the reverse holds true...
+    param_switched = param_from_annotation(t.Union[bool, int])
+    paraminfo_switched = components.params.ParamInfo.from_param(param_switched)
+
+    assert await paraminfo_switched.convert("1") is True
+
+
+# params.ParamInfo | t.Literal
+
+
+@pytest.mark.asyncio
+async def test_literal_paraminfo():
+    param = param_from_annotation(t.Literal[1, "a", True])
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    assert param == paraminfo.param
+
+    assert await paraminfo.convert("True") is True
+    assert await paraminfo.convert("a") == "a"
+    assert await paraminfo.convert("1") == 1
+
+    with pytest.raises(components.ConversionError):
+        await paraminfo.convert("something else")
+
+
+# params.ParamInfo | t.Collection
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "annotation, expected",
+    (
+        (t.Set[int], {1, 2, 3}),
+        (t.List[int], [1, 2, 3]),
+        (t.Tuple[int], (1, 2, 3)),
+    ),
+)
+async def test_collection_paraminfo(annotation: t.Any, expected: t.Collection[int]):
+    param = param_from_annotation(annotation)
+    # param = inspect.Parameter("x", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=int)
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    assert param == paraminfo.param
+
+    assert not paraminfo.optional
+
+    assert await paraminfo.convert(["1", "2", "3"]) == expected
+
+
+# params.ParamInfo | components.Converted
+
+
+def to_datetime(arg: str) -> datetime.datetime:
+    return datetime.datetime.fromtimestamp(int(arg), tz=utc)
+
+
+def from_datetime(arg: datetime.datetime) -> str:
+    return str(int(arg.timestamp()))
+
+
+async def to_datetime_async(arg: str) -> datetime.datetime:
+    return to_datetime(arg)
+
+
+async def from_datetime_async(arg: datetime.datetime) -> str:
+    return from_datetime(arg)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("conv_to", (to_datetime, to_datetime_async))
+@pytest.mark.parametrize("conv_from", (from_datetime, from_datetime_async))
+async def test_converted_paraminfo(
+    conv_to: t.Callable[..., t.Any], conv_from: t.Callable[..., t.Any]
+):
+    param = param_from_annotation(
+        components.Converted[components.patterns.STRICTINT, conv_to, conv_from]
+    )
+    paraminfo = components.params.ParamInfo.from_param(param)
+
+    assert param == paraminfo.param
+
+    assert await paraminfo.convert("0") == (dt := datetime.datetime(1970, 1, 1, tzinfo=utc))
+    assert await paraminfo.to_str(dt) == "0"
+
+
+# params.ParamInfo | exc
+
+
+def test_fail_converter_map_paraminfo():
+    param = param_from_annotation(datetime.datetime)
+    with pytest.raises(KeyError):
+        components.params.ParamInfo.from_param(param)
+
+
+def test_fail_unsupported_type_paraminfo():
+    param = param_from_annotation(t.ClassVar[int])
+    with pytest.raises(TypeError):
+        components.params.ParamInfo.from_param(param)
+
+
+# params.ParamInfo | fail conversion
+
+
+# params.SelectValue
+
+
+def test_selectvalue():
+    kwargs = {
+        "placeholder": "blablabla",
+        "min_values": "2",
+        "max_values": "4",
+        "options": ["a", "b", "c", "d", "e"],
+        "disabled": True,
+    }
+
+    from_cls = components.types_.AbstractComponent(type=disnake.ComponentType.select, **kwargs)
+    from_func = components.SelectValue(**kwargs)  # type: ignore
+
+    assert from_cls == from_func
+
+
+# params.ModalValue
+
+
+def test_modalvalue():
+    kwargs = {
+        "placeholder": "blablabla",
+        "label": "something",
+        "value": "something else",
+        "required": False,
+        "min_length": 123,
+        "max_length": 456,
+    }
+
+    short = disnake.TextInputStyle.short
+    long = disnake.TextInputStyle.paragraph
+
+    from_cls_short = components.params._ModalValue(**kwargs)  # type: ignore
+    from_func_short = components.ModalValue(**kwargs)  # type: ignore
+
+    assert from_cls_short.placeholder == from_func_short.placeholder == kwargs["placeholder"]
+    assert from_cls_short.label == from_func_short.label == kwargs["label"]
+    assert from_cls_short.value == from_func_short.value == kwargs["value"]
+    assert from_cls_short.required == from_func_short.required == kwargs["required"]
+    assert from_cls_short.min_length == from_func_short.min_length == kwargs["min_length"]
+    assert from_cls_short.max_length == from_func_short.max_length == kwargs["max_length"]
+    assert from_cls_short.style == from_func_short.style == short
+
+    from_cls_long = components.params._ModalValue(style=long, **kwargs)  # type: ignore
+    from_func_long = components.ParagraphModalValue(**kwargs)  # type: ignore
+
+    assert from_cls_long.placeholder == from_func_long.placeholder == kwargs["placeholder"]
+    assert from_cls_long.label == from_func_long.label == kwargs["label"]
+    assert from_cls_long.value == from_func_long.value == kwargs["value"]
+    assert from_cls_long.required == from_func_long.required == kwargs["required"]
+    assert from_cls_long.min_length == from_func_long.min_length == kwargs["min_length"]
+    assert from_cls_long.max_length == from_func_long.max_length == kwargs["max_length"]
+    assert from_cls_long.style == from_func_long.style == long
diff --git a/tests/test_types.py b/tests/test_types.py
index 0f2f3e7..f890858 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -1,132 +1,44 @@
-import typing as t
-
-import disnake
-import pytest
-from disnake.ext import commands
+import datetime
 
 import disnake_ext_components as components
 
+# types_.ListenerType
 
-@pytest.mark.asyncio
-async def test_int():
-    """Test a listener with int params."""
-
-    class A(commands.Cog):
-        @components.button_listener()
-        async def int_listener(
-            self, inter: disnake.MessageInteraction, int_: int, int_with_default: int = 3
-        ):
-            ...
-
-    actual = A().int_listener
-
-    assert actual.id_spec == "int_listener:{int_}:{int_with_default}"
-
-    params = actual.params
-
-    assert await params[0].convert("123", inter=None) == 123
-    assert await params[0].convert("-123", inter=None) == -123
-
-    with pytest.raises(components.exceptions.ConversionError):
-        await params[0].convert("1.23", inter=None)
-
-    assert await params[1].convert("123", inter=None) == 123
-    assert await params[1].convert("1.23", inter=None) == 3
-
-
-@pytest.mark.asyncio
-async def test_bool():
-    """Test a listener with bool param."""
-
-    class A(commands.Cog):
-        @components.button_listener(sep=":")
-        async def bool_listener(self, inter: disnake.MessageInteraction, bool_: bool):
-            ...
-
-    actual = A().bool_listener
-    param = actual.params[0]
-    trues = ["true", "t", "yes", "y", "1", "enable", "on"]
-    falses = ["false", "f", "no", "n", "0", "disable", "off"]
-
-    assert all([await param.convert(true, inter=None) is True for true in trues])
-    assert all([await param.convert(false, inter=None) is False for false in falses])
-
-    with pytest.raises(components.exceptions.ConversionError):
-        await param.convert("1.23", inter=None)
-
-
-@pytest.mark.asyncio
-async def test_unannotated():
-    """Test a listener with unannotated param."""
-
-    class A(commands.Cog):
-        @components.button_listener(sep=":")
-        async def unannotated_listener(
-            self, inter: disnake.MessageInteraction, please_use_typehints
-        ):
-            ...
-
-    actual = A().unannotated_listener
-    param = actual.params[0]
-    assert await param.convert("pretty please", inter=None) == "pretty please"
-
-
-@pytest.mark.asyncio
-async def test_union():
-    """Test a listener with union param."""
-
-    class A(commands.Cog):
-        @components.button_listener(sep=":")
-        async def union_listener(
-            self, inter: disnake.MessageInteraction, int_or_bool: t.Union[int, bool]
-        ):
-            ...
-
-    actual = A().union_listener
-    param = actual.params[0]
-
-    assert await param.convert("123", inter=None) == 123
-    assert await param.convert("true", inter=None) is True
 
-    with pytest.raises(components.exceptions.ConversionError):
-        await param.convert("abc", inter=None)
+def test_listenertype_const():
 
+    LT = components.ListenerType
 
-@pytest.mark.asyncio
-async def test_optional():
-    """Test a listener with optional param."""
+    assert LT.BUTTON == "on_button_click"
+    assert LT.SELECT == LT.DROPDOWN == "on_dropdown"
+    assert LT.MESSAGE_INTERACTION == LT.COMPONENT == "on_message_interaction"
+    assert LT.MODAL == "on_modal_submit"
 
-    class A(commands.Cog):
-        @components.button_listener(sep=":")
-        async def optional_listener(
-            self, inter: disnake.MessageInteraction, optional: t.Optional[int]
-        ):
-            ...
 
-    actual = A().optional_listener
-    param = actual.params[0]
+# types_.Converted
 
-    assert await param.convert("123", inter=None) == 123
-    assert await param.convert("true", inter=None) is None
 
+def test_converter():
+    def to_datetime(arg: str) -> datetime.datetime:
+        return datetime.datetime.fromtimestamp(int(arg))
 
-@pytest.mark.asyncio
-async def test_literal():
-    """Test a listener with literal param."""
+    def from_datetime(arg: datetime.datetime) -> str:
+        return str(arg.timestamp())
 
-    class A(commands.Cog):
-        @components.button_listener(sep=":")
-        async def literal_listener(
-            self, inter: disnake.MessageInteraction, literal: t.Literal["sheesh", 123, True]
-        ):
-            ...
+    converter_annotation = components.Converted[
+        components.patterns.STRICTINT,
+        to_datetime,
+        from_datetime,
+    ]
 
-    actual = A().literal_listener
-    param = actual.params[0]
+    actual_converter = components.Converted(
+        components.patterns.STRICTINT,
+        to_datetime,
+        from_datetime,
+    )
 
-    assert await param.convert("sheesh", inter=None) == "sheesh"
-    assert await param.convert("123", inter=None) == 123
-    assert await param.convert("True", inter=None) is True
+    assert isinstance(converter_annotation, components.Converted)
 
-    with pytest.raises(components.exceptions.ConversionError):
-        await param.convert("something dumb", inter=None)
+    assert converter_annotation.regex == actual_converter.regex
+    assert converter_annotation.converter_to == actual_converter.converter_to
+    assert converter_annotation.converter_from == actual_converter.converter_from
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 5cb1ae7..a0513bd 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,83 +1,104 @@
-import disnake
+import inspect
+import re
+import typing as t
+
 import pytest
-from disnake.ext import commands
 
 import disnake_ext_components as components
 
+# utils.ensure_compiled
 
-@pytest.mark.asyncio
-async def test_incorrect_listener():
-    """Test decorating a listener without annotated disnake.MessageInteraction."""
 
-    with pytest.raises(TypeError):
+def test_compile():
+    pat = components.utils.ensure_compiled("abc")
+
+    assert pat.pattern == "abc"
+    assert pat.flags == re.UNICODE
+
+
+def test_compile_flag():
+    pat = components.utils.ensure_compiled("abc", flags=re.DOTALL | re.IGNORECASE)
+
+    assert pat.pattern == "abc"
+    assert pat.flags == re.DOTALL | re.IGNORECASE | re.UNICODE
+
+
+def test_precompiled():
+    pre_pat = re.compile("abc")
+    pat = components.utils.ensure_compiled(pre_pat)
+
+    assert pat == pre_pat
+
+
+# utils.extract_listener_params
 
-        class A(commands.Cog):
-            @components.button_listener()
-            async def incorrect_listener(self, inter, a):
-                pass
 
+def test_button_params(button_listener_callback: t.Callable[..., t.Any]):
+    sig = inspect.signature(button_listener_callback)
+    params = sig.parameters
 
-@pytest.mark.asyncio
-async def test_id_builder_pass(inter: disnake.MessageInteraction):
-    """Test ComponentListener.build_custom_id."""
+    button_params, custom_id_params = components.utils.extract_listener_params(sig)
+    assert button_params == ()
+    assert custom_id_params == (params["foo"], params["bar"])
 
-    class A(commands.Cog):
-        @components.button_listener()
-        async def listener_(
-            self,
-            inter: disnake.MessageInteraction,
-            a: int,
-            b: str,
-            c: bool,
-            d: float,
-        ):
-            return a, b, c, d
 
-    actual = A().listener_
+def test_select_params(select_listener_callback: t.Callable[..., t.Any]):
+    sig = inspect.signature(select_listener_callback)
+    params = sig.parameters
 
-    abcd = (1, "b", True, 1.12)
-    custom_id = actual.build_custom_id(*abcd)
-    assert custom_id == "listener_:1:b:True:1.12"
+    select_params, custom_id_params = components.utils.extract_listener_params(sig)
+    assert select_params == (params["selected"],)
+    assert custom_id_params == (params["foo"], params["bar"])
 
-    inter.component.custom_id = custom_id
-    assert await actual(inter) == abcd
 
+def test_modal_params(modal_listener_callback: t.Callable[..., t.Any]):
+    sig = inspect.signature(modal_listener_callback)
+    params = sig.parameters
 
-def test_id_builder_overlap():
-    """Test the failure procedure for passing overlapping args to ComponentListener.build_custom_id."""
+    modal_params, custom_id_params = components.utils.extract_listener_params(sig)
+    assert modal_params == (params["field_A"], params["field_B"])
+    assert custom_id_params == (params["foo"], params["bar"])
 
-    class A(commands.Cog):
-        @components.button_listener()
-        async def listener_(
-            self,
-            inter: disnake.MessageInteraction,
-            a: int,
-            b: str,
-        ):
-            return a, b
 
-    actual = A().listener_
+def test_no_inter():
+    async def no_inter_callback(foo: int, bar: str):
+        ...
 
-    assert actual.build_custom_id(1, "a") == "listener_:1:a"
+    sig = inspect.signature(no_inter_callback)
 
     with pytest.raises(TypeError):
-        actual.build_custom_id(1, a="1")
+        components.utils.extract_listener_params(sig)
+
+
+# utils.id_spec_from_signature
+
+
+def test_auto_spec_button(button_listener_callback: t.Callable[..., t.Any]):
+    sig = inspect.signature(button_listener_callback)
+
+    spec = components.utils.id_spec_from_signature("name", "|", sig)
+    assert spec == "name|{foo}|{bar}"
+
+
+def test_auto_spec_select(select_listener_callback: t.Callable[..., t.Any]):
+    sig = inspect.signature(select_listener_callback)
+
+    spec = components.utils.id_spec_from_signature("name", "|", sig)
+    assert spec == "name|{foo}|{bar}"
+
+
+def test_auto_spec_modal(modal_listener_callback: t.Callable[..., t.Any]):
+    sig = inspect.signature(modal_listener_callback)
+
+    spec = components.utils.id_spec_from_signature("name", "|", sig)
+    assert spec == "name|{foo}|{bar}"
 
 
-def test_id_builder_regex():
-    """Test ComponentListener.build_custom_id for listeners with custom regex."""
+# utils.id_spec_from_regex
 
-    class A(commands.Cog):
-        @components.button_listener(regex=r"blah(?P<a>.+)/(?P<b>/+)")
-        async def listener_(
-            self,
-            inter: disnake.MessageInteraction,
-            a: int,
-            b: str,
-        ):
-            return a, b
 
-    actual = A().listener_
+def test_regex_spec():
+    pat = re.compile(r"something:(?P<foo>...):(?P<bar>...)")
 
-    assert actual.id_spec == "blah{a}/{b}"
-    assert actual.build_custom_id(1, "a") == "blah1/a"
+    spec = components.utils.id_spec_from_regex(pat)
+    assert spec == "something:{foo}:{bar}"