Type Annotations

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

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

Restricted Value Sets

from typing import Literal

@proto.cli
def train(
    optimizer: Literal["adam", "sgd", "rmsprop"] = "adam",  # Only these values
    device: Literal["cuda", "cpu", "mps"] = "cuda",  # Hardware selection
):
    """Training with literal types."""
    print(f"Using {optimizer} on {device}")

CLI usage:

python train.py --optimizer sgd --device cpu
# python train.py --optimizer invalid  # Would be an error

Help text shows options:

--optimizer {adam,sgd,rmsprop}
                     Optimizer (default: adam)

Enum Types

Using Python Enums

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 parameter
    device: Device = Device.CUDA,  # Enum with custom values
):
    """Training with enum types."""
    print(f"Optimizer: {optimizer.name}")  # ADAM
    print(f"Device: {device.value}")  # cuda

CLI usage:

python train.py --optimizer SGD --device CPU

Help text:

--optimizer {ADAM,SGD,RMSPROP}
                     Optimizer (default: ADAM)

Collection Types

List Types

from typing import List

@proto.cli
def process(
    files: List[str] = ["input.txt"],  # List of strings
    dimensions: List[int] = [128, 256],  # List of integers
):
    """Process multiple files."""
    for file in files:
        print(f"Processing {file}")

CLI usage:

python process.py --files a.txt b.txt c.txt
python process.py --dimensions 512 1024

Tuple Types

from typing import Tuple

@proto.cli
def train(
    image_size: Tuple[int, int] = (224, 224),  # Fixed-size tuple
    crop_size: tuple[int, int] = (112, 112),  # Python 3.9+ syntax
):
    """Training with tuple types."""
    print(f"Image size: {image_size[0]}x{image_size[1]}")

CLI usage:

python train.py --image-size 256 256

Path Types

pathlib.Path

from pathlib import Path

@proto.cli
def process(
    input_dir: Path = Path("./data"),  # Path parameter
    output_file: Path = Path("output.txt"),  # File path
):
    """Process files with Path types."""
    print(f"Reading from: {input_dir}")
    print(f"Writing to: {output_file}")

CLI usage:

python process.py --input-dir /path/to/data --output-file results.txt

Automatic conversion:

  • Strings are converted to Path objects

  • Path validation happens at runtime (if you check)

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

Supported

CLI Help

Example

int

INT

count: int = 10

float

FLOAT

lr: float = 0.001

str

STR

name: str = "default"

bool

(flag)

debug: bool = False

int | float

VALUE

lr: int | float = 0.001

str | None

STR

path: str | None = None

Literal[...]

{a,b,c}

mode: Literal["a", "b"]

Enum

{A,B,C}

opt: Optimizer = Optimizer.ADAM

List[T]

VALUE

files: List[str] = []

Tuple[T, ...]

VALUE

size: Tuple[int, int]

Path

STR

dir: Path = Path(".")

dict

⚠️

VALUE

Limited support

Custom classes

-

Not supported

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