Because Typer builds Click objects and runs on Click's runtime, a conversion is a series of local edits rather than a rewrite. The tool keeps working after every commit, and the risky part — proving nothing changed — is a test table you write before you start.
TL;DR
- Mount the Typer app inside the existing Click group so both halves run together.
- Move commands smallest first, one commit each.
- Translate decorators to signature parameters; the mapping is mechanical.
- Write a parity test over documented invocations before moving anything.
- Leave custom
Commandsubclasses and dynamically built groups in Click.
Prerequisites
A working Click application with tests, and Typer installed. Both libraries coexist happily in one project — Typer depends on Click.
Mounting both trees
The first commit changes no behaviour: it adds an empty Typer app and attaches it to the existing group.
# mytool/cli.py
import click
import typer
from mytool.commands import legacy # existing @click.command functions
from mytool.commands import migrated # the new Typer app (empty at first)
cli = click.Group(help="Manage deployments.")
cli.add_command(legacy.sync)
cli.add_command(legacy.status)
cli.add_command(typer.main.get_command(migrated.app))
typer.main.get_command converts a Typer app into a Click command, so it slots into the existing
tree. The reverse also works when you would rather make the Typer app the root:
app = typer.Typer()
app.add_click_command(legacy.sync, name="sync")
Either arrangement lets commands move one at a time while the CLI stays whole.
Translating a command
Most of the work is mechanical. A Click command:
@click.command()
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option("--retries", type=int, default=3, show_default=True, help="Attempts per file.")
@click.option("--tag", "tags", multiple=True, help="Attach a tag.")
@click.option("--mode", type=click.Choice(["keep", "delete"]), default="keep")
@click.option("--dry-run", is_flag=True, help="Report without uploading.")
@click.pass_obj
def sync(settings, source: Path, retries: int, tags: tuple[str, ...], mode: str, dry_run: bool) -> None:
"""Sync SOURCE to the bucket."""
becomes:
class SyncMode(str, Enum):
keep = "keep"
delete = "delete"
@app.command()
def sync(
ctx: typer.Context,
source: Annotated[Path, typer.Argument(exists=True)],
retries: Annotated[int, typer.Option(help="Attempts per file.")] = 3,
tags: Annotated[list[str], typer.Option("--tag", help="Attach a tag.")] = [],
mode: Annotated[SyncMode, typer.Option()] = SyncMode.keep,
dry_run: Annotated[bool, typer.Option(help="Report without uploading.")] = False,
) -> None:
"""Sync SOURCE to the bucket."""
settings = ctx.obj
Four translations to note. multiple=True becomes list[str], and the explicit "--tag" keeps
the flag name singular. click.Choice becomes an Enum, which also gives you completion and a
real type in the body. is_flag=True becomes bool, and Typer generates --dry-run/--no-dry-run
— if you only want the positive form, pass "--dry-run" explicitly. And @click.pass_obj becomes
a typer.Context parameter.
What should stay in Click
Three constructs have no direct equivalent, and the right answer is usually to leave them alone rather than force a translation.
Custom Command or Group subclasses. A lazy-loading group, an alias resolver, a group that
reads its subcommands from a registry — all of these are Click object-model code, and they keep
working unchanged inside a mixed tree.
Dynamically constructed commands. If options are derived at run time from a schema or a plugin, Click's builder API is the right tool; Typer assumes parameters are known when the module is imported.
Parameter types with complex conversion. A click.ParamType subclass can be used from Typer
directly with click_type=, so there is no need to rewrite it.
Pinning behaviour first
The parity test is what turns "I think this is equivalent" into something the build checks. Write it against the existing CLI, get it green, then let it guard every step:
import pytest
from click.testing import CliRunner
from mytool.cli import cli
CASES = [
([], 0),
(["--help"], 0),
(["sync", "./data"], 0),
(["sync"], 2),
(["sync", "./data", "--retries", "x"], 2),
(["sync", "./missing"], 2),
(["status", "--json"], 0),
]
@pytest.mark.parametrize("argv,expected", CASES, ids=lambda c: " ".join(c) if isinstance(c, list) else str(c))
def test_exit_codes_unchanged(argv, expected, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "data").mkdir()
assert CliRunner().invoke(cli, argv).exit_code == expected
Add an output assertion for commands that produce data — json.loads(result.stdout) for anything
with a --json mode is a strong check. What not to assert on is help text: Typer renders through
Rich, so the layout differs by design, and pinning it guarantees a failure that means nothing.
UX considerations
Two user-visible differences arrive with Typer and are worth deciding on deliberately.
Help output looks different. Panels, colour and aligned columns instead of Click's plain text.
Most people prefer it; if your users pipe --help into something, rich_markup_mode=None restores
plain output.
Boolean flags gain a negative form. --dry-run becomes --dry-run/--no-dry-run unless you
declare only the positive name. That is usually an improvement, and it is a new flag appearing in
your interface, so mention it in the changelog.
Neither is breaking, but both are visible, which makes them worth a line in release notes rather than a surprise.
Testing the behaviour
Run the parity suite after every command you move; it is fast and it is the whole safety net. Add one test that proves the mixed tree is intact while the migration is in progress:
def test_all_commands_are_reachable():
result = CliRunner().invoke(cli, ["--help"])
for name in ("sync", "status", "db", "remote"):
assert name in result.output
When the last command has moved, the final commit removes the Click group, promotes the Typer app to the root, and deletes the interop line. At that point the parity tests become ordinary tests — which is a good outcome, since they encode the invocations your users depend on.
A worked order of operations
For a tool of a dozen commands, the sequence that keeps risk low looks like this.
Commit 1 — interop, no behaviour change. Add Typer as a dependency, create an empty
migrated.app, mount it, and confirm the parity suite is green. Nothing has moved yet, and the
diff is four lines.
Commit 2 — the smallest leaf command. Pick the command with the fewest options and no shared state. Translate it, delete the Click version, run the suite. This is where you learn the mechanics on something that cannot break anyone's day.
Commit 3 — the callback. Move the root callback so global options and the settings object are built by Typer. This is the step that touches every command, so do it once the translations are familiar and while most commands are still Click ones reached through interop.
Commits 4 to n — one command each. In increasing order of complexity, leaving anything that
subclasses Command for last (or forever).
Final commit — promote and clean up. Make the Typer app the root, drop the interop line, remove
the Click import if nothing else uses it, and update the entry point in pyproject.toml if the
object it names has changed.
Each commit ships. If a release goes out mid-migration, users see a tool that works — with one cosmetic difference in the help output of the commands that have moved.
Things that surprise people mid-migration
Mutable default arguments. tags: list[str] = [] is idiomatic in Typer and flagged by linters
as a mutable default. Typer copies it per invocation, so it is safe here — configure the rule rather
than fighting it, or use Annotated[list[str], typer.Option()] = None and normalise inside.
Required options. In Click, required=True on an option is common. In Typer, a parameter with
no default is required, which usually reads better — but check whether the value should have been a
positional argument all along.
Argument order. Click applies decorators bottom-up, so the parameter order in the function may
not match the order options appear in help. In Typer the signature is the order. Migrating can
therefore change the sequence in --help, which is cosmetic but worth expecting.
Context is not automatic. A Click command with @click.pass_context becomes a Typer command
with an explicit ctx: typer.Context parameter. Forgetting it produces a NameError at run time
rather than a helpful message, so it is worth grepping for pass_context before starting.
Conclusion
Mount both, move the smallest command first, translate decorators into the signature, and let a parity table over documented invocations decide whether anything changed. The conversion is a sequence of small, revertible commits, and at no point does the tool ship half-migrated.
Frequently asked questions
Do I have to migrate everything?
No. A permanently mixed tree is a legitimate outcome — declared commands in Typer, dynamically built ones in Click. The interop is not a migration crutch; it is a supported way to use both object models in one application.
What happens to my Click context and pass_obj?
They keep working. Typer commands take ctx: typer.Context, which is Click's context, so
ctx.obj is the same object your Click callback assigned. That is what allows a half-migrated tree
to share state between old and new commands.
Will my custom parameter types still work?
Yes — pass them with click_type=. A click.ParamType subclass is unchanged by the migration, and
for validation that does not fit a type hint it remains the right tool in either framework.
Does the migration change my exit codes?
It should not, and the parity test is what proves it. Both frameworks exit 2 on usage errors and propagate exceptions to your boundary the same way. If a code changes, it is because a parameter became required or a type became stricter — worth catching before release.
How long does a conversion take?
For a tool with a dozen commands, typically a day of work spread over several commits, most of it mechanical. The parity test is the part worth doing carefully; the translations themselves are fast once you have done two or three.