Input & UX

Writing Custom Click Parameter Types

Validate and convert structured CLI arguments with custom click.ParamType classes: clear errors, metavars, defaults, shell completion, Typer use and tests.

Updated

Some command-line values have structure: a deploy target written eu-west-1:3, a duration like 90s or 1.5h, a key such as team/service, a semantic version range. Accepting them as plain strings pushes parsing into every command that uses them, with slightly different error messages each time and a traceback whenever someone forgets a check. Click's answer is the parameter type: a small class that converts the raw string into a real Python value, fails with a proper usage error when it cannot, describes its format in --help, and can even offer shell completion. This guide builds two parameter types, uses them in a command, explains the decisions that make them pleasant, and tests them — and shows how the same types plug into Typer. It belongs to the advanced argument validation strategies topic.

Prerequisites

  • Click 8.1+ (Typer users: the types work through click_type=, shown below).
  • A value format that appears in more than one command, or that deserves better errors than a plain string check.

What a parameter type does

What a ParamType does Click passes the raw string to the parameter type convert method, which returns a typed value or calls fail to produce a usage error with exit code 2. What a ParamType does argv string "eu-west-1:3" convert() parse + validate Typed value Region, int Command receives it raw or self.fail() kwargs Validation happens before the command runs, and errors look like every other Click error.

When Click parses an option or argument with type=SomeType(), it calls SomeType.convert(value, param, ctx) with the raw string. The method returns the converted value, or calls self.fail(message, param, ctx), which raises click.BadParameter. Click turns that into the standard usage error — the usage line, "Error: Invalid value for '--target': ...", and exit status 2 — so custom types produce errors indistinguishable from Click's built-in ones. Conversion happens before the command body runs, so the command receives only valid, typed values.

When a type is worth writing

Callback, type= function or ParamType? A decision for custom parameter handling in Click: simple conversions use a plain function, one-off checks use a callback, reusable types with completion and metavars use a ParamType class. Callback, type= function or ParamType? Will this type be reused, and does it need completion? One option, one check callback= validate in place Reused across commands ParamType name, metavar, completion A ParamType is a small class with a big payoff once three commands accept the same kind of value.

For a single option with a one-off rule, a callback= that validates the value in place is enough. For a simple conversion — a string to a datetime — a plain function passed as type= works too. Write a ParamType class when the value will be accepted by several commands, when you want a custom metavar in help, or when you want completion. Those three are the point at which a class pays for itself.

The recipe

# src/mytool/types.py
from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any

import click
from click.shell_completion import CompletionItem

REGIONS = ("eu-west-1", "eu-central-1", "us-east-1", "us-west-2", "ap-south-1")


@dataclass(frozen=True)
class Target:
    region: str
    replicas: int


class TargetType(click.ParamType):
    """REGION:COUNT, e.g. eu-west-1:3."""

    name = "target"

    def get_metavar(self, param: click.Parameter, ctx: click.Context | None = None) -> str:
        return "REGION:COUNT"

    def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> Target:
        if isinstance(value, Target):                     # defaults and programmatic calls
            return value
        region, sep, count = str(value).partition(":")
        if not sep:
            self.fail(f"{value!r} is missing ':COUNT'; expected REGION:COUNT, e.g. eu-west-1:3",
                      param, ctx)
        if region not in REGIONS:
            self.fail(f"unknown region {region!r}; choose from {', '.join(REGIONS)}", param, ctx)
        if not count.isdigit() or not 1 <= int(count) <= 50:
            self.fail(f"{count!r} is not a replica count between 1 and 50; "
                      "expected REGION:COUNT, e.g. eu-west-1:3", param, ctx)
        return Target(region, int(count))

    def shell_complete(self, ctx: click.Context, param: click.Parameter,
                       incomplete: str) -> list[CompletionItem]:
        if ":" in incomplete:
            return []
        return [CompletionItem(f"{r}:", help="region") for r in REGIONS if r.startswith(incomplete)]


class Duration(click.ParamType):
    """30s, 5m, 2h -> seconds as float."""

    name = "duration"
    _pattern = re.compile(r"([0-9]+(?:\.[0-9]+)?)(s|m|h)")

    def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> float:
        if isinstance(value, (int, float)):
            return float(value)
        m = self._pattern.fullmatch(str(value).strip())
        if not m:
            self.fail(f"{value!r} is not a duration; use a number and s, m or h (e.g. 90s, 5m)",
                      param, ctx)
        return float(m[1]) * {"s": 1, "m": 60, "h": 3600}[m[2]]


TARGET = TargetType()
DURATION = Duration()
# src/mytool/cli.py
import click

from mytool.types import DURATION, TARGET, Target


@click.group()
def cli() -> None:
    """Scaling tool."""


@cli.command()
@click.option("--target", "targets", type=TARGET, multiple=True, required=True,
              help="Where to scale, as REGION:COUNT (repeatable).")
@click.option("--timeout", type=DURATION, default="5m", show_default=True,
              help="How long to wait for the rollout.")
def scale(targets: tuple[Target, ...], timeout: float) -> None:
    """Scale the service in one or more regions."""
    for t in targets:
        click.echo(f"{t.region}: {t.replicas} replicas (timeout {timeout:g}s)")


if __name__ == "__main__":
    cli()

The details that matter

convert must accept already-converted values. Click passes defaults through convert too, and programmatic callers may pass objects. Returning a Target or number unchanged makes both work — without it, default=Target(...) or a numeric default would crash inside the type.

Errors say what was expected and give an example. "'three' is not a replica count between 1 and 50; expected REGION:COUNT, e.g. eu-west-1:3" tells the user exactly how to fix it. Checking the parts in order — separator, region, count — means the first thing wrong is the thing reported.

get_metavar documents the format. Help shows --target REGION:COUNT instead of the generic TARGET, so the format is visible before anyone gets it wrong. (Click 8.2 added the ctx parameter to get_metavar; accepting it with a default works on 8.1 as well.)

shell_complete offers regions. Pressing Tab after --target eu- completes to eu-west-1: and eu-central-1:; the trailing colon prompts for the count. Completion works once shell completion is installed, as in enabling tab completion in Click and Typer.

Types return domain objects. The command receives Target instances, not strings to split, so all the parsing lives in one tested place.

Module-level instances. TARGET = TargetType() is created once and shared; types are stateless, so one instance serves every option.

Errors that explain the format Terminal output of a Click command rejecting a malformed value with a message produced by a custom parameter type. Errors that explain the format bash $ mytool scale --target eu-west-1:three Usage: mytool scale [OPTIONS] Error: Invalid value for '--target': 'three' is not a replica count; expected REGION:COUNT, e.g. eu-west-1:3 The type knows its own format, so the error can show an example every time.

Using the same types in Typer

Typer generates Click parameters from annotations, and accepts a Click type through click_type=:

from typing import Annotated

import typer

from mytool.types import DURATION, TARGET, Target

app = typer.Typer()


@app.command()
def scale(
    targets: Annotated[list[Target], typer.Option("--target", click_type=TARGET)],
    timeout: Annotated[float, typer.Option(click_type=DURATION)] = 300.0,
) -> None:
    for t in targets:
        typer.echo(f"{t.region}: {t.replicas}")

Typer also has parser= for simple function-based conversion, discussed in using Annotated options in Typer; a full ParamType is the better choice when you want the metavar and completion as well.

UX considerations

  • Validate as early as possible. A type rejects a bad value before any work starts, so a typo never leaves a half-finished operation behind.
  • Accept reasonable variants. Stripping whitespace, accepting upper-case region names or both 5m and 5min costs little and reduces friction — but normalise to one canonical form in the returned value.
  • Keep messages consistent across types. "X is not a Y; expected FORMAT, e.g. EXAMPLE" is a pattern users learn once.
  • Do not do I/O in convert. Checking that a region exists by calling an API makes every invocation (and every completion) slow and fragile. Validate the format in the type and existence in the command, where failures can be reported with context.

