Skip to content

Fix: Typer Not Working — Argument Errors, Autocomplete, and Subcommand Issues

FixDevs ·

Quick Answer

How to fix Typer errors — type annotation required error, Optional argument parsing, boolean flag conventions, autocomplete installation failed, nested commands not found, and rich traceback disable.

The Error

You build a Typer CLI and type hints break the command:

import typer

app = typer.Typer()

@app.command()
def greet(name):
    typer.echo(f"Hello {name}")

app()
# Error: Type for parameter 'name' is not supported. Use type-annotated parameters.

Or an optional argument fails to parse from CLI:

$ mycli greet --name Alice
Usage: mycli greet [OPTIONS]
Try 'mycli greet --help' for help.
Error: Got unexpected extra argument (Alice)

Or autocomplete installation silently does nothing:

$ mycli --install-completion
zsh completion installed at ~/.zfunc/_mycli
# Restart shell, tab completion still doesn't work

Or a subcommand can’t be found after adding a sub-Typer:

Usage: mycli [OPTIONS] COMMAND [ARGS]...
Try 'mycli --help' for help.
Error: No such command 'db'.

Or rich tracebacks mask real errors with formatting you can’t parse:

╭─ Exception ─╮
│ ValueError  │  # Pretty output but loses line numbers in CI logs
╰─────────────╯

Typer is the modern Python CLI framework — it wraps Click with type-hint-based argument parsing, so you write name: str and Typer generates the CLI. The type-driven approach is elegant but creates specific failure modes when annotations are missing, conflict, or use patterns Typer doesn’t understand. This guide covers each.

Why This Happens

Typer reads your function signature to determine CLI arguments and options. Without type hints, Typer can’t infer argument types — it refuses to build the command. With Optional[X], Typer treats the argument as optional with None default. With bool, Typer creates flag options (--flag / --no-flag). Mismatches between your intent and Typer’s inference produce confusing errors.

Typer 0.12 (2024) changed some defaults, notably removing rich tracebacks from the default install and requiring typer[all] for full features. Code written against older tutorials sometimes fails to enable features that used to work automatically.

Fix 1: Type Annotations Required

import typer

app = typer.Typer()

# WRONG — no type annotation
@app.command()
def greet(name):
    print(f"Hello {name}")

# Error: Type for parameter 'name' is not supported

Fix — annotate every parameter:

@app.command()
def greet(name: str):
    print(f"Hello {name}")

Common types:

import typer
from enum import Enum
from pathlib import Path
from typing import Annotated, Optional

app = typer.Typer()

class LogLevel(str, Enum):
    debug = "debug"
    info = "info"
    warning = "warning"

@app.command()
def run(
    name: str,                                    # Required positional
    count: int = 1,                                # Optional with default
    verbose: bool = False,                          # Flag: --verbose / --no-verbose
    output: Path = Path("./out"),                   # Path (validated as path)
    level: LogLevel = LogLevel.info,                # Enum — validated values
    tags: Optional[list[str]] = None,              # Multiple values
):
    typer.echo(f"Running {name} {count} times at {level}")

Annotated[] for rich option configuration (recommended over inline defaults):

from typing import Annotated
import typer

@app.command()
def process(
    input_file: Annotated[Path, typer.Argument(help="Input file to process", exists=True)],
    output: Annotated[Path, typer.Option("--output", "-o", help="Output path")] = Path("./out"),
    force: Annotated[bool, typer.Option("--force", "-f")] = False,
    workers: Annotated[int, typer.Option(min=1, max=32)] = 4,
):
    ...

Annotated is the modern way — works with type checkers, keeps help text and options with the type.

Common Mistake: Using typer.Option(...) as a default value directly (pre-Annotated style). This still works but is deprecated:

# OLD style (still works, but Annotated is preferred)
@app.command()
def run(
    workers: int = typer.Option(4, "--workers", "-w", min=1, max=32),
):
    ...

# NEW style (recommended)
@app.command()
def run(
    workers: Annotated[int, typer.Option("--workers", "-w", min=1, max=32)] = 4,
):
    ...

Fix 2: Arguments vs Options

Typer distinguishes positional arguments from flag-prefixed options:

@app.command()
def command(
    # Positional argument (required by default)
    file: str,

    # Optional positional (default value → optional)
    backup_file: str = "",

    # Option (has typer.Option or a type that implies it)
    verbose: bool = False,

    # Explicit Option
    count: Annotated[int, typer.Option("--count", "-n")] = 1,
):
    ...

CLI usage:

mycli command file1.txt                    # file=file1.txt
mycli command file1.txt backup.txt          # file=file1.txt, backup_file=backup.txt
mycli command file1.txt --verbose            # file=file1.txt, verbose=True
mycli command file1.txt -n 5                 # file=file1.txt, count=5

Lists as arguments:

@app.command()
def process(
    files: list[Path],   # Variadic positional: typer command file1 file2 file3
):
    for f in files:
        typer.echo(f"Processing {f}")

