diff --git a/click_repl/_completer.py b/click_repl/_completer.py
index 1f64fa0..73a769c 100644
--- a/click_repl/_completer.py
+++ b/click_repl/_completer.py
@@ -1,10 +1,13 @@
-from __future__ import unicode_literals
+from __future__ import annotations
 
 import os
+import typing as t
 from glob import iglob
+from typing import Generator
 
 import click
-from prompt_toolkit.completion import Completion, Completer
+from prompt_toolkit.completion import CompleteEvent, Completer, Completion
+from prompt_toolkit.document import Document
 
 from .utils import _resolve_context, split_arg_string
 
@@ -26,17 +29,19 @@
     AUTO_COMPLETION_PARAM = "autocompletion"
 
 
-def text_type(text):
-    return "{}".format(text)
-
-
 class ClickCompleter(Completer):
     __slots__ = ("cli", "ctx", "parsed_args", "parsed_ctx", "ctx_command")
 
-    def __init__(self, cli, ctx, show_only_unused=False, shortest_only=False):
+    def __init__(
+        self,
+        cli: click.MultiCommand,
+        ctx: click.Context,
+        show_only_unused: bool = False,
+        shortest_only: bool = False,
+    ) -> None:
         self.cli = cli
         self.ctx = ctx
-        self.parsed_args = []
+        self.parsed_args: list[str] = []
         self.parsed_ctx = ctx
         self.ctx_command = ctx.command
         self.show_only_unused = show_only_unused
@@ -44,12 +49,12 @@ def __init__(self, cli, ctx, show_only_unused=False, shortest_only=False):
 
     def _get_completion_from_autocompletion_functions(
         self,
-        param,
-        autocomplete_ctx,
-        args,
-        incomplete,
-    ):
-        param_choices = []
+        param: click.Parameter,
+        autocomplete_ctx: click.Context,
+        args: list[str],
+        incomplete: str,
+    ) -> list[Completion]:
+        param_choices: list[Completion] = []
 
         if HAS_CLICK_V8:
             autocompletions = param.shell_complete(autocomplete_ctx, incomplete)
