🦆 Python Type Hints: From Perl's Moose to mypy

2026-02-12

Perl developers understand the value of type safety. Whether through Moose's elegant isa => 'Int' or Moo's lightweight constraints, we've long benefited from catching type errors before they explode in production. But Python's approach to types couldn't be more different and for Perl developers transitioning to Python, it requires a fundamental shift in thinking. 🤔🦞

This guide covers Python's type hint system and mypy static type checker from a Perl developer's perspective. If you're coming from Moose or Moo, you'll discover both familiar patterns and surprising differences. Let's dive into the world of gradual typing, static analysis and the philosophical shift from runtime enforcement to compile-time verification.

The Fundamental Difference: Runtime vs Static

In Perl's Moose ecosystem, type constraints are runtime enforcers. When you declare a Moose attribute with a type, that type is checked every time you set the value:

terminal
package Person;
use Moose;

has 'name' => (is => 'rw', isa => 'Str');
has 'age'  => (is => 'rw', isa => 'Int');  # Runtime type check!

1;

terminal
my $person = Person->new(name => "Alice", age => 30);
$person->age("thirty");  # Dies at runtime with: "Validation failed for 'Int'"

This is powerful and immediate. The moment you violate the contract, your program explodes with a clear message. Moose types are active guards standing watch at your data's gates.

Python type hints, by contrast, are purely informational by default:

terminal
class Person:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age

person = Person("Alice", 30)
person.age = "thirty"  # Perfectly valid Python, no error!

No explosion. No complaint. Python happily assigns a string to what you "hinted" should be an integer. The : int annotation is just that, a hint. The Python interpreter ignores it completely.

But here's where it gets interesting. Enter mypy, the static type checker. While Python itself ignores your type hints, mypy reads them and performs static analysis, catching type errors without ever running your code:

terminal
$ mypy person.py
person.py:8: error: Incompatible types in assignment 
(expression has type "str", variable has type "int")  [assignment]
    person.age = "thirty"
                 ^
Found 1 error in 1 file (checked 1 source file)

This is the paradigm shift for Perl developers: Python separates enforcement from annotation. You write the hints, the static checker validates them and the runtime remains unencumbered. It's a different flavor of safety, pre-runtime rather than in-the-moment.

Basic Type Annotations: The Foundation

Let's start with the simple cases. Python's basic types mirror what you'd expect from Perl:

Perl/Moose Python Type Hint
isa => 'Int' int
isa => 'Str' str
isa => 'Num' float
isa => 'Bool' bool

Python basic annotations:

terminal
age: int = 30
name: str = "Alice"
price: float = 19.99
is_active: bool = True

Perl Moose equivalent:

terminal
has 'age'       => (is => 'rw', isa => 'Int');
has 'name'      => (is => 'rw', isa => 'Str');
has 'price'     => (is => 'rw', isa => 'Num');
has 'is_active' => (is => 'rw', isa => 'Bool');

Simple enough. But notice the philosophical difference: Moose's attributes are declared within a class framework with automatic accessor generation. Python's type annotations can appear anywhere, they're just metadata attached to variables, function parameters or class attributes.

Variable Annotations

Python allows type annotations on any variable, not just class attributes:

terminal
# Anywhere in your code
count: int = 0
message: str = "Hello"
prices: list[float] = [19.99, 29.99, 39.99]  # Python 3.9+