Lists as options (multiple flag instances):

@app.command()
def tag(
    tags: Annotated[list[str], typer.Option("--tag", "-t")] = [],
):
    typer.echo(f"Tags: {tags}")

# Usage: mycli tag --tag a --tag b --tag c

Fix 3: Boolean Flags

@app.command()
def run(
    verbose: bool = False,   # Creates --verbose and --no-verbose
):
    ...

Typer auto-generates both flags:

mycli run --verbose       # verbose=True
mycli run --no-verbose    # verbose=False
mycli run                 # verbose=False (default)

Single-form flag (no --no-* counterpart):

@app.command()
def run(
    verbose: Annotated[bool, typer.Option("--verbose/")] = False,   # Trailing slash = no negative
):
    ...

Custom flag names:

@app.command()
def run(
    dry_run: Annotated[bool, typer.Option("--dry-run/--execute")] = False,
):
    ...

# mycli run --dry-run       → dry_run=True
# mycli run --execute       → dry_run=False

Pro Tip: For dangerous commands (delete, overwrite), make destructive behavior require an explicit flag. --force / --no-force is conventional; defaulting to False means users must opt in to destructive actions. Pair with a confirmation prompt for extra safety.

@app.command()
def delete(
    path: Path,
    force: Annotated[bool, typer.Option("--force", "-f")] = False,
):
    if not force:
        typer.confirm(f"Really delete {path}?", abort=True)
    path.unlink()

Fix 4: Subcommands and Nested Apps

import typer

app = typer.Typer()
db_app = typer.Typer()

app.add_typer(db_app, name="db")

@app.command()
def version():
    typer.echo("1.0.0")

@db_app.command()
def migrate():
    typer.echo("Migrating database...")

@db_app.command()
def reset():
    typer.echo("Resetting database...")

if __name__ == "__main__":
    app()

CLI usage:

mycli version           # Top-level command
mycli db migrate         # Nested command
mycli db reset
mycli db --help          # Shows db's subcommands

Nested two levels deep:

app = typer.Typer()
db_app = typer.Typer()
user_app = typer.Typer()

app.add_typer(db_app, name="db")
db_app.add_typer(user_app, name="user")

@user_app.command()
def create(name: str):
    ...

# Usage: mycli db user create Alice

Common Mistake: Calling app.command() on the wrong app instance. If you have db_app for subcommands but decorate a function with @app.command(), it becomes a top-level command, not a subcommand. Always decorate with the specific sub-app you intend to attach it to.

Shared options across all subcommands via a callback:

@app.callback()
def main(
    verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
):
    """Main entry point — runs before any command."""
    if verbose:
        typer.echo("Verbose mode enabled")

The callback runs before any subcommand. Useful for logging setup, config loading, authentication.

Fix 5: Autocomplete Installation

mycli --install-completion
# Installs shell completion script

After installation:

  • Bash: Add source ~/.bash_completions/mycli.sh to ~/.bashrc
  • Zsh: Ensure compinit runs in ~/.zshrc
  • Fish: Typer writes to ~/.config/fish/completions/ — auto-loaded

Check which shell Typer detected:

mycli --show-completion
# Prints the completion script for your current shell

Custom completion for a specific argument:

def complete_user(incomplete: str):
    users = get_all_users()   # From DB, config, etc.
    for user in users:
        if user.startswith(incomplete):
            yield user

@app.command()
def delete_user(
    username: Annotated[str, typer.Argument(autocompletion=complete_user)],
):
    ...

Autocomplete still not working? — common causes:

  1. Shell not restarted after install
  2. Non-standard shell (install script only handles bash/zsh/fish/powershell)
  3. Binary not on PATH — completion script references the command by name; if mycli isn’t findable, completion doesn’t trigger

Fix 6: Rich Traceback and Error Handling

Typer installs Rich’s pretty tracebacks by default (when rich is installed). For CI logs or servers that process stderr, this is often noise.

Disable rich tracebacks:

import typer

app = typer.Typer(pretty_exceptions_enable=False)   # Plain traceback

Disable rich output globally:

app = typer.Typer(
    pretty_exceptions_enable=False,
    pretty_exceptions_show_locals=False,
    no_args_is_help=True,   # Show help when no args given
)

Environment variable (for existing apps without changing code):

_TYPER_STANDARD_TRACEBACK=1 mycli command

Custom error handling with typer.Exit:

@app.command()
def process(file: Path):
    if not file.exists():
        typer.echo(f"Error: {file} not found", err=True)
        raise typer.Exit(code=1)

    # ... normal processing

typer.Exit(code=N) is the clean way to exit with a specific code. Normal Python exceptions work too but produce a traceback.

Abort a prompt:

@app.command()
def destructive():
    typer.confirm("Are you sure?", abort=True)   # Raises typer.Abort if user says no
    ...

Fix 7: Progress Bars and Output

Typer wraps Click’s progressbar and adds Rich-based ones:

