🎉 75% of content is free forever — Unlock Premium from $10/mo →
CW
Search courses…
💼 Servicesℹ️ About✉️ ContactView Pricing Plansfrom $10

Python Type Hints — Static Typing for Python

Python AdvancedType Hints🟢 Free Lesson

Advertisement

Python Type Hints — Static Typing for Python

Type hints document expected types and enable static analysis. They catch bugs before runtime, improve code readability, and provide better IDE support. Python uses gradual typing, so you can adopt type hints incrementally.

Learning Objectives

  • Add type hints to functions and variables
  • Use advanced types (Union, Optional, TypeVar, Generic)
  • Define protocols and typed structures
  • Run mypy for static type checking
  • Apply gradual typing to existing codebases
  • Understand when and how to use type hints effectively

Basic Type Hints

def greet(name: str) -> str:
    """Greet a person by name."""
    return f"Hello, {name}"

def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

# Variable annotations
age: int = 25
name: str = "Alice"
scores: list[int] = [95, 87, 91]
is_active: bool = True

Function Parameter Types

# Simple types
def process(data: str, count: int, rate: float) -> bool:
    return True

# Multiple types with Union
def format_value(value: int | str) -> str:  # Python 3.10+
    return str(value)

# Using Union (compatible with older Python)
from typing import Union

def format_value(value: Union[int, str]) -> str:
    return str(value)

# No return value
def log_message(message: str) -> None:
    print(message)

# Return any type
def get_value(key: str) -> any:  # Not recommended, but possible
    return data[key]

Advanced Types

Optional — Value or None

from typing import Optional

# Optional[X] is shorthand for Union[X, None]
def find_user(user_id: int) -> Optional[dict]:
    """Find user by ID, return None if not found."""
    users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
    return users.get(user_id)

def process_data(data: Optional[list[str]] = None) -> int:
    """Process data, defaulting to empty list."""
    if data is None:
        data = []
    return len(data)

# Python 3.10+ syntax
def find_user(user_id: int) -> dict | None:
    return users.get(user_id)

TypeVar — Generic Types

from typing import TypeVar, List

T = TypeVar('T')  # Declares a type variable

def first(items: List[T]) -> T:
    """Return the first item in a list."""
    return items[0]

# Usage — type is inferred
result = first([1, 2, 3])  # result is int
result = first(["a", "b"])  # result is str

# Multiple type variables
T = TypeVar('T')
K = TypeVar('K')

def get_first_and_key(items: List[T], key: K) -> tuple[T, K]:
    return items[0], key

# Bounded TypeVar
from typing import TypeVar

Numeric = TypeVar('Numeric', int, float)

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

add(1, 2)      # OK
add(1.5, 2.5)  # OK
add("a", "b")  # mypy error

TypedDict — Typed Dictionaries

from typing import TypedDict, Required, NotRequired

class UserDict(TypedDict):
    """Typed dictionary for user data."""
    name: str
    age: int
    email: str
    is_active: bool

# Usage
user: UserDict = {
    "name": "Alice",
    "age": 30,
    "email": "alice@example.com",
    "is_active": True
}

# Optional fields
class ProductDict(TypedDict, total=False):
    """Product with optional fields."""
    name: str
    price: float
    description: NotRequired[str]
    rating: NotRequired[float]

# Partial (all fields optional)
from typing import TypedDict, Required

class UpdateUser(TypedDict, total=False):
    name: str
    email: str

Callable — Function Types

from typing import Callable, Awaitable

# Simple callable
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)

add = lambda x, y: x + y
result = apply(add, 2, 3)  # 5

# Callable with no arguments
def run_periodically(func: Callable[[], None], interval: float) -> None:
    while True:
        func()
        time.sleep(interval)

# Callable that returns Optional
def find_and_process(
    func: Callable[[str], Optional[dict]],
    key: str
) -> Optional[dict]:
    return func(key)

# Async callable
async def async_apply(
    func: Callable[[int], Awaitable[int]],
    value: int
) -> int:
    return await func(value)

Tuple and FrozenSet

from typing import Tuple, FrozenSet