Perl has no direct equivalent for standalone variable type declarations (unless you're using experimental signatures or Data::Class). Perl's philosophy has always been: variables are untyped containers; values have types at runtime.

Function Signatures

Here's where type hints shine, function definitions:

Python with type hints:

terminal
def calculate_total(base_price: float, quantity: int, tax_rate: float = 0.08) -> float:
    subtotal = base_price * quantity
    tax = subtotal * tax_rate
    return subtotal + tax

Perl with experimental signatures (no type checking):

terminal
use feature 'signatures';

sub calculate_total ($base_price, $quantity, $tax_rate = 0.08) {
    my $subtotal = $base_price * $quantity;
    my $tax = $subtotal * $tax_rate;
    return $subtotal + $tax;
}

Perl with Moose type checks (manual):

terminal
use MooseX::Params::Validate;
use Types::Standard qw(Num Int);

sub calculate_total {
    my ($base_price, $quantity, $tax_rate) = validate_pos(
        @_,
        { isa => Num },
        { isa => Int },
        { isa => Num, default => 0.08 }
    );

    my $subtotal = $base_price * $quantity;
    my $tax = $subtotal * $tax_rate;
    return $subtotal + $tax;
}

The Python version achieves static type safety with a simple annotation. The Moose version achieves runtime type safety but requires significantly more boilerplate. This is the trade-off: Python's approach is lighter to write but requires external tooling (mypy) to catch errors. Moose's approach is heavier but provides immediate runtime enforcement.

Generic Types: Containers with Intent

Basic types are fine for scalars, but real code uses collections. This is where generics enter the picture, specifying not just that something is a list, but what that list contains.

Perl's ArrayRef and HashRef

Moose developers know these well:

terminal
has 'names'  => (is => 'rw', isa => 'ArrayRef[Str]');
has 'scores' => (is => 'rw', isa => 'HashRef[Int]');
has 'matrix' => (is => 'rw', isa => 'ArrayRef[ArrayRef[Num]]');

Moose validates these at runtime. Try assigning a string to names and Moose will reject it. Try putting a string where an integer belongs in scores and Moose catches it.

Python's Generic Types

Python's typing module (and modern Python 3.9+ built-ins) provides equivalent constructs:

terminal
from typing import List, Dict, Optional, Union  # Python 3.8 and earlier
# Or use built-in generics in Python 3.9+: list[str], dict[str, int]

# List of strings
names: list[str] = ["Alice", "Bob", "Charlie"]

# Dictionary mapping strings to integers  
scores: dict[str, int] = {"Alice": 95, "Bob": 87}

# Matrix (list of lists of floats)
matrix: list[list[float]] = [[1.0, 2.0], [3.0, 4.0]]

# Optional values
def find_user(user_id: int) -> dict[str, str] | None:
    # May return a user dict or None
    pass

Key differences: 1. Python's generics are hints only, mypy will flag violations, but Python itself won't stop you at runtime 2. Syntax evolution, Python 3.9+ allows list[str]; earlier versions need List[str] from typing 3. Union syntax, Python 3.10+ allows str | None instead of Optional[str]

The Migration Path: Generic Examples

Let's see a complete comparison for a data processing function:

Perl with Moose types:

terminal
package DataProcessor;
use Moose;
use Types::Standard qw(ArrayRef HashRef Int Str);

sub process_records {
    my ($self, $records, $threshold) = validate_pos(
        @_,
        1,  # $self
        { isa => ArrayRef[HashRef[Str, Int]] },  # Array of hashrefs with string keys, int values
        { isa => Int, default => 10 }
    );

    my @results;
    for my $record (@$records) {
        push @results, $record if $record->{score} > $threshold;
    }

    return \@results;
}

__PACKAGE__->meta->make_immutable;
1;

Python with type hints:

terminal
from typing import TypeVar

# Define type variables for more precise generic constraints
T = TypeVar('T', int, float)

def process_records(
    records: list[dict[str, int]], 
    threshold: int = 10
) -> list[dict[str, int]]:
    """Filter records based on score threshold."""
    results: list[dict[str, int]] = []
    for record in records:
        if record.get("score", 0) > threshold:
            results.append(record)
    return results

The Python version is significantly more concise. But notice what's missing: runtime validation. If a caller passes a malformed data structure, Python won't catch it during execution, it might fail with a KeyError or TypeError deeper in the code. mypy would catch mismatches at "compile" time (really, static analysis time), but only if the caller is also type-hinted.

Optional Types and Union Types

Real-world data is messy. Sometimes a value exists; sometimes it's undef/None. Both Perl and Python have patterns for this.

The Maybe Pattern

In Moose, Maybe[T] represents a value that is either type T or undef:

terminal
has 'nickname' => (is => 'rw', isa => 'Maybe[Str]');  # String or undef
has 'spouse'   => (is => 'rw', isa => 'Maybe[Person]');  # Person object or undef

Python's Optional

Python uses Optional[T] (or T | None in Python 3.10+) for the same concept:

terminal
from typing import Optional  # Python 3.9 and earlier

class Person:
    name: str
    nickname: Optional[str] = None  # May be str or None
    spouse: Optional['Person'] = None  # May be Person or None (forward reference)

# Python 3.10+ syntax (cleaner):
class Person:
    name: str
    nickname: str | None = None
    spouse: 'Person' | None = None

Important for Perl developers: In Python, Optional[str] is literally a type that accepts str OR None. Unlike Moose's Maybe, there's no automatic coercion or special behavior, it's just a type annotation. If you declare nickname: str | None but try to use it as a guaranteed string, mypy will flag it:

terminal
def greet(person: Person) -> str:
    return f"Hello, {person.nickname.upper()}!"  # mypy error: Item "None" has no attribute "upper"

This forces you to handle the None case explicitly:

terminal
def greet(person: Person) -> str:
    if person.nickname is not None:
        return f"Hello, {person.nickname.upper()}!"
    return f"Hello, {person.name}!"

Python 3.10+ also adds pattern matching which pairs beautifully with Optional types:

terminal
def greet(person: Person) -> str:
    match person.nickname:
        case str(n):  # Only matches if nickname is a string
            return f"Hello, {n.upper()}!"
        case None:
            return f"Hello, {person.name}!"

Union Types for Multiple Possibilities

Sometimes a value can be one of several types. In Moose, this requires custom subtypes or coercions:

terminal
# Moose: custom subtype for numeric-or-string
coerce 'NumericOrString',
    from 'Str', via { $_ },
    from 'Num', via { $_ };

has 'value' => (is => 'rw', isa => 'NumericOrString');

In Python, Union types are native and simple:

terminal
from typing import Union  # Python 3.9 and earlier

# A value that can be int or str
def format_value(value: int | str) -> str:  # Python 3.10+
    if isinstance(value, int):
        return f"Number: {value}"
    return f"String: {value}"

# Union with multiple types
JsonValue = int | float | str | bool | None | list['JsonValue'] | dict[str, 'JsonValue']

def parse_json(data: JsonValue) -> None:
    pass

The Python approach using | (or Union[] in older versions) is more straightforward than Moose's coercion system, but remember: it's hints only. Python won't stop you at runtime from passing a type that's not in the union.

Class Type Annotations: Defining Structure

Classes are where the comparison gets most interesting. Both Moose and Python's dataclasses (with type hints) aim to create structured data objects with clear contracts.

Moose Class Example

terminal
package User;
use Moose;
use MooseX::StrictConstructor;
use Types::Standard qw(Str Int ArrayRef InstanceOf);
use DateTime;

has 'username'   => (is => 'ro', isa => Str, required => 1);
has 'email'      => (is => 'rw', isa => Str, required => 1);
has 'age'        => (is => 'rw', isa => Int);
has 'tags'       => (is => 'rw', isa => ArrayRef[Str], default => sub { [] });
has 'created_at' => (is => 'ro', isa => InstanceOf['DateTime'], default => sub { DateTime->now });

__PACKAGE__->meta->make_immutable;
1;

# Usage at runtime
my $user = User->new(
    username => "alice",
    email    => "[email protected]",
    age      => 30,
    tags     => ["developer", "perl"]
);

$user->age("thirty");  # Runtime error: validation fails

Python dataclass + Type Hints

terminal
from dataclasses import dataclass, field
from datetime import datetime
from typing import List

@dataclass
class User:
    username: str
    email: str
    age: int | None = None
    tags: list[str] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.now)

