Architecture

Converting a Click App to Typer

Migrate a Click CLI to Typer one command at a time - mount both trees, translate decorators to signatures, and pin behaviour with parity tests.

Updated

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 Command subclasses 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.

Converting one command at a time A migration from Click to Typer: mount the Typer app inside the existing group, move the smallest command, run parity tests, then move the rest. Converting one command at a time Mount both trees in one CLI 1 Move one the smallest command 2 Parity test same argv, same exit code 3 Repeat one commit each 4 the tool works after every step; nothing ships half-migrated Interop in both directions is what makes an incremental conversion possible at all.

Translating a command

Click decorator to Typer signature A translation table from Click option and argument decorators to the equivalent Typer type-annotated parameters. Click decorator to Typer signature Click Typer @click.argument("src") src: str type=int, default=3 n: int = 3 is_flag=True force: bool = False multiple=True tags: list[str] type=click.Choice([...]) an Enum type @click.pass_obj ctx: typer.Context The mechanical translations cover most of a migration; custom Command subclasses stay in Click.

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

What a parity test should pin The behaviours a migration test suite should verify before and after converting a command line framework. What a parity test should pin Assert on these Exit code for every documented invocation stdout content for commands that produce data Which stream errors are written to Option names, defaults and required-ness Do not assert on Whole help screens — the rendering differs Error message wording from the framework The order options appear in help Internal call sequences The behaviour that scripts depend on is stable across frameworks; the presentation is not.

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.