diff --git a/CHANGES.rst b/CHANGES.rst
index 83d959125e..dc406e1bbd 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,9 @@ Version 8.1.5
 
 Unreleased
 
+-   Fix type hints for ``@click.command()`` and ``@click.option()``. Introduce typing
+    tests. :issue:`2558`
+
 
 Version 8.1.4
 -------------
diff --git a/requirements/dev.txt b/requirements/dev.txt
index ed462080af..1a4ecc7b57 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -30,8 +30,6 @@ filelock==3.12.2
     #   virtualenv
 identify==2.5.24
     # via pre-commit
-nodeenv==1.8.0
-    # via pre-commit
 pip-compile-multi==2.6.3
     # via -r requirements/dev.in
 pip-tools==6.13.0
diff --git a/requirements/typing.in b/requirements/typing.in
index f0aa93ac8e..a20f06c424 100644
--- a/requirements/typing.in
+++ b/requirements/typing.in
@@ -1 +1,2 @@
 mypy
+pyright
diff --git a/requirements/typing.txt b/requirements/typing.txt
index 13afd660ed..f04fde5dd1 100644
--- a/requirements/typing.txt
+++ b/requirements/typing.txt
@@ -1,4 +1,4 @@
-# SHA1:7983aaa01d64547827c20395d77e248c41b2572f
+# SHA1:0d25c235a98f3c8c55aefb59b91c82834e185f0a
 #
 # This file is autogenerated by pip-compile-multi
 # To update, run:
@@ -9,5 +9,12 @@ mypy==1.4.1
     # via -r requirements/typing.in
 mypy-extensions==1.0.0
     # via mypy
+nodeenv==1.8.0
+    # via pyright
+pyright==1.1.317
+    # via -r requirements/typing.in
 typing-extensions==4.6.3
     # via mypy
+
+# The following packages are considered to be unsafe in a requirements file:
+# setuptools
diff --git a/setup.cfg b/setup.cfg
index 7446f699b9..d5fcdcf011 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -78,7 +78,7 @@ per-file-ignores =
     src/click/__init__.py: F401
 
 [mypy]
-files = src/click
+files = src/click, tests/typing
 python_version = 3.7
 show_error_codes = True
 disallow_subclassing_any = True
diff --git a/src/click/decorators.py b/src/click/decorators.py
index 797449d630..674aee53bc 100644
--- a/src/click/decorators.py
+++ b/src/click/decorators.py
@@ -21,7 +21,6 @@
 R = t.TypeVar("R")
 T = t.TypeVar("T")
 _AnyCallable = t.Callable[..., t.Any]
-_Decorator: "te.TypeAlias" = t.Callable[[T], T]
 FC = t.TypeVar("FC", bound=t.Union[_AnyCallable, Command])
 
 
@@ -150,16 +149,12 @@ def command(
     ...
 
 
-# variant: name omitted, cls _must_ be a keyword argument, @command(cmd=CommandCls, ...)
-# The correct way to spell this overload is to use keyword-only argument syntax:
-# def command(*, cls: t.Type[CmdType], **attrs: t.Any) -> ...
-# However, mypy thinks this doesn't fit the overloaded function. Pyright does
-# accept that spelling, and the following work-around makes pyright issue a
-# warning that CmdType could be left unsolved, but mypy sees it as fine. *shrug*
+# variant: name omitted, cls _must_ be a keyword argument, @command(cls=CommandCls, ...)
 @t.overload
 def command(
     name: None = None,
-    cls: t.Type[CmdType] = ...,
+    *,
+    cls: t.Type[CmdType],
     **attrs: t.Any,
 ) -> t.Callable[[_AnyCallable], CmdType]:
     ...
@@ -331,7 +326,7 @@ def _param_memo(f: t.Callable[..., t.Any], param: Parameter) -> None:
 
 def argument(
     *param_decls: str, cls: t.Optional[t.Type[Argument]] = None, **attrs: t.Any
-) -> _Decorator[FC]:
+) -> t.Callable[[FC], FC]:
     """Attaches an argument to the command.  All positional arguments are
     passed as parameter declarations to :class:`Argument`; all keyword
     arguments are forwarded unchanged (except ``cls``).
@@ -359,7 +354,7 @@ def decorator(f: FC) -> FC:
 
 def option(
     *param_decls: str, cls: t.Optional[t.Type[Option]] = None, **attrs: t.Any
-) -> _Decorator[FC]:
+) -> t.Callable[[FC], FC]:
     """Attaches an option to the command.  All positional arguments are
     passed as parameter declarations to :class:`Option`; all keyword
     arguments are forwarded unchanged (except ``cls``).
@@ -385,7 +380,7 @@ def decorator(f: FC) -> FC:
     return decorator
 
 
-def confirmation_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]:
+def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
     """Add a ``--yes`` option which shows a prompt before continuing if
     not passed. If the prompt is declined, the program will exit.
 
@@ -409,7 +404,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None:
     return option(*param_decls, **kwargs)
 
 
-def password_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]:
+def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
     """Add a ``--password`` option which prompts for a password, hiding
     input and asking to enter the value again for confirmation.
 
@@ -433,7 +428,7 @@ def version_option(
     prog_name: t.Optional[str] = None,
     message: t.Optional[str] = None,
     **kwargs: t.Any,
-) -> _Decorator[FC]:
+) -> t.Callable[[FC], FC]:
     """Add a ``--version`` option which immediately prints the version
     number and exits the program.
 
@@ -539,7 +534,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None:
     return option(*param_decls, **kwargs)
 
 
-def help_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]:
+def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
     """Add a ``--help`` option which immediately prints the help page
     and exits the program.
 