# Usage
user = User(
    username="alice",
    email="[email protected]",
    age=30,
    tags=["developer", "python"]
)

user.age = "thirty"  # No runtime error! mypy would catch it statically

Key observations:

  1. Boilerplate reduction, Python's @dataclass generates __init__, __repr__, __eq__ and more automatically. Moose also generates these, but with more ceremony.

  2. Immutability, Moose uses is => 'ro' for read-only. Python dataclasses use frozen=True: python @dataclass(frozen=True) class ImmutableUser: username: str email: str

  3. Constructor strictness, Moose with MooseX::StrictConstructor rejects unknown attributes. Python dataclasses ignore extra arguments by default but can use __post_init__ for validation.

  4. Runtime enforcement, Moose enforces at runtime; Python's dataclass relies on mypy for static checking.

Custom Validators: When Hints Aren't Enough

Sometimes you need actual runtime validation. Perl developers often want to replicate Moose's "validate on setter" behavior. Python can achieve this with property decorators:

terminal
from dataclasses import dataclass, field

@dataclass
class ValidatedUser:
    _username: str = field(init=False)
    _age: int = field(init=False)

    def __init__(self, username: str, age: int):
        self.username = username  # Triggers setter
        self.age = age  # Triggers setter

    @property
    def username(self) -> str:
        return self._username

    @username.setter
    def username(self, value: str) -> None:
        if not value or len(value) < 3:
            raise ValueError("Username must be at least 3 characters")
        self._username = value

    @property  
    def age(self) -> int:
        return self._age

    @age.setter
    def age(self, value: int) -> None:
        if not isinstance(value, int) or value < 0 or value > 150:
            raise ValueError("Age must be an integer between 0 and 150")
        self._age = value