# Fixed-length tuple
def get_coordinates() -> Tuple[float, float]:
    return (40.7128, -74.0060)

# Variable-length tuple
def get_all_scores() -> Tuple[int, ...]:
    return (85, 92, 78, 95)

# FrozenSet (immutable set)
def get_unique_ids(ids: FrozenSet[int]) -> int:
    return len(ids)

Generic Classes

from typing import TypeVar, Generic, List

T = TypeVar('T')

class Stack(Generic[T]):
    """Generic stack data structure."""

    def __init__(self) -> None:
        self._items: List[T] = []

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

    def pop(self) -> T:
        return self._items.pop()

    def peek(self) -> T:
        return self._items[-1]

    def is_empty(self) -> bool:
        return len(self._items) == 0

    def __len__(self) -> int:
        return len(self._items)

# Type is inferred
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
value: int = int_stack.pop()

str_stack: Stack[str] = Stack()
str_stack.push("hello")
str_value: str = str_stack.pop()

Generic with Multiple Types

from typing import TypeVar, Generic, Dict, List

K = TypeVar('K')
V = TypeVar('V')

class Registry(Generic[K, V]):
    """Generic registry mapping keys to values."""

    def __init__(self) -> None:
        self._data: Dict[K, V] = {}

    def register(self, key: K, value: V) -> None:
        self._data[key] = value

    def get(self, key: K) -> V:
        return self._data[key]

    def get_all(self) -> List[V]:
        return list(self._data.values())

# Usage
user_registry: Registry[int, str] = Registry()
user_registry.register(1, "Alice")
user_registry.register(2, "Bob")

config_registry: Registry[str, int] = Registry()
config_registry.register("port", 8080)
config_registry.register("timeout", 30)

Protocol — Structural Subtyping

Protocols define interfaces without requiring inheritance:

from typing import Protocol

class Drawable(Protocol):
    """Protocol for objects that can be drawn."""

    def draw(self) -> str: ...

class Circle:
    def draw(self) -> str:
        return "O"

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

class Triangle:
    def draw(self) -> str:
        return "/\\"

def render(shape: Drawable) -> str:
    """Render any drawable shape."""
    return shape.draw()

# These all work — no inheritance needed!
render(Circle())   # "O"
render(Square())   # "[]"
render(Triangle()) # "/\\"

Real-World Protocol Example

from typing import Protocol, runtime_checkable

@runtime_checkable
class Database(Protocol):
    """Protocol for database-like objects."""

    def execute(self, query: str) -> list: ...
    def close(self) -> None: ...

class SQLiteDB:
    def __init__(self, path: str):
        self.path = path

    def execute(self, query: str) -> list:
        # SQLite implementation
        return []

    def close(self) -> None:
        pass

class PostgreSQLDB:
    def __init__(self, connection_string: str):
        self.connection_string = connection_string

    def execute(self, query: str) -> list:
        # PostgreSQL implementation
        return []

    def close(self) -> None:
        pass

def process_data(db: Database) -> None:
    """Process data using any database implementation."""
    results = db.execute("SELECT * FROM data")
    # Process results
    db.close()

# Both work!
process_data(SQLiteDB("app.db"))
process_data(PostgreSQLDB("postgresql://localhost/mydb"))

Type Aliases

from typing import TypeAlias, List, Dict, Optional

# Define type aliases for complex types
Vector: TypeAlias = List[float]
Matrix: TypeAlias = List[Vector]
UserDict: TypeAlias = Dict[str, Optional[str]]
JSON: TypeAlias = Dict[str, any]

# Use aliases for cleaner code
def dot_product(a: Vector, b: Vector) -> float:
    return sum(x * y for x, y in zip(a, b))

def matrix_multiply(a: Matrix, b: Matrix) -> Matrix:
    # Complex matrix multiplication
    pass

def process_user(user: UserDict) -> str:
    name = user.get("name", "Unknown")
    return f"Processing {name}"

mypy Static Checking

Basic mypy Usage

# Install: pip install mypy
# Run: mypy your_script.py

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

add("hello", "world")  # mypy error: Argument 1 has type "str", expected "int"

mypy Configuration

