Skip to content

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

FixDevs · (Updated: )

Part of:  Python Errors

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.

Platform Differences: Typer vs Click vs argparse, Async, Packaging Per OS

Typer’s value proposition is type-hint-driven CLI design — but the underlying mechanics shift depending on your runtime, async needs, and distribution target.

Typer vs argparse vs Click. Argparse ships with the stdlib — zero dependencies, verbose API, no shell completion built in. Click is Typer’s parent — decorator-based, mature, used by pip and Black. Typer wraps Click and adds type-hint parsing plus Rich-styled help. Use argparse for one-file scripts, Click when integrating with a Click plugin ecosystem (Flask CLI), and Typer for new CLIs that want minimum boilerplate. Typer commands can be added to a pure Click app via typer.main.get_command(app) — useful for migrating large Click codebases incrementally.

FastAPI heritage. Typer was written by FastAPI’s author and shares its design DNA: Annotated[] for metadata, dependency-injection-style callbacks, and Pydantic-friendly type handling. If you already know FastAPI’s Annotated[X, Query(...)] pattern, Typer’s Annotated[X, typer.Option(...)] works identically. Validation errors surface in the same human-readable format.

Async commands aren’t supported natively. Wrap with asyncio.run:

import asyncio
import typer

app = typer.Typer()

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

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

For long-running async commands, use anyio.run(_fetch, url) instead — works with both asyncio and trio backends. Don’t call asyncio.run from within a callback that already started a loop; it raises RuntimeError: asyncio.run() cannot be called from a running event loop.

Rich rendering is enabled by default when rich is installed (Typer 0.12+ requires pip install typer[all] for the full experience). Help text gets syntax highlighting, error tracebacks become panels with show_locals, and progress bars render with spinners. To turn off Rich entirely in CI logs, pass pretty_exceptions_enable=False to the Typer() constructor or set _TYPER_STANDARD_TRACEBACK=1.

Packaging entry points per OS. The console script declared in pyproject.toml works identically across Linux, macOS, and Windows, but the binary lives in different places:

  • Linux/macOS: ~/.local/bin/mycli (user install) or /usr/local/bin/mycli (system install)
  • Windows: %APPDATA%\Python\Python3X\Scripts\mycli.exe plus a mycli.exe shim
[project.scripts]
mycli = "mycli.main:app"

On Windows, the entry-point shim is a small .exe generated by pip. If pip install doesn’t add the Scripts directory to PATH, the command isn’t callable — python -m pip install --user mycli then python -m mycli works as a fallback.

Distribution paths:

  • pip install -e . — editable install during development
  • uv tool install mycli-package — isolated venv per tool, no global Python pollution
  • pipx install mycli-package — same idea, older predecessor of uv tool
  • PyInstaller --onefile — single-binary distribution, bundles the Python interpreter (50-80 MB)
  • shiv or pex — single-file zipapp, requires Python on the target machine (much smaller)

Cross-platform autocomplete has rough edges:

  • bash/zsh/fish on Linux/macOS — mycli --install-completion works
  • PowerShell on Windows — needs Register-ArgumentCompleter manually; Typer’s installer covers it for PowerShell 5.1+
  • WSL2 — completion installs to the WSL shell only, not Windows-side PowerShell
  • macOS Bash 3.x (system default) — older completion features need Bash 4+; install via brew install bash

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. Initialize the logger once in your callback so every subcommand inherits the configured sink.

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 pre-commit hooks that validate CLI behavior in CI, configure the hook to call mycli --help and assert exit 0 — catches broken Annotated[] definitions before they reach production.

TypeError: 'ABCMeta' object is not subscriptable on Older Python

Typer’s Annotated[X, typer.Option(...)] requires typing.Annotated, which arrived in Python 3.9. On 3.8, import from typing_extensions instead:

from typing_extensions import Annotated

The list[str] syntax for generic types also needs Python 3.9+; on 3.8 use List[str] from typing. Typer 0.12 dropped Python 3.7 entirely — pin typer<0.12 if you still need 3.7 support.

Help Text Markup Not Rendering

Typer parses help= strings as Rich markup when rich is installed:

typer.Option(help="[bold]Important[/]: enables [red]destructive[/] mode")

If markup shows up as literal [bold] tags in the help output, Rich isn’t installed — pip install typer[all] or pip install rich fixes it. To opt out of markup parsing (useful when help text contains literal brackets), pass rich_help_panel=None and rich_markup_mode=None to the Typer() constructor.

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