Many options accept one of a fixed set of values: an environment (dev, staging, production), an output format (table, json), a log level. Declaring that set — instead of accepting any string and checking it later — gives you validation with a clear error, the choices listed in --help, shell completion for free, and a typed value inside the command. Typer and Click both support it, through Python Enum and Literal types in Typer and click.Choice in Click. They differ in one detail that bites during migrations and refactors: Typer matches an enum's values; Click matches its names. This guide shows each approach, that difference, case-insensitive matching, repeatable choices and how to test them. It belongs to the Typer vs Click topic.
Prerequisites
- Typer 0.12+ (examples checked with 0.27) or Click 8.2+ (checked with 8.5).
- The
Annotatedstyle from using Annotated options in Typer.
Three ways to declare a choice
An Enum is the richest: the command receives an enum member, so code can attach behaviour or extra data to each choice, and type checkers know every possible value. A Literal (in Typer) is the lightest: the command receives a plain string, and the allowed values live right in the signature. click.Choice with a list of strings is Click's classic form; since Click 8.2 it also accepts an Enum class directly.
The recipe: Typer
# src/mytool/cli.py
from enum import Enum
from typing import Annotated, Literal
import typer
app = typer.Typer()
class Env(str, Enum):
dev = "dev"
staging = "staging"
prod = "production"
@app.callback()
def main() -> None:
"""Deployment tool."""
@app.command()
def deploy(
env: Annotated[Env, typer.Option(case_sensitive=False, help="Target environment.")] = Env.dev,
fmt: Annotated[Literal["table", "json"], typer.Option("--format")] = "table",
regions: Annotated[list[Env], typer.Option("--region", help="Repeat for several.")] = [],
) -> None:
"""Deploy to an environment."""
typer.echo(f"deploying to {env.value} ({fmt}); extra regions: "
f"{', '.join(r.value for r in regions) or 'none'}")
The help lists each set of choices, and invalid input is a usage error with exit code 2:
╭─ Options ──────────────────────────────────────────────────────────╮
│ --env <dev|staging|production> Target environment. │
│ [default: dev] │
│ --format <table|json> [default: table] │
│ --region <dev|staging|production> Repeat for several. │
╰────────────────────────────────────────────────────────────────────╯
$ mytool deploy --env prod
│ Invalid value for '--env': 'prod' is not one of 'dev', 'staging', 'production'. │
Note what Typer accepted and rejected: the user types the enum's value (production), not its name (prod). case_sensitive=False lets PRODUCTION through too. A list[Env] option can be repeated (--region dev --region staging), each value validated against the same set. Using a mutable default ([]) is safe here because Typer builds a fresh list for each invocation; with plain functions you would use None.
When to choose Enum vs Literal
Use Literal for a handful of format-like strings the code only compares against. Use Enum when choices carry meaning beyond their spelling — a mapping to API identifiers, a display label, a method that does the work — or when the same set is used by several commands and should live in one place. Subclassing str (as Env(str, Enum) does) keeps the members usable wherever a string is expected, including JSON serialisation.
The recipe: Click
# src/mytool/click_cli.py
from enum import Enum
import click
class Env(Enum):
dev = "dev"
staging = "staging"
prod = "production"
@click.command()
@click.option("--env", type=click.Choice(Env, case_sensitive=False), default="dev",
show_default=True, help="Target environment.")
@click.option("--format", "fmt", type=click.Choice(["table", "json"]), default="table")
def deploy(env: Env, fmt: str) -> None:
"""Deploy to an environment."""
click.echo(f"deploying to {env.value} ({fmt})")
With an Enum class, click.Choice lists and matches the member names — --env [dev|staging|prod] — and passes the member to the function. So in Click, --env prod works and --env production is rejected; in Typer it is the other way round. When names and values are identical, as for dev and staging here, the difference is invisible, which is exactly why it causes surprises: everything works until someone adds a member whose value differs from its name.
Avoiding the mismatch
The simplest defence is a convention: make enum names and values identical for anything used as a CLI choice (production = "production"), or use lower-case strings that are valid identifiers. Then both frameworks accept the same input, migrations between them are safe, and help output matches what users type. If you need different spellings — a short CLI value mapping to a long API identifier — keep the CLI-facing enum simple and translate in code:
API_NAMES = {Env.dev: "development-eu1", Env.staging: "staging-eu1", Env.prod: "prod-eu1"}
That keeps the user-visible contract (the choices in --help) independent from internal identifiers that may change. Converting a Click app to Typer is the moment this matters most: check every click.Choice(SomeEnum) before moving it to a Typer annotation.
The same choices from environment variables and config files
An option's value does not always come from the command line. With typer.Option(envvar="MYTOOL_ENV") (or envvar= in Click), the framework reads the environment variable and runs it through the same validation, so MYTOOL_ENV=prod fails exactly like --env prod would — good, because a typo in a CI variable should not silently select a default. Values from a configuration file are different: the framework never sees them, so validate them against the same enum when loading the file:
def env_from_config(raw: str) -> Env:
try:
return Env(raw.lower()) # match on value, like Typer does
except ValueError:
allowed = ", ".join(e.value for e in Env)
raise ValueError(f"config: environment must be one of {allowed}, got {raw!r}") from None
Using the enum as the single definition of valid choices — for flags, environment variables and config alike — keeps the three sources consistent and gives every error message the same list of allowed values. The precedence between them is covered in config precedence: flags, env, files, defaults.
UX considerations
- Accept case-insensitively when the choices are words users type (
--env Production); keep it sensitive when case is meaningful. - Put the most common choice first in the enum; it becomes the first item in help and completion.
- Choose names users already know — the ones in your web UI, docs and API — rather than internal abbreviations.
- Keep the set stable. Removing a choice breaks scripts like removing a flag; deprecate it first, as in versioning and deprecating CLI flags.
- Let completion do the work. Both frameworks complete choices automatically once completion is installed; see enabling tab completion in Click and Typer.
Testing the behaviour
Test the accepted spellings, the rejected ones and the typed value the command receives — in both frameworks if you use both:
# tests/test_choices.py
import pytest
from click.testing import CliRunner as ClickRunner
from typer.testing import CliRunner
from mytool import click_cli
from mytool.cli import app
@pytest.mark.parametrize("value", ["production", "PRODUCTION"])
def test_typer_accepts_values_case_insensitively(value):
result = CliRunner().invoke(app, ["deploy", "--env", value])
assert result.exit_code == 0 and "deploying to production" in result.output
def test_typer_rejects_enum_names():
assert CliRunner().invoke(app, ["deploy", "--env", "prod"]).exit_code == 2
def test_click_accepts_names_not_values():
runner = ClickRunner()
assert runner.invoke(click_cli.deploy, ["--env", "prod"]).exit_code == 0
assert runner.invoke(click_cli.deploy, ["--env", "production"]).exit_code == 2
def test_repeated_choices_are_collected():
result = CliRunner().invoke(app, ["deploy", "--region", "dev", "--region", "staging"])
assert "extra regions: dev, staging" in result.output
def test_literal_rejects_unknown_format():
result = CliRunner().invoke(app, ["deploy", "--format", "xml"])
assert result.exit_code == 2 and "'xml' is not one of" in result.output
The pair of name-versus-value tests documents the framework difference in executable form, so nobody "simplifies" one side without noticing.
Conclusion
Declare fixed sets of values as choices rather than validating strings by hand: Enum or Literal annotations in Typer, click.Choice in Click. Remember that Typer matches enum values while click.Choice(Enum) matches names, keep names and values identical for CLI-facing enums to sidestep the difference, use case_sensitive=False for words people type, and test both the accepted spellings and the rejected ones.
Frequently asked questions
How do I show a description for each choice in help?
Neither framework does this per value out of the box. Describe the choices in the option's help text ("dev: shared sandbox; staging: pre-release; production: live"), or, for longer explanations, in the command's epilog — see adding examples and epilogs to help output.
Can the set of choices come from configuration or an API?
Not as a static Enum. Accept a string, validate it against the dynamic set in a callback or at the start of the command, and offer dynamic shell completion — the techniques are in dynamic completion values from APIs and files.
Does Typer support IntEnum or numeric choices?
Typer works with str-based enums most reliably. For numeric choices, use int with min/max bounds when the values form a range, or a Literal of strings and convert inside the command.
What about argparse?
add_argument("--env", choices=["dev", "staging", "production"]) gives the same validation and help; with an Enum, pass type=Env and choices=list(Env), and give the enum a __str__ that returns the value so help shows the right spellings.
Should help show the default choice?
Yes — Typer does by default, and in Click pass show_default=True. Knowing that --env defaults to dev is often the single most useful fact in the option's help, and it prevents the nasty surprise of a deployment that went somewhere unexpected because nobody passed the flag.