# mypy.ini or setup.cfg
[mypy]
python_version = 3.10
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

# Per-module overrides
[mymodule.*]
disallow_untyped_defs = False

Strict Mode

# Run mypy in strict mode
mypy --strict your_script.py

# This checks:
# - All functions have type annotations
# - No implicit Any types
# - No untyped decorators
# - etc.

Common mypy Errors

from typing import List, Optional

# Error: Incompatible return type
def get_name() -> str:
    return 123  # error: Incompatible return value type (got "int", expected "str")

# Error: Missing return statement
def process(x: int) -> str:  # error: Missing return statement
    if x > 0:
        return "positive"

# Error: Incompatible types
def add(x: int, y: int) -> int:
    return x + y

result: str = add(1, 2)  # error: Incompatible types in assignment

# Error: Argument type
def greet(name: str) -> str:
    return f"Hello, {name}"

greet(123)  # error: Argument 1 to "greet" has incompatible type "int"; expected "str"

# Error: Optional handling
def find(x: int) -> Optional[int]:
    return x if x > 0 else None

value: int = find(-1)  # error: Incompatible types in assignment
# Fix: value = find(-1) or 0

Gradual Typing

Add type hints incrementally to existing code:

# Step 1: Add hints to function signatures
def process(data, options=None):  # Before
    pass

def process(data: dict, options: Optional[dict] = None) -> dict:  # After
    pass

# Step 2: Use mypy:ignore for complex sections
def legacy_function(data):  # type: ignore
    # Untyped legacy code
    pass

# Step 3: Add hints to internal functions
def _helper(x: int, y: int) -> int:
    return x + y

# Step 4: Gradually remove type: ignore comments

Type Stub Files

# For third-party libraries without type hints
# Create file: mylib.pyi

from typing import Any, Optional

def my_function(arg: str, option: bool = True) -> dict[str, Any]: ...

class MyClass:
    def __init__(self, value: int) -> None: ...
    def method(self, arg: str) -> Optional[int]: ...

Real-World Examples

Example 1: Typing a Function Library

from typing import TypeVar, Generic, List, Optional, Callable, Any
from dataclasses import dataclass

T = TypeVar('T')
R = TypeVar('R')

@dataclass
class Result(Generic[T]):
    """Result type for operations that can fail."""
    success: bool
    value: Optional[T] = None
    error: Optional[str] = None

    @classmethod
    def ok(cls, value: T) -> 'Result[T]':
        return cls(success=True, value=value)

    @classmethod
    def fail(cls, error: str) -> 'Result[T]':
        return cls(success=False, error=error)

def map_result(result: Result[T], func: Callable[[T], R]) -> Result[R]:
    """Map a function over a Result value."""
    if result.success and result.value is not None:
        return Result.ok(func(result.value))
    return Result.fail(result.error or "Unknown error")

def chain_results(
    results: List[Result[T]],
    func: Callable[[T], Result[R]]
) -> Result[R]:
    """Chain multiple Result operations."""
    for result in results:
        if not result.success:
            return Result.fail(result.error or "Operation failed")
        if result.value is not None:
            mapped = func(result.value)
            if not mapped.success:
                return mapped
    return Result.fail("No results to process")

# Usage
def parse_int(s: str) -> Result[int]:
    try:
        return Result.ok(int(s))
    except ValueError:
        return Result.fail(f"Cannot parse '{s}' as integer")

def double(n: int) -> Result[int]:
    return Result.ok(n * 2)

result = parse_int("42")
mapped = map_result(result, double)
print(mapped)  # Result(success=True, value=84, error=None)

result = parse_int("not a number")
mapped = map_result(result, double)
print(mapped)  # Result(success=False, value=None, error="Cannot parse 'not a number' as integer")

Example 2: Typed API Client

from typing import TypeVar, Generic, List, Optional, Dict, Any
from dataclasses import dataclass
import requests

T = TypeVar('T')

@dataclass
class APIResponse(Generic[T]):
    status_code: int
    data: Optional[T]
    error: Optional[str] = None