import typer
import time

@app.command()
def process(files: list[Path]):
    with typer.progressbar(files) as progress:
        for f in progress:
            time.sleep(0.1)   # Some processing

Rich progress bar (more feature-rich):

from rich.progress import track

@app.command()
def process(files: list[Path]):
    for f in track(files, description="Processing..."):
        time.sleep(0.1)

Colored output:

import typer

typer.echo(typer.style("Error", fg=typer.colors.RED, bold=True))
typer.echo(typer.style("Success", fg=typer.colors.GREEN))

# Or use typer.secho for one-liner
typer.secho("Warning", fg=typer.colors.YELLOW, bold=True, err=True)

Tables with Rich:

from rich.console import Console
from rich.table import Table

console = Console()

@app.command()
def list_users():
    table = Table("ID", "Name", "Email")
    for user in get_users():
        table.add_row(str(user.id), user.name, user.email)
    console.print(table)

Fix 8: Config File Loading and Environment Variables

Environment variables for options:

@app.command()
def connect(
    host: Annotated[str, typer.Option(envvar="MYAPP_HOST")] = "localhost",
    api_key: Annotated[str, typer.Option(envvar="MYAPP_API_KEY")] = "",
):
    ...

Now MYAPP_HOST=prod.example.com mycli connect works without --host.

Secret inputs (hidden echo):

@app.command()
def login(
    username: str,
    password: Annotated[str, typer.Option(prompt=True, hide_input=True)] = "",
):
    ...

# Usage prompts for password without showing it
# mycli login alice
# Password: <hidden>

Config files — Typer doesn’t have built-in config loading, but integrates cleanly with Pydantic Settings:

from pydantic_settings import BaseSettings
import typer
from typing import Annotated

class Settings(BaseSettings):
    api_host: str = "localhost"
    api_key: str = ""

    class Config:
        env_file = ".env"
        env_prefix = "MYAPP_"

settings = Settings()
app = typer.Typer()

@app.command()
def run(
    host: Annotated[str, typer.Option()] = settings.api_host,
    api_key: Annotated[str, typer.Option()] = settings.api_key,
):
    ...

Load once at startup, use as Typer option defaults. CLI flags override config file values.

Still Not Working?

Typer vs Click vs argparse

  • Typer — Modern, type-hint-based, built on Click. Best for new projects.
  • Click — Decorator-based, no type hints required, enormous ecosystem. Best for integrations with existing Click plugins.
  • argparse — Stdlib, verbose, universally available. Best when you want zero dependencies.

Typer is a Click wrapper, so anything Click can do, Typer can too. But the type-hint API makes simple CLIs much easier to write.

Testing Typer Apps

from typer.testing import CliRunner

runner = CliRunner()

def test_greet():
    result = runner.invoke(app, ["greet", "Alice"])
    assert result.exit_code == 0
    assert "Hello Alice" in result.output

For pytest fixture patterns that pair with Typer’s CliRunner, see pytest fixture not found.

Default Command vs No-Args Behavior

By default, running mycli with no args prints an error. To show help instead:

app = typer.Typer(no_args_is_help=True)

Or make a specific subcommand the default:

@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
    if ctx.invoked_subcommand is None:
        # No subcommand given — do something default
        typer.echo("Welcome to MyCLI")
        raise typer.Exit()

Async Commands

Typer doesn’t support async def commands directly. Wrap with asyncio.run:

import asyncio
import typer

app = typer.Typer()

async def _fetch(url: str):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            return await resp.text()

@app.command()
def fetch(url: str):
    result = asyncio.run(_fetch(url))
    typer.echo(result)

Or use the anyio helper for cross-loop compatibility. For async runtime issues, see Python asyncio not running.

Building and Distributing a Typer CLI

# pyproject.toml
[project]
name = "mycli"
dependencies = ["typer[all]"]

[project.scripts]
mycli = "mycli.main:app"
pip install -e .
mycli --help

Distribution as a standalone binary with PyInstaller:

pip install pyinstaller
pyinstaller --onefile -n mycli mycli/main.py

Or with uv tool install for Python-environment-based distribution:

uv tool install mycli-package
# Installs mycli as a global command, isolated in its own venv

For uv setup and tool installation, see uv not working.

Logging Integration

Typer CLIs usually need structured logging. For Loguru integration that complements Typer’s rich console output, see Loguru not working. For Rich-based logging formats that Typer inherits, set the handler up explicitly rather than relying on Typer’s built-in rich tracebacks.

Shell-Specific Gotchas

  • PowerShell on Windows — autocomplete needs Register-ArgumentCompleter
  • WSL2 — completion installs to the WSL shell, not Windows’ shells
  • Fish — no compinit needed; completions auto-load from ~/.config/fish/completions/
  • Bash 3.x (macOS default) — some Typer completion features need Bash 4+. Install via brew install bash.

For general pre-commit hooks that validate CLI behavior in CI, see pre-commit not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles