Type Annotations

params-proto v3 supports rich type annotations for parameters, providing type safety and automatic CLI help generation.

Known Type System Issues

All major types are now fully supported in v3.0.0-rc24!

Previously broken types now working:

  • Literal[...] - Full validation of allowed values

  • Enum - Conversion to enum members (case-insensitive matching)

  • Path - Automatic instantiation via pathlib.Path()

  • dict - Safe parsing using ast.literal_eval

Required Parameters and Callable Types

Key Design Principle: For required parameters (those without default values), params-proto always calls the type hint as a constructor.

This is the foundation for Union types and potential subcommand support.

How It Works

When a parameter has no default value, its type hint must be callable:

from dataclasses import dataclass
from params_proto import proto

@dataclass
class Params:
    lr: float = 0.001
    batch_size: int = 32

@proto.cli
def train(
    config: Params,  # Required parameter - Params will be called as Params()
    epochs: int = 100,  # Optional parameter with default
):
    """Train with configuration."""
    print(f"Using lr={config.lr}, batch_size={config.batch_size}")

CLI Usage:

# params-proto calls Params() to instantiate it
python train.py params --lr 0.01 --batch-size 64

Union Types as Subcommands

This pattern enables Union types to act like subcommands:

from dataclasses import dataclass

@dataclass
class Perspect:
    """Perspective camera configuration."""
    fov: float = 60.0  # Field of view in degrees
    near: float = 0.1  # Near clipping plane
    far: float = 100.0  # Far clipping plane

@dataclass
class Orthographic:
    """Orthographic camera configuration."""
    zoom: float = 1.0  # Zoom level
    near: float = 0.1  # Near clipping plane
    far: float = 100.0  # Far clipping plane

@proto.cli
def render(
    camera: Perspect | Orthographic,  # Required - user must choose which type
    output: str = "output.png",
):
    """Render scene with camera configuration."""
    print(f"Using camera: {camera}")

CLI Usage:

# Choose perspective camera (calls Perspect())
python render.py perspect --fov 45.0 --near 0.1

# Choose orthographic camera (calls Orthographic())
python render.py orthographic --zoom 2.0 --near 0.5

# Get help for specific camera type
python render.py perspect --help
python render.py orthographic --help

The first positional argument selects which type to instantiate, and subsequent arguments configure that instance.

Why This Matters

This design enables:

  1. Type-safe subcommand patterns without special subcommand syntax

  2. Polymorphic configurations - choose between different config types at runtime

  3. Composable types - any callable (dataclass, class, function) works as a type hint

  4. Automatic CLI generation - params-proto generates appropriate help text for each union member

Required vs Optional

The callable type instantiation only applies to required parameters:

@proto.cli
def train(
    # Required: Type hint MUST be callable, will be instantiated
    config: Params,

    # Optional: Uses the default value, type hint is for validation
    epochs: int = 100,
    lr: float = 0.001,
):
    pass

For optional parameters (with defaults), the type hint is used for type conversion and validation, not instantiation.

Class Name to CLI Command Conversion

Important: When using Union types, class names are converted to lowercase CLI commands:

from dataclasses import dataclass

@dataclass
class Perspect:      # Python: PascalCase
    fov: float = 60.0

@dataclass
class Orthographic:
    zoom: float = 1.0

@proto.cli
def render(camera: Perspect | Orthographic):
    """Render with camera."""
    pass

CLI usage:

# Class names become kebab-case commands
python render.py perspect --fov 45.0       # Not Perspect or PERSPECT
python render.py orthographic --zoom 2.0   # Not Orthographic

Conversion rule: PascalCase → kebab-case (e.g., HTTPServerhttp-server, MLModelml-model)

Best Practices for Union Type Names

Use simple single-word names:

# ✓ Recommended
class Train:      # → train
class Evaluate:   # → evaluate
class Export:     # → export

Acronyms and multi-word names now convert properly:

# ✓ Good: acronyms now convert to kebab-case
class HTTPServer:    # → http-server
class MLModel:       # → ml-model
class DeepQNetwork:  # → deep-q-network

# ✓ Also good: simple alternatives remain clear
class Server:     # → server
class Model:      # → model
class Network:    # → network

Basic Types

Primitive Types

from params_proto import proto

@proto.cli
def example(
    count: int = 10,  # Integer parameter
    rate: float = 0.5,  # Floating point parameter
    name: str = "default",  # String parameter
    enabled: bool = True,  # Boolean flag
):
    """Example with basic types."""
    pass

CLI usage:

python example.py --count 20 --rate 0.75 --name custom --enabled
python example.py --no-enabled  # Set to False

Type Conversion

Values are automatically converted to the annotated type:

@proto.cli
def train(
    lr: float = 0.001,  # Converts "0.01" → 0.01
    epochs: int = 100,  # Converts "50" → 50
    debug: bool = False,  # Converts "true" → True
):
    pass

Boolean conversion:

  • True: "true", "1", "yes", "on" (case-insensitive)

  • False: "false", "0", "no", "off", or flag --no-{param}

Optional Types

Using | None (Python 3.10+)

@proto.cli
def process(
    config_file: str | None = None,  # Optional string
    max_items: int | None = None,  # Optional integer
):
    """Process with optional parameters."""
    if config_file:
        print(f"Using config: {config_file}")
    else:
        print("Using defaults")

Using Optional (Python 3.9+)

from typing import Optional

@proto.cli
def process(
    config_file: Optional[str] = None,
    max_items: Optional[int] = None,
):
    """Same as above, alternative syntax."""
    pass

Union Types

Multiple Accepted Types

@proto.cli
def train(
    lr: int | float = 0.001,  # Accepts either int or float
    seed: int | None = None,  # Accepts int or None
):
    """Training with union types."""
    pass

CLI usage:

python train.py --lr 0.01  # float
python train.py --lr 1  # int (also valid)

Literal Types

Literal types restrict parameters to a specific set of allowed values with runtime validation.

from typing import Literal

@proto.cli
def train(
    # String literals
    optimizer: Literal["adam", "sgd", "rmsprop"] = "adam",
    device: Literal["cuda", "cpu", "mps"] = "cuda",
    # Numeric literals
    precision: Literal[16, 32, 64] = 32,
    # Mixed literals
    mode: Literal["auto", 1, 2, 3] = "auto",
):
    """Training with literal types."""
    print(f"Using {optimizer} on {device}, precision={precision}")

CLI usage:

# Valid values are accepted
python train.py --optimizer sgd --device cpu

# Invalid values are rejected with clear error
python train.py --optimizer invalid
# error: value must be one of ('adam', 'sgd', 'rmsprop'), got 'invalid'

# Numeric literals work too
python train.py --precision 16

Help text shows allowed values:

--optimizer {adam,sgd,rmsprop}    Optimizer (default: adam)
--precision {16,32,64}             Precision (default: 32)

Key features:

  • Validates input against allowed values

  • Works with strings, numbers, or mixed types

  • Clear error messages showing valid options

Enum Types

Enums are converted to enum members with automatic case-insensitive name matching.

from enum import Enum, auto
from params_proto import proto

class Optimizer(Enum):
    """Optimizer choices."""
    ADAM = auto()
    SGD = auto()
    RMSPROP = auto()

class Device(Enum):
    """Hardware device."""
    CUDA = "cuda"
    CPU = "cpu"
    MPS = "mps"

@proto.cli
def train(
    optimizer: Optimizer = Optimizer.ADAM,   # Enum with auto()
    device: Device = Device.CUDA,             # Enum with custom values
):
    """Training with enum types."""
    print(f"Optimizer: {optimizer.name}")     # ADAM
    print(f"Optimizer value: {optimizer.value}")  # 1
    print(f"Device: {device.name}")            # CUDA
    print(f"Device value: {device.value}")     # cuda

CLI usage:

# Exact case matching
python train.py --optimizer SGD --device CPU

# Case-insensitive matching (CLI convenience)
python train.py --optimizer adam --device cuda

# Mixed case also works
python train.py --optimizer Sgd --device Mps

Help text shows member names:

--optimizer {ADAM,SGD,RMSPROP}    Optimizer (default: ADAM)
--device {CUDA,CPU,MPS}            Hardware device (default: CUDA)

Key features:

  • Converts CLI strings to enum members

  • Case-insensitive name matching for user convenience

  • Shows available options in help text

  • Works with both auto() and custom values

Collection Types

List Types

List types allow collecting multiple values from the CLI. Each element is converted to the specified type.

from typing import List

@proto.cli
def process(
    files: List[str] = ["input.txt"],  # List of strings
    dimensions: List[int] = [128, 256],  # List of integers
    ratios: List[float] = [0.5, 0.3],  # List of floats
):
    """Process multiple files."""
    print(f"Files: {files}")  # e.g., ['a.txt', 'b.txt', 'c.txt']
    print(f"Dimensions: {dimensions}")  # e.g., [512, 1024]
    print(f"Ratios: {ratios}")  # e.g., [0.8, 0.2]

CLI usage:

# Multiple string values
python process.py --files a.txt b.txt c.txt

# Multiple integer values (type-converted)
python process.py --dimensions 512 1024

# Multiple float values
python process.py --ratios 0.8 0.2

# Combine with other arguments
python process.py --files x.txt y.txt --dimensions 256 256 --ratios 0.5

Help text shows list notation:

--files [STR]            List of input files (default: ['input.txt'])
--dimensions [INT]       Dimensions to process (default: [128, 256])
--ratios [FLOAT]         Aspect ratios (default: [0.5, 0.3])

How it works:

  • Arguments after --flag are collected until the next flag or end of arguments

  • Each value is converted to the element type (e.g., "256"256 for List[int])

  • The result is always a list, even with a single value

Tuple Types

Tuple types allow collecting multiple values from the CLI with automatic element type conversion. Both variable-length and fixed-size tuples are supported.

from typing import Tuple

@proto.cli
def train(
    # Variable-length tuple: Tuple[T, ...] collects values into a tuple
    learning_schedule: Tuple[float, ...] = (0.1, 0.01),
    # Fixed-size tuple: Tuple[T1, T2, T3] has specific type for each position
    image_size: Tuple[int, int] = (224, 224),
    # Mixed types in fixed-size tuple
    config: Tuple[int, str, float] = (10, "default", 0.5),
):
    """Training with tuple types."""
    print(f"Learning schedule: {learning_schedule}")  # e.g., (0.5, 0.1, 0.01)
    print(f"Image size: {image_size[0]}x{image_size[1]}")  # e.g., 256x256
    print(f"Config: {config}")  # e.g., (42, 'custom', 0.75)

CLI usage:

# Variable-length tuple - collects all values
python train.py --learning-schedule 0.5 0.1 0.01

# Fixed-size tuple with specific position types
python train.py --image-size 256 256

# Mixed type fixed-size tuple
python train.py --config 42 custom 0.75

# Combine with other arguments
python train.py --learning-schedule 0.2 0.02 --image-size 512 512

Help text notation:

  • Variable-length: --param (INT,...)

  • Fixed-size: --param (INT,STR,FLOAT)

How it works:

  • Arguments after --flag are collected until the next flag or end of arguments

  • Each value is converted to its corresponding type position

  • For Tuple[T, ...], all values get converted to element type T

  • For fixed-size tuples, each value gets the type from its position

Path Types

pathlib.Path objects are automatically instantiated from string arguments.

from pathlib import Path
from params_proto import proto

@proto.cli
def process(
    input_dir: Path = Path("./data"),      # Directory path
    output_file: Path = Path("output.txt"), # File path
    config: Path = Path.home() / ".config", # Home-relative path
):
    """Process files with Path types."""
    print(f"Reading from: {input_dir}")
    print(f"Writing to: {output_file}")
    print(f"Config at: {config}")
    # Paths are ready to use with pathlib methods
    input_dir.mkdir(parents=True, exist_ok=True)

CLI usage:

# Relative paths
python process.py --input-dir ./data --output-file results.txt

# Absolute paths
python process.py --input-dir /var/data --output-file /tmp/results.txt

# Paths with special characters
python process.py --input-dir ~/Documents/project --output-file ./output/final.txt

Key features:

  • Strings automatically converted to Path objects

  • Works with relative and absolute paths

  • Ready to use with pathlib methods (mkdir(), exists(), etc.)

  • Help text shows PATH type

Dictionary Types

Dictionary types are parsed safely using Python’s ast.literal_eval - no code execution risk.

from typing import Dict
from params_proto import proto

@proto.cli
def config(
    # Generic dict (any keys/values)
    params: Dict = None,
    # Typed dict (documentation)
    settings: Dict[str, int] = None,
    # Complex nested structures
    model_config: Dict[str, float] = None,
):
    """Configure with dictionaries."""
    print(f"params: {params}")
    print(f"settings: {settings}")
    if model_config:
        for key, value in model_config.items():
            print(f"  {key}: {value}")

CLI usage:

# Simple dict with strings and numbers
python config.py --params '{"lr": 0.01, "batch_size": 32}'

# Nested structures
python config.py --settings '{"depth": 3, "width": 128, "dropout": 0}'

# Complex dict with various types
python config.py --model-config '{
    "learning_rate": 0.001,
    "hidden_sizes": [256, 128, 64],
    "activation": "relu",
    "use_batch_norm": true
}'

# Dict with lists and nested dicts
python config.py --params '{"data": {"train": 0.8, "val": 0.1, "test": 0.1}, "tags": ["ml", "v2"]}'

Security note:

  • Uses ast.literal_eval which only evaluates Python literal structures

  • Safe for untrusted input - will never execute arbitrary code

  • Supports: strings, numbers, lists, dicts, tuples, booleans, None

Key features:

  • Safe parsing with ast.literal_eval

  • Supports nested structures and mixed types

  • Works with both single and double quotes: {'a': 1} or {"a": 1}

  • Help text shows dict type: {STR:INT} or {KEY:VALUE}

  • No code execution - purely data literal evaluation

Complex Types

Nested Unions

@proto.cli
def train(
    lr: int | float | None = 0.001,  # Multiple options
    layers: List[int] | None = None,  # Optional list
):
    """Complex union types."""
    pass

Literal with Union

@proto.cli
def process(
    format: Literal["json", "yaml"] | None = None,  # Optional literal
    precision: Literal[16, 32, 64] = 32,  # Numeric literal
):
    """Literal with union types."""
    pass

Type Support Matrix

Type

CLI Support

Help Display

Notes

int

✅ Full

INT

Fully working

float

✅ Full

FLOAT

Fully working

str

✅ Full

STR

Fully working

bool

✅ Full

(flag)

Supports --flag and --no-flag

int | float

✅ Full

VALUE

Ambiguous unions work

str | None (Optional)

✅ Full

STR

Correctly unwraps to inner type

Literal[...]

✅ Full

{a,b,c}

Full validation of allowed values

Enum

✅ Full

{A,B,C}

Enum member conversion (case-insensitive)

List[T]

✅ Full

[INT] or [STR]

Fully working with element type conversion

Tuple[T, ...]

✅ Full

(INT,...) or (INT,STR,FLOAT)

Fully working - variable and fixed-size tuples

Path

✅ Full

PATH

Automatic conversion to Path objects

dict

✅ Full

{STR:INT}

Safe parsing with ast.literal_eval

Union[Class1, Class2]

✅ Full

Subcommand

Works as pseudo-subcommands

Custom classes

❌ Broken

-

Must be dataclasses with Union context

Type Validation

Runtime Checks

