Marcus Kazmierczak

Home mkaz.blog

Working With Python

Type Hints

In Python 3.5, type hints were introduced as a means for adding type information. Python 3.9 made many types available as built-ins, and Python 3.10 expanded these capabilities with the union operator (|) and other improvements. This guide assumes Python 3.10+.

Why Use Type Hints?

Type hints are optional annotations that specify expected types for variables, function parameters, and return values. While the Python interpreter does not enforce type hints at runtime, they provide significant benefits:

Catch bugs before runtime:

Type checkers like mypy analyze your code and catch type mismatches before you run it.

# Without type hints - wrong type passed, error only at runtime
def calculate_discount(price, discount_percent):
    return price * (1 - discount_percent)

# Bug: passing string instead of number
total = calculate_discount("100", 0.2)  # TypeError at runtime!
# TypeError: can't multiply sequence by non-int of type 'float'

# With type hints - type checker catches this immediately
def calculate_discount(price: float, discount_percent: float) -> float:
    return price * (1 - discount_percent)

total = calculate_discount("100", 0.2)  # mypy error BEFORE running!
# error: Argument 1 has incompatible type "str"; expected "float"

Another common bug type hints catch:

# Without type hints - forgot to return value
def get_user_name(user_id):
    name = lookup_user(user_id)
    # Oops! Forgot to return - function returns None

result = get_user_name(123)
print(result.upper())  # AttributeError at runtime!

# With type hints - type checker catches missing return
def get_user_name(user_id: int) -> str:
    name = lookup_user(user_id)
    # mypy error: Missing return statement

Provide self-documenting code:

# What does this function expect and return?
def get_user_info(user_id):
    return database.lookup(user_id)

# Much clearer with type hints
def get_user_info(user_id: int) -> dict[str, str]:
    return database.lookup(user_id)

Enable better IDE support:

Type hints power autocomplete, inline documentation, and refactoring tools in modern IDEs.

Basic Types

The core basic types: str, int, float, and bool.

my_string: str = ""
my_int: int = 0
my_float: float = 0.0
my_bool: bool = True

The core collection types: list, dict, tuple, and set.

my_list: list[str] = []
my_dict: dict[str, int] = {}
my_tuple: tuple[str, int] = ("hello", 42)  # Fixed-length tuple
my_set: set[int] = set()

For variable-length tuples, use ellipsis:

my_var_tuple: tuple[int, ...] = (1, 2, 3, 4, 5)  # Variable-length tuple

Functions

To use type hints in a function for arguments and return values.

def add(a: int, b: int) -> int:
    return a + b

Use None when your function does not return a value.

def hello(name: str) -> None:
    print(f"Hello {name}")

Functions with default arguments:

def greet(name: str, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

Variable-length arguments:

# *args - variable positional arguments
def sum_numbers(*args: int) -> int:
    return sum(args)

result = sum_numbers(1, 2, 3, 4, 5)  # 15

# **kwargs - variable keyword arguments
def print_info(**kwargs: str) -> None:
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_info(name="Alice", city="Seattle", role="Developer")

# Combined example
def create_record(id: int, *tags: str, **metadata: str | int) -> dict:
    return {
        "id": id,
        "tags": list(tags),
        "metadata": metadata
    }

record = create_record(1, "python", "tutorial", author="Alice", year=2024)
# {"id": 1, "tags": ["python", "tutorial"], "metadata": {"author": "Alice", "year": 2024}}

Classes

To use type hints in a class.

class Point:
    x: int
    y: int

    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y

    def distance_from_origin(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

Custom Types

You can use your own classes as type hints in other places:

def move_point(point: Point, dx: int, dy: int) -> Point:
    return Point(point.x + dx, point.y + dy)

my_point: Point = Point(1, 2)
new_point: Point = move_point(my_point, 3, 4)

Optional Types

For variables that can be None, use the union syntax with |:

my_optional_string: str | None = None

def find_user(user_id: int) -> str | None:
    # Returns username if found, None otherwise
    return users.get(user_id)

Union Types

Use union types for variables that can hold values of multiple different types. Use the | operator:

# Can be either int or str
my_union: int | str = "Hello"
my_union = 123  # Also valid

# Multiple types including None
id_value: int | str | None = None
id_value = 42
id_value = "user_123"

Literal Types

Use Literal for values that must be specific literals:

from typing import Literal

def set_mode(mode: Literal["read", "write", "append"]) -> None:
    print(f"Setting mode to {mode}")

# This will work
set_mode("read")

# This will cause a type checker error
set_mode("invalid")

Any Type

Use Any when you need to opt out of type checking. Try to limit its use, as it bypasses type checking.

from typing import Any

def process_data(data: Any) -> Any:
    # This function can accept and return anything
    return data

Type Aliases

Create type aliases for complex types:

# Modern syntax using built-in types
Coordinates = list[tuple[float, float]]
UserID = int
JSONData = dict[str, Any]

my_coordinates: Coordinates = [(1.0, 2.0), (3.0, 4.0)]
user_id: UserID = 12345

TypedDict

TypedDict lets you specify the exact structure of dictionaries with specific required keys and value types. This is much more precise than dict[str, Any].

from typing import TypedDict

class UserDict(TypedDict):
    name: str
    age: int
    email: str

def create_user(user: UserDict) -> None:
    print(f"Creating {user['name']}, age {user['age']}")

# Type checker ensures all required keys are present
user: UserDict = {
    "name": "Alice",
    "age": 30,
    "email": "alice@example.com"
}
create_user(user)

# This would cause a type error - missing 'email' key
bad_user: UserDict = {
    "name": "Bob",
    "age": 25
}  # Error: Missing key 'email'

Optional keys can be marked using total=False inheritance:

from typing import TypedDict

# Create base class for optional fields
class OptionalFields(TypedDict, total=False):
    email: str
    phone: str

# Inherit and add required fields
class UserDict(OptionalFields):
    name: str
    age: int

# Now email and phone are optional
user: UserDict = {
    "name": "Alice",
    "age": 30
}  # Valid - email and phone are optional

Generics and TypeVar

Use generics and TypeVar to create generic functions and classes:

from typing import TypeVar, Generic

T = TypeVar('T')

def first_item(items: list[T]) -> T | None:
    return items[0] if items else None

# Usage
numbers = [1, 2, 3]
first_num = first_item(numbers)  # Type checker knows this is int | None

strings = ["a", "b", "c"]
first_str = first_item(strings)  # Type checker knows this is str | None

Generic classes:

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T | None:
        return self._items.pop() if self._items else None

# Usage
int_stack: Stack[int] = Stack()
str_stack: Stack[str] = Stack()

Callable Types

Use Callable to type hint functions that are passed as arguments or returned from functions. The syntax is Callable[[arg_types], return_type].

from collections.abc import Callable

# Function that takes a callback
def process_numbers(
    numbers: list[int],
    transform: Callable[[int], str]  # Takes int, returns str
) -> list[str]:
    return [transform(n) for n in numbers]

# Example callbacks
def to_hex(n: int) -> str:
    return hex(n)

def to_binary(n: int) -> str:
    return bin(n)

# Usage
hex_results = process_numbers([1, 2, 3], to_hex)  # ["0x1", "0x2", "0x3"]
bin_results = process_numbers([1, 2, 3], to_binary)  # ["0b1", "0b10", "0b11"]

Multiple parameters:

def apply_operation(
    a: int,
    b: int,
    operation: Callable[[int, int], int]
) -> int:
    return operation(a, b)

def add(x: int, y: int) -> int:
    return x + y

def multiply(x: int, y: int) -> int:
    return x * y

result1 = apply_operation(5, 3, add)  # 8
result2 = apply_operation(5, 3, multiply)  # 15

No parameters or no return value:

# Function with no parameters that returns a string
def get_greeting_function() -> Callable[[], str]:
    def greet() -> str:
        return "Hello!"
    return greet

# Function taking a string with no return value
def process_with_callback(callback: Callable[[str], None]) -> None:
    callback("Processing...")

def log_message(msg: str) -> None:
    print(f"LOG: {msg}")

process_with_callback(log_message)

Note: Use collections.abc.Callable rather than typing.Callable for better performance.

Final Types

Use Final for constants that should not be reassigned:

from typing import Final

MAX_CONNECTIONS: Final = 100
API_VERSION: Final[str] = "v1.2.3"

# This would cause a type checker error:
MAX_CONNECTIONS = 200

Protocol (Structural Subtyping)

Protocol enables structural subtyping (duck typing) with type checking. Any class that has the required methods automatically satisfies the protocol, without needing explicit inheritance.

from typing import Protocol

class Drawable(Protocol):
    """Any object with a draw() method is Drawable"""
    def draw(self) -> str:
        ...

def render_shape(shape: Drawable) -> None:
    print(shape.draw())

# These classes don't inherit from Drawable,
# but they satisfy the protocol
class Circle:
    def draw(self) -> str:
        return "○"

class Square:
    def draw(self) -> str:
        return "□"

class Text:
    def draw(self) -> str:
        return "ABC"

# All work with render_shape!
render_shape(Circle())  # ○
render_shape(Square())  # □
render_shape(Text())    # ABC

Protocol with properties:

class Sized(Protocol):
    """Any object with a size property"""
    @property
    def size(self) -> int:
        ...

def print_size(obj: Sized) -> None:
    print(f"Size: {obj.size}")

class Array:
    def __init__(self, length: int):
        self._length = length

    @property
    def size(self) -> int:
        return self._length

class File:
    def __init__(self, bytes: int):
        self._bytes = bytes

    @property
    def size(self) -> int:
        return self._bytes

# Both work!
print_size(Array(10))  # Size: 10
print_size(File(1024))  # Size: 1024

Protocols are particularly useful when working with third-party libraries or when you want type checking without requiring inheritance.

Type Guards

Type guards narrow down types within conditional blocks, helping type checkers understand runtime type checks. Use TypeGuard to create custom type checking functions.

from typing import TypeGuard

def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    """Check if all items in list are strings"""
    return all(isinstance(x, str) for x in val)

def process_items(items: list[object]) -> None:
    if is_string_list(items):
        # Type checker now knows items is list[str]
        result = ", ".join(items)  # Works! Type is narrowed
        print(result)
    else:
        print("Not all strings")

# Usage
mixed: list[object] = ["a", "b", "c"]
process_items(mixed)  # "a, b, c"

mixed2: list[object] = ["a", 1, "c"]
process_items(mixed2)  # "Not all strings"

Type narrowing with isinstance:

def handle_value(value: int | str | None) -> str:
    if isinstance(value, str):
        # Type checker knows value is str here
        return value.upper()
    elif isinstance(value, int):
        # Type checker knows value is int here
        return str(value * 2)
    else:
        # Type checker knows value is None here
        return "No value"

NewType

NewType creates distinct types from existing ones, helping prevent mixing up semantically different values that have the same underlying type.

from typing import NewType

# Create distinct types
UserID = NewType("UserID", int)
OrderID = NewType("OrderID", int)

def get_user(user_id: UserID) -> str:
    return f"User {user_id}"

def get_order(order_id: OrderID) -> str:
    return f"Order {order_id}"

# Create instances
user = UserID(42)
order = OrderID(100)

get_user(user)    # ✓ Correct
get_order(order)  # ✓ Correct

get_user(order)   # ✗ Type error! OrderID is not UserID
get_order(user)   # ✗ Type error! UserID is not OrderID

# This also prevents accidental mixing:
def transfer(from_user: UserID, to_user: UserID, amount: float) -> None:
    print(f"Transfer ${amount} from {from_user} to {to_user}")

transfer(user, order, 100.0)  # ✗ Type error caught!

Practical example:

# Without NewType - easy to mix up
def calculate_price(base: float, tax: float, discount: float) -> float:
    return base + tax - discount

# What if you pass them in wrong order? No type error!
calculate_price(10.0, 5.0, 2.0)  # base=10, tax=5, discount=2
calculate_price(5.0, 2.0, 10.0)  # Wrong order but no error!

# With NewType - much safer
Price = NewType("Price", float)
Tax = NewType("Tax", float)
Discount = NewType("Discount", float)

def calculate_price_safe(base: Price, tax: Tax, discount: Discount) -> Price:
    return Price(base + tax - discount)

base = Price(10.0)
tax = Tax(2.0)
discount = Discount(5.0)

calculate_price_safe(base, tax, discount)  # ✓ Correct
calculate_price_safe(tax, discount, base)  # ✗ Type error!

When to Use Type Hints

Best Use Cases

Type hints are most beneficial when you are:

Complex data structures:

# Type hints make complex nested data clear
APIResponse = dict[str, list[dict[str, str | int | bool]]]

Public APIs and libraries:

# Help users understand how to use your code
def fetch_data(endpoint: str, params: dict[str, str] | None = None) -> dict:
    """Users know exactly what to pass and what they'll get back"""

Team projects: - Reduces communication overhead - Makes code reviews easier - Helps new team members understand the codebase - Prevents common type-related bugs

Long-lived production code: - Catches bugs during development, not in production - Makes refactoring safer - Improves maintainability over time

When to Skip Type Hints

You might skip type hints for:

Simple scripts:

# For a 20-line automation script
import os
files = os.listdir(".")  # Type is obvious
for f in files:
    print(f)

Prototyping:

# When exploring ideas, types might change frequently
def experiment(data):
    # Add types later when the design stabilizes
    pass

Obvious contexts:

# Type is clear from context
count = 0
for item in items:
    count += 1

Adoption Strategy

For new projects: Use type hints from the start, especially for function signatures.

For existing projects: Add incrementally: 1. Start with public APIs and interfaces 2. Add to functions causing bugs or confusion 3. Gradually expand coverage 4. Use mypy with permissive settings initially 5. Increase strictness over time

Don't aim for 100% coverage - focus on areas where type hints add the most value.

Common Pitfalls and Best Practices

1. Mutable default arguments

# Dangerous - the list is shared across all calls!
def add_item(item: str, items: list[str] = []) -> list[str]:
    items.append(item)
    return items

# Better - use None as default
def add_item(item: str, items: list[str] | None = None) -> list[str]:
    if items is None:
        items = []
    items.append(item)
    return items

2. Not using Optional when needed

# Bad - claims it always returns str
def get_config(key: str) -> str:
    return config.get(key)  # Could return None!

# Good - acknowledge None is possible
def get_config(key: str) -> str | None:
    return config.get(key)

3. Overusing Any

# Bad - defeats the purpose of type hints
def process(data: Any) -> Any:
    return data.upper()

# Better - be specific
def process(data: str) -> str:
    return data.upper()

# Or use generics if truly generic
from typing import TypeVar

T = TypeVar("T")

def process(data: T) -> T:
    return data

4. Forward references for self-referential classes

# Without forward reference - error!
class TreeNode:
    def __init__(self, value: int):
        self.value = value
        self.left: TreeNode = None   # NameError: TreeNode not defined yet!
        self.right: TreeNode = None

# Solution 1: Use string literals
class TreeNode:
    def __init__(self, value: int):
        self.value = value
        self.left: "TreeNode | None" = None
        self.right: "TreeNode | None" = None

# Solution 2: Use __future__ annotations
from __future__ import annotations

class TreeNode:
    def __init__(self, value: int):
        self.value = value
        self.left: TreeNode | None = None
        self.right: TreeNode | None = None

5. Ignoring type errors without understanding them

# Bad - hiding the problem
result = complex_function(data)  # type: ignore

# Better - understand and fix, or document why ignoring
# Type error: complex_function expects dict but config might be None
# TODO: Ensure config is validated before this call
result = complex_function(data)  # type: ignore[arg-type]

6. Runtime behavior - type hints don't validate at runtime

def add(a: int, b: int) -> int:
    return a + b

# This runs without error! Python doesn't check types at runtime
result = add("hello", "world")  # Returns "helloworld"
print(result)  # Works fine

# Use mypy to catch: mypy script.py
# error: Argument 1 to "add" has incompatible type "str"; expected "int"

Gradual Typing Strategy

When adding type hints to an existing codebase, start incrementally:

Phase 1: Start with public APIs

# Begin with functions other code depends on
def calculate_total(items: list[dict], tax_rate: float) -> float:
    ...

Phase 2: Add return types

# Even without parameter types, return types help
def get_user_data(user_id):  # Parameter untyped for now
    ...
    return user_data  # What type is this?

# Add return type first
def get_user_data(user_id) -> UserDict:
    ...

Phase 3: Fill in parameter types

def get_user_data(user_id: int) -> UserDict:
    ...

Phase 4: Increase strictness gradually

# Start permissive in mypy.ini
[mypy]
check_untyped_defs = False
disallow_untyped_defs = False

# Gradually enable stricter checking
[mypy]
check_untyped_defs = True
warn_return_any = True
# Then later:
disallow_untyped_defs = True

Using type: ignore strategically:

# For legacy code you can't fix yet
legacy_result = old_untyped_function()  # type: ignore[no-untyped-call]

# For third-party libraries without stubs
import some_old_library  # type: ignore[import]

Framework Integration

FastAPI - Type hints power automatic validation:

from fastapi import FastAPI
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
    email: str

app = FastAPI()

@app.post("/users/")
def create_user(user: User) -> dict[str, str]:
    # FastAPI automatically:
    # - Validates input matches User schema
    # - Generates OpenAPI documentation
    # - Provides type hints for IDE
    return {"message": f"Created user {user.name}"}

# Invalid request (age is string) automatically rejected:
# POST /users/ {"name": "Alice", "age": "thirty", "email": "a@example.com"}
# Returns 422 validation error

Benefits: - Automatic request validation - Auto-generated API documentation - IDE autocomplete for request/response - Reduced boilerplate code

Tools and Workflow

Type hints are checked by static analysis tools, not by Python itself at runtime. Here's how to integrate type checking into your workflow.

Type Checking Workflow

1. Write code with type hints:

# user.py
def get_user_email(user_id: int) -> str:
    users = {1: "alice@example.com", 2: "bob@example.com"}
    return users.get(user_id)  # Bug: returns str | None, not str!

result: str = get_user_email(999)
print(result.upper())  # Could crash if result is None!

2. Run type checker:

$ mypy user.py

3. Interpret the output:

user.py:3: error: Incompatible return value type (got "str | None", expected "str")  [return-value]
user.py:5: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]
Found 2 errors in 1 file (checked 1 source file)

4. Fix the issues:

def get_user_email(user_id: int) -> str | None:  # Fixed return type
    users = {1: "alice@example.com", 2: "bob@example.com"}
    return users.get(user_id)

result = get_user_email(999)
if result is not None:  # Handle None case
    print(result.upper())
else:
    print("User not found")

5. Verify the fix:

$ mypy user.py
Success: no issues found in 1 source file

mypy

The original and most widely used type checker:

# Install mypy
pip install mypy

# Run mypy on a file
mypy my_file.py

# Run mypy on a directory
mypy src/

# Show error codes (helpful for targeted ignores)
mypy --show-error-codes my_file.py

# Check for common issues
mypy --strict my_file.py

Common mypy error messages:

# error: Argument 1 has incompatible type "str"; expected "int"
process_age("25")  # Should be: process_age(25)

# error: Missing return statement
def get_value() -> int:
    print("Getting value")  # Forgot to return!

# error: Incompatible types in assignment
value: int = "text"  # Type mismatch

# error: Call to untyped function in typed context
result = untyped_function()  # Function has no type hints

Pyright/Pylance

Microsoft's type checker, used in VS Code's Pylance extension:

# Install pyright
npm install -g pyright

# Run pyright
pyright src/

Configuration

You can configure type checkers with configuration files:

mypy.ini or pyproject.toml for mypy:

[mypy]
python_version = 3.10
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

pyrightconfig.json for Pyright:

{
    "pythonVersion": "3.10",
    "typeCheckingMode": "strict",
    "reportMissingImports": true
}