This gives you Moose-like runtime validation, at the cost of significantly more boilerplate. Most Python developers prefer the "gradual typing" approach: use type hints for static safety, add runtime validation only where absolutely necessary.

mypy: Your Static Type Guardian

Type hints alone are just documentation. The power comes from mypy, the static type checker. For Perl developers, this is the biggest mental shift: instead of runtime validation ("die if wrong"), you get static validation ("refuse to check in/type if wrong").

Installation and Basic Usage

terminal
# Install mypy
pip install mypy

# Check a single file
mypy my_script.py

# Check a project (recursively)
mypy .

# Strict mode (recommended for new projects)
mypy --strict my_project/

Example: Catching Errors Early

Consider this buggy code:

terminal
from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float
    quantity: int

def calculate_revenue(products: list[Product]) -> float:
    total = 0
    for p in products:
        total += p.price * p.quantity
    return total

# Bug: passing a string instead of int for quantity
laptop = Product(name="Laptop", price=999.99, quantity="10")  # mypy catches this!
tablet = Product(name="Tablet", price=499.99, quantity=5)

revenue = calculate_revenue([laptop, tablet])

Without mypy, this code runs and likely crashes at runtime or produces wrong results. With mypy:

terminal
$ mypy revenue.py
revenue.py:16: error: Argument "quantity" to "Product" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)

The error was caught before execution, without running a single test. This is the promise of static typing.

Configuration for Projects

Create a pyproject.toml or mypy.ini to configure mypy for your team:

terminal
# pyproject.toml
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_subclassing_any = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true

Or the equivalent mypy.ini:

terminal
[mypy]
python_version = 3.11
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

These strict settings ensure comprehensive type coverage, similar to Moose's strict validation but enforced at "compile" time.

Gradual Typing: The Migration Path

One of mypy's greatest features is gradual typing. You can add hints incrementally to existing code:

terminal
# File: gradually_typed.py

def legacy_function(data):  # No types, allowed!
    return data.upper()

def modern_function(name: str) -> str:  # Fully typed
    return name.upper()

By default, mypy ignores unannotated functions. As you annotate, mypy validates more. This is perfect for migrating Perl codebases, you don't have to type everything at once.

To check untyped functions too, use:

terminal
mypy --check-untyped-defs my_project/

Or add to config:

terminal
[tool.mypy]
check_untyped_defs = true