@proto.cli
def train(lr: float = 0.001):
    """Training function."""
    # Type already converted by params-proto
    assert isinstance(lr, float), "lr must be float"

    # Add value validation
    if lr <= 0 or lr >= 1:
        raise ValueError("lr must be in (0, 1)")

Using Dataclass Validation

from dataclasses import dataclass
from params_proto import proto

@proto
@dataclass
class Params:    lr: float = 0.001
    batch_size: int = 32

    def __post_init__(self):
        """Validate after initialization."""
        if self.lr <= 0:
            raise ValueError("lr must be positive")
        if self.batch_size < 1:
            raise ValueError("batch_size must be >= 1")

Type Hints Best Practices

1. Be Specific

# ✓ Good: specific literal
@proto.cli
def train(optimizer: Literal["adam", "sgd"] = "adam"):
    pass

# ✗ Avoid: too general
@proto.cli
def train(optimizer: str = "adam"):
    pass

2. Use Optional for Nullable Values

# ✓ Good: explicit None
@proto.cli
def process(config: str | None = None):
    pass

# ✗ Avoid: implicit None
@proto.cli
def process(config: str = None):  # Type mismatch
    pass

3. Document Type Constraints

# ✓ Good: documented constraints
@proto.cli
def train(
    lr: float = 0.001,  # Learning rate in range (0, 1)
    epochs: int = 100,  # Number of epochs (positive)
):
    """Training with documented types."""
    pass

4. Use Enums for Fixed Sets

from enum import Enum

# ✓ Good: enum for options
class Model(Enum):
    RESNET = "resnet50"
    VIT = "vit-base"

@proto.cli
def train(model: Model = Model.RESNET):
    pass

# ✗ Avoid: magic strings
@proto.cli
def train(model: str = "resnet50"):
    # User could pass any string
    pass

Advanced Type Patterns

Generic Types

from typing import TypeVar, Generic, List

T = TypeVar('T')

@proto
class Config(Generic[T]):
    """Generic configuration."""
    items: List[T]
    default: T

# Usage (type parameter determined at runtime)
int_config = Config[int](items=[1, 2, 3], default=0)

NewType for Documentation

from typing import NewType

# Create semantic types
LearningRate = NewType('LearningRate', float)
BatchSize = NewType('BatchSize', int)

@proto.cli
def train(
    lr: LearningRate = 0.001,  # Type hint is self-documenting
    batch_size: BatchSize = 32,
):
    """Training with semantic types."""
    pass

Type Aliases

from typing import Union, List

# Define complex types once
Numeric = Union[int, float]
PathLike = Union[str, Path]
StringList = List[str]

@proto.cli
def process(
    threshold: Numeric = 0.5,
    input_path: PathLike = "data",
    files: StringList = ["a.txt"],
):
    """Using type aliases."""
    pass

Environment Variable Types

Type conversion also works with environment variables:

from params_proto import proto, EnvVar

@proto.cli
def train(
    # Environment variables are type-converted
    lr: float = EnvVar @ "LEARNING_RATE" | 0.001,
    batch_size: int = EnvVar @ "BATCH_SIZE" | 32,
    use_cuda: bool = EnvVar @ "USE_CUDA" | True,
):
    """Types work with EnvVar."""
    pass

Usage:

LEARNING_RATE=0.01 BATCH_SIZE=64 python train.py
# lr will be float(0.01), batch_size will be int(64)

Troubleshooting

Type Mismatch Errors

# Problem: None as default for non-optional type
@proto.cli
def bad(count: int = None):  # Type error!
    pass

# Solution: Use Optional
@proto.cli
def good(count: int | None = None):
    pass

Union Type Ambiguity

# Problem: Ambiguous union
@proto.cli
def ambiguous(value: int | str = 1):
    # CLI string "42" - is it int or str?
    pass

# Solution: Use Literal or Enum for clarity
@proto.cli
def clear(value: Literal[1, 2, 3] = 1):
    pass