class APIClient(Generic[T]):
    """Generic API client with typed responses."""

    def __init__(self, base_url: str, response_type: type):
        self.base_url = base_url
        self.response_type = response_type
        self.session = requests.Session()

    def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> APIResponse[T]:
        try:
            response = self.session.get(
                f"{self.base_url}{endpoint}",
                params=params,
                timeout=10
            )
            response.raise_for_status()
            data = response.json()
            typed_data = self.response_type(**data) if isinstance(data, dict) else data
            return APIResponse(
                status_code=response.status_code,
                data=typed_data
            )
        except Exception as e:
            return APIResponse(
                status_code=0,
                data=None,
                error=str(e)
            )

# Define your data types
@dataclass
class User:
    id: int
    name: str
    email: str

# Usage
client = APIClient("https://api.example.com", User)
response = client.get("/users/1")
if response.data:
    print(f"User: {response.data.name}")

Example 3: Type-Safe Configuration

from typing import TypedDict, NotRequired, Required
from pathlib import Path

class DatabaseConfig(TypedDict, total=False):
    host: str
    port: int
    username: str
    password: str
    database: str
    ssl: bool

class AppConfig(TypedDict):
    """Application configuration with required and optional fields."""
    app_name: str
    debug: bool
    database: DatabaseConfig
    log_level: NotRequired[str]
    max_connections: NotRequired[int]

def load_config() -> AppConfig:
    """Load and validate configuration."""
    config: AppConfig = {
        "app_name": "MyApp",
        "debug": False,
        "database": {
            "host": "localhost",
            "port": 5432,
            "username": "admin",
            "password": "secret",
            "database": "mydb",
        }
    }
    return config

def process_config(config: AppConfig) -> None:
    """Process configuration with type safety."""
    print(f"App: {config['app_name']}")
    print(f"Debug: {config['debug']}")
    db_config = config['database']
    print(f"DB Host: {db_config.get('host', 'localhost')}")
    print(f"DB Port: {db_config.get('port', 5432)}")

Common Mistakes

MistakeProblemSolution
Using Any too muchDefeats purpose of type hintsUse specific types or TypeVar
Not handling OptionalRuntime None errorsCheck for None before using
Over-annotating simple codeClutters codeOnly annotate public APIs
Ignoring mypy errorsMiss real bugsFix errors or use # type: ignore with reason
Not using TypeVarGeneric functions lose type infoUse TypeVar for generic code
Mixing typing stylesConfusing codeBe consistent across codebase

Best Practices

# 1. Annotate public APIs first
def public_function(arg: str, option: bool = True) -> dict[str, Any]:
    """This function's signature is fully typed."""
    pass

def _private_helper(x):  # Internal functions can be untyped initially
    pass

# 2. Use Optional for nullable values
def find_user(user_id: int) -> Optional[User]:
    return db.get_user(user_id)

# 3. Use TypeVar for generics
T = TypeVar('T')

def first(items: list[T]) -> T:
    return items[0]

# 4. Use Protocol for duck typing
class Writable(Protocol):
    def write(self, data: str) -> None: ...

def save_to(file: Writable, data: str) -> None:
    file.write(data)

# 5. Use TypeAlias for complex types
from typing import TypeAlias

JSON: TypeAlias = dict[str, Any]
UserList: TypeAlias = list[dict[str, str]]

# 6. Run mypy regularly
# mypy --strict your_package/

Key Takeaways

  1. Add type hints for better IDE support, documentation, and static analysis
  2. Use Optional[X] (or X | None in 3.10+) for values that can be None
  3. Use TypeVar for generic functions and classes that work with multiple types
  4. Use Protocol for structural subtyping — define interfaces without inheritance
  5. Run mypy to catch type errors statically before runtime
  6. Adopt type hints gradually — start with public APIs and critical functions
  7. Type hints are documentation — they make code self-documenting and easier to understand

Premium Content

Python Type Hints — Static Typing for Python

Unlock this lesson and 900+ advanced tutorials with a Premium plan.

🎯End-to-end Projects
💼Interview Prep
📜Certificates
🤝Community Access

Already a member? Log in

Need Expert Python Help?

Get personalized tutoring, project support, or professional consulting.

Advertisement