Type Stubs for Third-Party Libraries

Not all Python libraries have type hints. mypy uses stub files (.pyi) to provide type information for untyped libraries. Many popular libraries now include stubs or have them available in types-* packages:

terminal
# Install stubs for requests, redis, etc.
pip install types-requests types-redis

For libraries without stubs, mypy will report errors. You can:

  1. Add # type: ignore comments for specific calls
  2. Write your own stub files
  3. Use Any type (the escape hatch)
terminal
import untyped_library  # type: ignore
from typing import Any

def use_library() -> Any:
    return untyped_library.mysterious_function()  # Returns Any

Try to minimize Any, it defeats the purpose of type checking. But it's useful for boundaries with untyped code.

Practical Patterns: Perl → Python Type Translation

Let's look at common patterns and how they translate:

Pattern 1: Subtypes and Constraints

Perl Moose with constraints:

terminal
has 'positive_int' => (
    is  => 'rw',
    isa => subtype 'PositiveInt',
        as 'Int',
        where { $_ > 0 },
        message { "Int is not larger than 0" }
);

Python with NewType and validation:

terminal
from typing import NewType, TypeVar
from dataclasses import dataclass

# NewType creates distinct types that are checked by mypy
PositiveInt = NewType('PositiveInt', int)

def create_positive_int(value: int) -> PositiveInt:
    if value <= 0:
        raise ValueError(f"Value {value} is not positive")
    return PositiveInt(value)

@dataclass
class Data:
    count: PositiveInt

    def __post_init__(self):
        if self.count <= 0:
            raise ValueError("count must be positive")

NewType is purely for static checking, at runtime, PositiveInt is just int. For runtime validation, combine with __post_init__ or property setters.

Pattern 2: Enums and Literal Types

Perl Moose with enum:

terminal
enum 'Status', [qw(pending active completed)];
has 'status' => (is => 'rw', isa => 'Status');

Python with Enum or Literal:

terminal
from enum import Enum, auto
from typing import Literal

# Class-based enum (most similar to Moose)
class Status(Enum):
    PENDING = auto()
    ACTIVE = auto()
    COMPLETED = auto()

@dataclass
class Task:
    name: str
    status: Status = Status.PENDING

# Or use Literal for simple cases (lighter weight)
SimpleStatus = Literal["pending", "active", "completed"]

def update_status(status: SimpleStatus) -> None:
    pass

update_status("active")  # OK
update_status("expired")  # mypy error: invalid literal

Pattern 3: Coercions

Perl Moose coercion:

terminal
coerce 'Int',
    from 'Str', via { 0 + $_ };

has 'age' => (is => 'rw', isa => 'Int', coerce => 1);

Python with dataclass and factory:

terminal
from dataclasses import dataclass, field

def coerce_to_int(value: str | int) -> int:
    if isinstance(value, str):
        return int(value)
    return value

@dataclass
class Person:
    _age: int = field(init=False)

    def __init__(self, age: str | int):
        self.age = coerce_to_int(age)  # Type coercion happens here

    @property
    def age(self) -> int:
        return self._age

    @age.setter
    def age(self, value: int) -> None:
        self._age = value

Python lacks Moose's automatic coercion system. You must implement coercions manually in constructors or factory functions.

Pattern 4: Roles and Protocols

Moose "roles" (mixins with validation) have a Python equivalent in Protocols (structural subtyping):

Perl Moose Role:

terminal
package Printable;
use Moose::Role;

requires 'to_string';

sub print {
    my ($self) = @_;
    print $self->to_string(), "\n";
}

package Document;
use Moose;
with 'Printable';

sub to_string { "Document content" }

Python Protocol:

terminal
from typing import Protocol, runtime_checkable

class Printable(Protocol):
    def to_string(self) -> str: ...

    def print(self) -> None:
        print(self.to_string())

@runtime_checkable
class ExplicitPrintable(Protocol):
    """Runtime checkable version."""
    def to_string(self) -> str: ...