Testing the behaviour

Types are easy to test directly — call convert with None for param and ctx — and through a command for the full user-facing behaviour:

# tests/test_types.py
import click
import pytest
from click.testing import CliRunner

from mytool.cli import cli
from mytool.types import DURATION, TARGET, Target

runner = CliRunner()


@pytest.mark.parametrize("text, expected", [
    ("eu-west-1:3", Target("eu-west-1", 3)),
    ("us-east-1:50", Target("us-east-1", 50)),
])
def test_valid_targets(text, expected):
    assert TARGET.convert(text, None, None) == expected


@pytest.mark.parametrize("text, fragment", [
    ("eu-west-1", "missing ':COUNT'"),
    ("mars-1:3", "unknown region"),
    ("eu-west-1:three", "not a replica count"),
    ("eu-west-1:0", "not a replica count"),
])
def test_invalid_targets_explain_themselves(text, fragment):
    with pytest.raises(click.BadParameter, match=fragment):
        TARGET.convert(text, None, None)


def test_durations():
    assert DURATION.convert("90s", None, None) == 90
    assert DURATION.convert("1.5h", None, None) == 5400
    assert DURATION.convert(30, None, None) == 30.0


def test_command_uses_types_and_default():
    result = runner.invoke(cli, ["scale", "--target", "eu-west-1:3", "--target", "ap-south-1:2"])
    assert result.exit_code == 0
    assert "eu-west-1: 3 replicas (timeout 300s)" in result.output


def test_bad_value_is_a_usage_error():
    result = runner.invoke(cli, ["scale", "--target", "eu-west-1:three"])
    assert result.exit_code == 2
    assert "Invalid value for '--target'" in result.output


def test_help_shows_metavar():
    assert "REGION:COUNT" in runner.invoke(cli, ["scale", "--help"]).output


def test_completion_offers_regions():
    ctx = click.Context(cli.commands["scale"])
    items = TARGET.shell_complete(ctx, cli.commands["scale"].params[0], "eu-")
    assert [i.value for i in items] == ["eu-west-1:", "eu-central-1:"]

The parametrised failure tests pin the error messages as well as the failure, because the messages are part of the user experience. The command tests check the integration: defaults converted, repeated options collected, exit code 2 on bad input, and the metavar in help. For broader coverage of odd inputs, property-based testing CLI arguments with Hypothesis can assert that no string ever makes a type raise anything other than BadParameter.

Conclusion

A custom click.ParamType turns a structured command-line value into a small, reusable, tested component: convert parses and validates, self.fail produces standard usage errors, get_metavar documents the format, and shell_complete makes it fast to type. Accept already-converted values so defaults work, write errors that show the expected format with an example, keep I/O out of conversion, and reuse the same instances from Click and Typer alike. Every command that accepts the value then gets identical parsing, help and errors for free.

Frequently asked questions

Can a type depend on other options?

convert receives the context, and ctx.params holds options parsed so far — but parse order depends on the command line, so relying on it is fragile. Validate relationships between options after parsing instead; see validating dependent and conflicting options.

How do I make a choice type with descriptions in completion?

Return CompletionItem(value, help="description") from shell_complete; zsh and fish display the help text next to each candidate. For fixed choices without custom parsing, click.Choice already completes.

Should types raise ValueError instead of calling fail?

Click converts ValueError from a plain function type= into a generic error, but in a ParamType, self.fail gives you control of the message and includes the parameter name. Use fail.

Can one type produce several values?

Yes: return a tuple or a small dataclass, as Target does. For options that take several separate words, use nargs=2 or a tuple type instead.