@@ -62,7 +67,7 @@ def _get_completion_from_autocompletion_functions(
             if isinstance(autocomplete, tuple):
                 param_choices.append(
                     Completion(
-                        text_type(autocomplete[0]),
+                        str(autocomplete[0]),
                         -len(incomplete),
                         display_meta=autocomplete[1],
                     )
@@ -71,46 +76,48 @@ def _get_completion_from_autocompletion_functions(
             elif HAS_CLICK_V8 and isinstance(
                 autocomplete, click.shell_completion.CompletionItem
             ):
-                param_choices.append(
-                    Completion(text_type(autocomplete.value), -len(incomplete))
-                )
+                param_choices.append(Completion(autocomplete.value, -len(incomplete)))
 
             else:
-                param_choices.append(
-                    Completion(text_type(autocomplete), -len(incomplete))
-                )
+                param_choices.append(Completion(str(autocomplete), -len(incomplete)))
 
         return param_choices
 
-    def _get_completion_from_choices_click_le_7(self, param, incomplete):
+    def _get_completion_from_choices_click_le_7(
+        self, param: click.Parameter, incomplete: str
+    ) -> list[Completion]:
+        param_type = t.cast(click.Choice, param.type)
+
         if not getattr(param.type, "case_sensitive", True):
             incomplete = incomplete.lower()
             return [
                 Completion(
-                    text_type(choice),
+                    choice,
                     -len(incomplete),
-                    display=text_type(repr(choice) if " " in choice else choice),
+                    display=repr(choice) if " " in choice else choice,
                 )
-                for choice in param.type.choices  # type: ignore[attr-defined]
+                for choice in param_type.choices  # type: ignore[attr-defined]
                 if choice.lower().startswith(incomplete)
             ]
 
         else:
             return [
                 Completion(
-                    text_type(choice),
+                    choice,
                     -len(incomplete),
-                    display=text_type(repr(choice) if " " in choice else choice),
+                    display=repr(choice) if " " in choice else choice,
                 )
-                for choice in param.type.choices  # type: ignore[attr-defined]
+                for choice in param_type.choices  # type: ignore[attr-defined]
                 if choice.startswith(incomplete)
             ]
 
-    def _get_completion_for_Path_types(self, param, args, incomplete):
+    def _get_completion_for_Path_types(
+        self, param: click.Parameter, args: list[str], incomplete: str
+    ) -> list[Completion]:
         if "*" in incomplete:
             return []
 
-        choices = []
+        choices: list[Completion] = []
         _incomplete = os.path.expandvars(incomplete)
         search_pattern = _incomplete.strip("'\"\t\n\r\v ").replace("\\\\", "\\") + "*"
         quote = ""
@@ -134,29 +141,36 @@ def _get_completion_for_Path_types(self, param, args, incomplete):
 
             choices.append(
                 Completion(
-                    text_type(path),
+                    path,
                     -len(incomplete),
-                    display=text_type(os.path.basename(path.strip("'\""))),
+                    display=os.path.basename(path.strip("'\"")),
                 )
             )
 
         return choices
 
-    def _get_completion_for_Boolean_type(self, param, incomplete):
+    def _get_completion_for_Boolean_type(
+        self, param: click.Parameter, incomplete: str
+    ) -> list[Completion]:
+        boolean_mapping: dict[str, tuple[str, ...]] = {
+            "true": ("1", "true", "t", "yes", "y", "on"),
+            "false": ("0", "false", "f", "no", "n", "off"),
+        }
+
         return [
-            Completion(
-                text_type(k), -len(incomplete), display_meta=text_type("/".join(v))
-            )
-            for k, v in {
-                "true": ("1", "true", "t", "yes", "y", "on"),
-                "false": ("0", "false", "f", "no", "n", "off"),
-            }.items()
+            Completion(k, -len(incomplete), display_meta="/".join(v))
+            for k, v in boolean_mapping.items()
             if any(i.startswith(incomplete) for i in v)
         ]
 
-    def _get_completion_from_params(self, autocomplete_ctx, args, param, incomplete):
-
-        choices = []
+    def _get_completion_from_params(
+        self,
+        autocomplete_ctx: click.Context,
+        args: list[str],
+        param: click.Parameter,
+        incomplete: str,
+    ) -> list[Completion]:
+        choices: list[Completion] = []
         param_type = param.type
 
         # shell_complete method for click.Choice is intorduced in click-v8
@@ -185,12 +199,12 @@ def _get_completion_from_params(self, autocomplete_ctx, args, param, incomplete)
 
     def _get_completion_for_cmd_args(
         self,
-        ctx_command,
-        incomplete,
-        autocomplete_ctx,
-        args,
-    ):
-        choices = []
+        ctx_command: click.Command,
+        incomplete: str,
+        autocomplete_ctx: click.Context,
+        args: list[str],
+    ) -> list[Completion]:
+        choices: list[Completion] = []
         param_called = False
 
         for param in ctx_command.params:
@@ -229,9 +243,9 @@ def _get_completion_for_cmd_args(
                     elif option.startswith(incomplete) and not hide:
                         choices.append(
                             Completion(
-                                text_type(option),
+                                option,
                                 -len(incomplete),
-                                display_meta=text_type(param.help or ""),
+                                display_meta=param.help or "",
                             )
                         )
 
@@ -250,12 +264,14 @@ def _get_completion_for_cmd_args(
 
         return choices
 
-    def get_completions(self, document, complete_event=None):
+    def get_completions(
+        self, document: Document, complete_event: CompleteEvent | None = None
+    ) -> Generator[Completion, None, None]:
         # Code analogous to click._bashcomplete.do_complete
 
         args = split_arg_string(document.text_before_cursor, posix=False)
 
-        choices = []
+        choices: list[Completion] = []
         cursor_within_command = (
             document.text_before_cursor.rstrip() == document.text_before_cursor
         )
@@ -277,7 +293,7 @@ def get_completions(self, document, complete_event=None):
             try:
                 self.parsed_ctx = _resolve_context(args, self.ctx)
             except Exception:
-                return []  # autocompletion for nonexistent cmd can throw here
+                return  # autocompletion for nonexistent cmd can throw here
             self.ctx_command = self.parsed_ctx.command
 
         if getattr(self.ctx_command, "hidden", False):
@@ -301,7 +317,7 @@ def get_completions(self, document, complete_event=None):
                     elif name.lower().startswith(incomplete_lower):
                         choices.append(
                             Completion(
-                                text_type(name),
+                                name,
                                 -len(incomplete),
                                 display_meta=getattr(command, "short_help", ""),
                             )
@@ -310,10 +326,5 @@ def get_completions(self, document, complete_event=None):
         except Exception as e:
             click.echo("{}: {}".format(type(e).__name__, str(e)))
 
-        # If we are inside a parameter that was called, we want to show only
-        # relevant choices
-        # if param_called:
-        #     choices = param_choices
-
         for item in choices:
             yield item
diff --git a/click_repl/_repl.py b/click_repl/_repl.py
index 0445182..4199add 100644
--- a/click_repl/_repl.py
+++ b/click_repl/_repl.py
@@ -1,29 +1,30 @@
-from __future__ import with_statement
+from __future__ import annotations
 
-import click
 import sys
+from typing import Any, MutableMapping, cast
+
+import click
 from prompt_toolkit.history import InMemoryHistory
 
 from ._completer import ClickCompleter
+from .core import ReplContext
 from .exceptions import ClickExit  # type: ignore[attr-defined]
 from .exceptions import CommandLineParserError, ExitReplException, InvalidGroupFormat
-from .utils import _execute_internal_and_sys_cmds
-from .core import ReplContext
 from .globals_ import ISATTY, get_current_repl_ctx
-
+from .utils import _execute_internal_and_sys_cmds
 
 __all__ = ["bootstrap_prompt", "register_repl", "repl"]
 
 
 def bootstrap_prompt(
-    group,
-    prompt_kwargs,
-    ctx=None,
-):
+    group: click.MultiCommand,
+    prompt_kwargs: dict[str, Any],
+    ctx: click.Context,
+) -> dict[str, Any]:
     """
     Bootstrap prompt_toolkit kwargs or use user defined values.
 
-    :param group: click Group
+    :param group: click.MultiCommand object
     :param prompt_kwargs: The user specified prompt kwargs.
     """
 
@@ -38,8 +39,11 @@ def bootstrap_prompt(
 
 
 def repl(
-    old_ctx, prompt_kwargs={}, allow_system_commands=True, allow_internal_commands=True
-):
+    old_ctx: click.Context,
+    prompt_kwargs: dict[str, Any] = {},
+    allow_system_commands: bool = True,
+    allow_internal_commands: bool = True,
+) -> None:
     """
     Start an interactive shell. All subcommands are available in it.
 
@@ -54,10 +58,12 @@ def repl(
     group_ctx = old_ctx
     # Switching to the parent context that has a Group as its command
     # as a Group acts as a CLI for all of its subcommands
-    if old_ctx.parent is not None and not isinstance(old_ctx.command, click.Group):
+    if old_ctx.parent is not None and not isinstance(
+        old_ctx.command, click.MultiCommand
+    ):
         group_ctx = old_ctx.parent
 
-    group = group_ctx.command
+    group = cast(click.MultiCommand, group_ctx.command)
 
     # An Optional click.Argument in the CLI Group, that has no value
     # will consume the first word from the REPL input, causing issues in
@@ -66,7 +72,7 @@ def repl(
     for param in group.params:
         if (
             isinstance(param, click.Argument)
-            and group_ctx.params[param.name] is None
+            and group_ctx.params[param.name] is None  # type: ignore[index]
             and not param.required
         ):
             raise InvalidGroupFormat(
@@ -78,16 +84,20 @@ def repl(
     # nesting REPLs (note: pass `None` to `pop` as we don't want to error if
     # REPL command already not present for some reason).
     repl_command_name = old_ctx.command.name
-    if isinstance(group_ctx.command, click.CommandCollection):
+
+    available_commands: MutableMapping[str, click.Command] = {}
+
+    if isinstance(group, click.CommandCollection):
         available_commands = {
-            cmd_name: cmd_obj
-            for source in group_ctx.command.sources
-            for cmd_name, cmd_obj in source.commands.items()
+            cmd_name: source.get_command(group_ctx, cmd_name)  # type: ignore[misc]
+            for source in group.sources
+            for cmd_name in source.list_commands(group_ctx)
         }
-    else:
-        available_commands = group_ctx.command.commands
 
-    original_command = available_commands.pop(repl_command_name, None)
+    elif isinstance(group, click.Group):
+        available_commands = group.commands
+
+    original_command = available_commands.pop(repl_command_name, None)  # type: ignore
 
     repl_ctx = ReplContext(
         group_ctx,
@@ -152,9 +162,9 @@ def get_command() -> str:
                 break
 
     if original_command is not None:
-        available_commands[repl_command_name] = original_command
+        available_commands[repl_command_name] = original_command  # type: ignore[index]
 
 
-def register_repl(group, name="repl"):
+def register_repl(group: click.Group, name="repl") -> None:
     """Register :func:`repl()` as sub-command *name* of *group*."""
     group.command(name=name)(click.pass_context(repl))
diff --git a/click_repl/globals_.py b/click_repl/globals_.py
index 6a73652..3e1f49b 100644
--- a/click_repl/globals_.py
+++ b/click_repl/globals_.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import sys
-from typing import TYPE_CHECKING, NoReturn
+from typing import TYPE_CHECKING, NoReturn, overload
 
 from ._ctx_stack import _context_stack
 
@@ -12,6 +12,16 @@
 ISATTY = sys.stdin.isatty()
 
 
+@overload
+def get_current_repl_ctx() -> ReplContext | NoReturn:
+    ...
+
+
+@overload
+def get_current_repl_ctx(silent: bool = False) -> ReplContext | NoReturn | None:
+    ...
+
+
 def get_current_repl_ctx(silent: bool = False) -> ReplContext | NoReturn | None:
     """
     Retrieves the current click-repl context.
diff --git a/click_repl/py.typed b/click_repl/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/click_repl/utils.py b/click_repl/utils.py
index 9aa9800..9f029f7 100644
--- a/click_repl/utils.py
+++ b/click_repl/utils.py
@@ -1,11 +1,19 @@
-import click
+from __future__ import annotations
+
 import os
 import shlex
-import sys
+import typing as t
 from collections import defaultdict
+from typing import Callable, Generator, Iterator, NoReturn, Sequence
+
+import click
+from typing_extensions import TypeAlias
 
 from .exceptions import CommandLineParserError, ExitReplException
 
+T = t.TypeVar("T")
+InternalCommandCallback: TypeAlias = Callable[[], None]
+
 
 __all__ = [
     "_execute_internal_and_sys_cmds",
@@ -21,15 +29,7 @@
 ]
 
 
-# Abstract datatypes in collections module are moved to collections.abc
-# module in Python 3.3
-if sys.version_info >= (3, 3):
-    from collections.abc import Iterable, Mapping  # noqa: F811
-else:
-    from collections import Iterable, Mapping
-
-
-def _resolve_context(args, ctx=None):
+def _resolve_context(args: list[str], ctx: click.Context) -> click.Context:
     """Produce the context hierarchy starting with the command and
     traversing the complete arguments. This only follows the commands,
     it doesn't trigger input prompts or callbacks.
@@ -75,10 +75,10 @@ def _resolve_context(args, ctx=None):
     return ctx
 
 
-_internal_commands = {}
+_internal_commands: dict[str, tuple[InternalCommandCallback, str | None]] = {}
 
 
-def split_arg_string(string, posix=True):
+def split_arg_string(string: str, posix: bool = True) -> list[str]:
     """Split an argument string as with :func:`shlex.split`, but don't
     fail if the string is incomplete. Ignores a missing closing quote or
     incomplete escape sequence and uses the partial token as-is.
@@ -107,16 +107,20 @@ def split_arg_string(string, posix=True):
     return out
 
 
-def _register_internal_command(names, target, description=None):
+def _register_internal_command(
+    names: str | Sequence[str] | Generator[str, None, None] | Iterator[str],
+    target: InternalCommandCallback,
+    description: str | None = None,
+) -> None:
     if not hasattr(target, "__call__"):
         raise ValueError("Internal command must be a callable")
 
     if isinstance(names, str):
         names = [names]
 
-    elif isinstance(names, Mapping) or not isinstance(names, Iterable):
+    elif not isinstance(names, (Sequence, Generator, Iterator)):
         raise ValueError(
-            '"names" must be a string, or an iterable object, but got "{}"'.format(
+            '"names" must be a string, or a Sequence of strings, but got "{}"'.format(
                 type(names).__name__
             )
         )
@@ -125,18 +129,20 @@ def _register_internal_command(names, target, description=None):
         _internal_commands[name] = (target, description)
 
 
-def _get_registered_target(name, default=None):
-    target_info = _internal_commands.get(name)
+def _get_registered_target(
+    name: str, default: T | None = None
+) -> InternalCommandCallback | T | None:
+    target_info = _internal_commands.get(name, None)
     if target_info:
         return target_info[0]
     return default
 
 
-def _exit_internal():
+def _exit_internal() -> NoReturn:
     raise ExitReplException()
 
 
-def _help_internal():
+def _help_internal() -> None:
     formatter = click.HelpFormatter()
     formatter.write_heading("REPL help")
     formatter.indent()
@@ -159,8 +165,7 @@ def _help_internal():
             for description, mnemonics in info_table.items()
         )
 
-    val = formatter.getvalue()  # type: str
-    return val
+    print(formatter.getvalue())
 
 
 _register_internal_command(["q", "quit", "exit"], _exit_internal, "exits the repl")
@@ -170,21 +175,19 @@ def _help_internal():
 
 
 def _execute_internal_and_sys_cmds(
-    command,
-    allow_internal_commands=True,
-    allow_system_commands=True,
-):
+    command: str,
+    allow_internal_commands: bool = True,
+    allow_system_commands: bool = True,
+) -> list[str] | None:
     """
     Executes internal, system, and all the other registered click commands from the input
     """
     if allow_system_commands and dispatch_repl_commands(command):
         return None
 
-    if allow_internal_commands:
-        result = handle_internal_commands(command)
-        if isinstance(result, str):
-            click.echo(result)
-            return None
+    if allow_internal_commands and command.startswith(":"):
+        handle_internal_commands(command)
+        return None
 
     try:
         return split_arg_string(command)
@@ -192,12 +195,12 @@ def _execute_internal_and_sys_cmds(
         raise CommandLineParserError("{}".format(e))
 
 
-def exit():
+def exit() -> NoReturn:
     """Exit the repl"""
     _exit_internal()
 
 
-def dispatch_repl_commands(command):
+def dispatch_repl_commands(command: str) -> bool:
     """
     Execute system commands entered in the repl.
 
@@ -210,13 +213,12 @@ def dispatch_repl_commands(command):
     return False
 
 
-def handle_internal_commands(command):
+def handle_internal_commands(command: str) -> None:
     """
     Run repl-internal commands.
 
     Repl-internal commands are all commands starting with ":".
     """
-    if command.startswith(":"):
-        target = _get_registered_target(command[1:], default=None)
-        if target:
-            return target()
+    target = _get_registered_target(command[1:], default=None)
+    if target:
+        target()
diff --git a/setup.cfg b/setup.cfg
index b8b3a2d..bd1522e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -39,6 +39,8 @@ testing =
   pytest>=7.2.1
   pytest-cov>=4.0.0
   tox>=4.4.3
+  flake8>=6.0.0
+  mypy>=1.9.0
 
 [flake8]
 ignore = E203, E266, W503, E402, E731, C901
diff --git a/tox.ini b/tox.ini
index 92b95cc..4172362 100644
--- a/tox.ini
+++ b/tox.ini
@@ -10,7 +10,7 @@ isolated_build = true
 [gh-actions]
 python =
     3.7: py37, click7, flake8
-    3.8: py38
+    3.8: py38, mypy
     3.9: py39
     3.10: py310
     3.11: py311
@@ -33,6 +33,11 @@ basepython = python3.7
 deps = flake8
 commands = flake8 click_repl tests
 
+[testenv:mypy]
+basepython = python3.8
+deps = mypy
+commands = mypy click_repl
+
 [testenv:click7]
 basepython = python3.10
 deps =