# Any class with a to_string method satisfies the protocol
class Document:
    def to_string(self) -> str:
        return "Document content"

def print_anything(item: Printable) -> None:
    item.print()

# Document doesn't need to explicitly declare anything!
doc = Document()
print_anything(doc)  # Works because Document has to_string() method

Protocols use structural subtyping, if a class has the right methods, it satisfies the protocol. No explicit inheritance or declaration required. This is more flexible than Moose roles but lacks runtime role composition.

Best Practices for Perl Developers

After working with both systems, here are my recommendations for Perl developers transitioning to Python type hints:

1. Start Gradual, Then Strict

Begin with basic type hints on new code. Once comfortable, enable strict mypy settings for your project:

terminal
# Start here
mypy .

# Once 80%+ coverage, switch to strict
mypy --strict .

2. Use dataclasses for Data Structures

Prefer @dataclass over plain classes for data containers. It's closest to Moose's declarative attribute system:

terminal
from dataclasses import dataclass

@dataclass(frozen=True)  # Immutable like Moose with is => 'ro'
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False

3. Know When to Add Runtime Validation

type hints catch errors during development. For production safety at boundaries (APIs, config files, user input), add runtime validation:

terminal
from pydantic import BaseModel  # Excellent runtime validation library

class Config(BaseModel):
    host: str
    port: int = Field(ge=1, le=65535, default=8080)  # Runtime bounds checking!

# Pydantic validates at instantiation
config = Config(host="example.com", port=8080)  # OK
config = Config(host="example.com", port=99999)  # Runtime validation error!

Pydantic bridges the gap between Moose-style runtime validation and Python's type hints.

4. Avoid Over-Using Any

Every Any type is a hole in your type safety. Use specific types, even if verbose:

terminal
# Avoid
def bad_function(data: Any) -> Any:
    pass

# Better
def good_function(data: dict[str, int | str]) -> list[str]:
    pass

If a type is truly variable, use TypeVar with constraints:

terminal
from typing import TypeVar

T = TypeVar('T', int, float)  # T must be int or float

def sum_values(values: list[T]) -> T:
    return sum(values)

5. Use mypy in CI/CD

Make mypy part of your build pipeline:

terminal
# .github/workflows/ci.yml
- name: Type Check
  run: mypy --strict .

This ensures type safety is enforced before code merges, just like Moose enforces at runtime.

6. Document Intent with Type Hints

Type hints serve as living documentation. They're more precise than docstrings and always up-to-date (or mypy complains):

terminal
from typing import Callable

def process_data(
    data: list[dict[str, Any]],
    transformer: Callable[[dict[str, Any]], str],
    filter_fn: Callable[[dict[str, Any]], bool] | None = None
) -> list[str]:
    """Process data with optional filtering and transformation."""
    results = []
    for item in data:
        if filter_fn is None or filter_fn(item):
            results.append(transformer(item))
    return results

The Verdict

Perl's Moose/Moo type system and Python's type hints+mypy achieve similar goals through different philosophies:

Aspect Perl Moose Python Type Hints + mypy
Enforcement Runtime (immediate) Static (pre-execution)
Performance Minor setter overhead Zero runtime cost
Flexibility Full runtime customization Gradual adoption possible
Tools Moose debugger, stack traces mypy, IDE integration
Syntax Attribute declaration Variable/function annotation
Generics ArrayRef[Str] list[str]
Validation In setters Pre-runtime + manual if needed

Moose feels like a vigilant security guard, checking every ID at the door. Python's type hints feel like a pre-flight checklist, you verify before takeoff, then fly without interruption.

For Perl developers, the key insight is: static type checking catches the same category of errors but at a different phase. You trade runtime enforcement for static guarantees and zero runtime overhead. It's not better or worse, just different.

The pragmatic approach? Use type hints everywhere. Use mypy strictly. Add runtime validation (pydantic) at system boundaries. You'll get Moose-like safety with Python's modern tooling. 🦆🦞


Questions about type hints or migration strategies? Share your thoughts below!


← back to index