diff --git a/tests/typing/typing_aliased_group.py b/tests/typing/typing_aliased_group.py
new file mode 100644
index 0000000000..ccc706bc53
--- /dev/null
+++ b/tests/typing/typing_aliased_group.py
@@ -0,0 +1,45 @@
+"""Example from https://click.palletsprojects.com/en/8.1.x/advanced/#command-aliases"""
+from __future__ import annotations
+
+from typing_extensions import assert_type
+
+import click
+
+
+class AliasedGroup(click.Group):
+    def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
+        rv = click.Group.get_command(self, ctx, cmd_name)
+        if rv is not None:
+            return rv
+        matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
+        if not matches:
+            return None
+        elif len(matches) == 1:
+            return click.Group.get_command(self, ctx, matches[0])
+        ctx.fail(f"Too many matches: {', '.join(sorted(matches))}")
+
+    def resolve_command(
+        self, ctx: click.Context, args: list[str]
+    ) -> tuple[str | None, click.Command, list[str]]:
+        # always return the full command name
+        _, cmd, args = super().resolve_command(ctx, args)
+        assert cmd is not None
+        return cmd.name, cmd, args
+
+
+@click.command(cls=AliasedGroup)
+def cli() -> None:
+    pass
+
+
+assert_type(cli, AliasedGroup)
+
+
+@cli.command()
+def push() -> None:
+    pass
+
+
+@cli.command()
+def pop() -> None:
+    pass
diff --git a/tests/typing/typing_confirmation_option.py b/tests/typing/typing_confirmation_option.py
new file mode 100644
index 0000000000..09ecaa96da
--- /dev/null
+++ b/tests/typing/typing_confirmation_option.py
@@ -0,0 +1,13 @@
+"""From https://click.palletsprojects.com/en/8.1.x/options/#yes-parameters"""
+from typing_extensions import assert_type
+
+import click
+
+
+@click.command()
+@click.confirmation_option(prompt="Are you sure you want to drop the db?")
+def dropdb() -> None:
+    click.echo("Dropped all tables!")
+
+
+assert_type(dropdb, click.Command)
diff --git a/tests/typing/typing_help_option.py b/tests/typing/typing_help_option.py
new file mode 100644
index 0000000000..6b822c1314
--- /dev/null
+++ b/tests/typing/typing_help_option.py
@@ -0,0 +1,13 @@
+from typing_extensions import assert_type
+
+import click
+
+
+@click.command()
+@click.help_option("-h", "--help")
+def hello() -> None:
+    """Simple program that greets NAME for a total of COUNT times."""
+    click.echo("Hello!")
+
+
+assert_type(hello, click.Command)
diff --git a/tests/typing/typing_options.py b/tests/typing/typing_options.py
new file mode 100644
index 0000000000..6a6611928e
--- /dev/null
+++ b/tests/typing/typing_options.py
@@ -0,0 +1,15 @@
+"""From https://click.palletsprojects.com/en/8.1.x/quickstart/#adding-parameters"""
+from typing_extensions import assert_type
+
+import click
+
+
+@click.command()
+@click.option("--count", default=1, help="number of greetings")
+@click.argument("name")
+def hello(count: int, name: str) -> None:
+    for _ in range(count):
+        click.echo(f"Hello {name}!")
+
+
+assert_type(hello, click.Command)
diff --git a/tests/typing/typing_password_option.py b/tests/typing/typing_password_option.py
new file mode 100644
index 0000000000..8537e6ff59
--- /dev/null
+++ b/tests/typing/typing_password_option.py
@@ -0,0 +1,14 @@
+import codecs
+
+from typing_extensions import assert_type
+
+import click
+
+
+@click.command()
+@click.password_option()
+def encrypt(password: str) -> None:
+    click.echo(f"encoded: to {codecs.encode(password, 'rot13')}")
+
+
+assert_type(encrypt, click.Command)
diff --git a/tests/typing/typing_simple_example.py b/tests/typing/typing_simple_example.py
new file mode 100644
index 0000000000..0a94b8cb78
--- /dev/null
+++ b/tests/typing/typing_simple_example.py
@@ -0,0 +1,16 @@
+"""The simple example from https://github.com/pallets/click#a-simple-example."""
+from typing_extensions import assert_type
+
+import click
+
+
+@click.command()
+@click.option("--count", default=1, help="Number of greetings.")
+@click.option("--name", prompt="Your name", help="The person to greet.")
+def hello(count: int, name: str) -> None:
+    """Simple program that greets NAME for a total of COUNT times."""
+    for _ in range(count):
+        click.echo(f"Hello, {name}!")
+
+
+assert_type(hello, click.Command)
diff --git a/tests/typing/typing_version_option.py b/tests/typing/typing_version_option.py
new file mode 100644
index 0000000000..fd473a001b
--- /dev/null
+++ b/tests/typing/typing_version_option.py
@@ -0,0 +1,15 @@
+"""
+From https://click.palletsprojects.com/en/8.1.x/options/#callbacks-and-eager-options.
+"""
+from typing_extensions import assert_type
+
+import click
+
+
+@click.command()
+@click.version_option("0.1")
+def hello() -> None:
+    click.echo("Hello World!")
+
+
+assert_type(hello, click.Command)
diff --git a/tox.ini b/tox.ini
index 1b163ff5a0..df53602287 100644
--- a/tox.ini
+++ b/tox.ini
@@ -20,7 +20,10 @@ commands = pre-commit run --all-files
 
 [testenv:typing]
 deps = -r requirements/typing.txt
-commands = mypy
+commands =
+    mypy
+    pyright --verifytypes click
+    pyright tests/typing
 
 [testenv:docs]
 deps = -r requirements/docs.txt
