Type hints make Python code clearer and enable better tooling. Here's what you need to know.

Basic Type Hints

def greet(name: str) -> str:
    return f"Hello, {name}"
 
age: int = 25
prices: list[float] = [19.99, 29.99]
config: dict[str, int] = {"timeout": 30}

Optional and Union

from typing import Optional, Union
 
# Optional = Union with None
def find_user(id: int) -> Optional[User]:
    return users.get(id)  # Returns User or None
 
# Union for multiple types
def process(value: Union[str, int]) -> str:
    return str(value)
 
# Python 3.10+ syntax
def process(value: str | int) -> str:
    return str(value)

Collections

from typing import List, Dict, Set, Tuple, Sequence
 
# Before Python 3.9, use typing module
names: List[str] = ["alice", "bob"]
scores: Dict[str, int] = {"alice": 100}
 
# Python 3.9+: use built-in types
names: list[str] = ["alice", "bob"]
scores: dict[str, int] = {"alice": 100}
 
# Tuple with specific types
point: tuple[float, float] = (1.0, 2.0)
record: tuple[str, int, bool] = ("name", 42, True)
 
# Variable-length tuple
values: tuple[int, ...] = (1, 2, 3, 4)
 
# Sequence for read-only access
def process(items: Sequence[str]) -> None:
    for item in items:
        print(item)

Callable

from typing import Callable
 
# Function that takes (int, int) and returns int
Operation = Callable[[int, int], int]
 
def apply(op: Operation, a: int, b: int) -> int:
    return op(a, b)
 
apply(lambda x, y: x + y, 1, 2)
 
# Any callable
def run(func: Callable[..., None]) -> None:
    func()

TypeVar (Generics)

from typing import TypeVar, List
 
T = TypeVar("T")
 
def first(items: List[T]) -> T:
    return items[0]
 
# Constrained TypeVar
Number = TypeVar("Number", int, float)
 
def add(a: Number, b: Number) -> Number:
    return a + b
 
# Bound TypeVar
from typing import TypeVar
 
class Animal:
    pass
 
A = TypeVar("A", bound=Animal)
 
def process_animal(animal: A) -> A:
    return animal

Generic Classes

from typing import TypeVar, Generic
 
T = TypeVar("T")
 
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:
        return self._items.pop()
 
stack: Stack[int] = Stack()
stack.push(1)
stack.push(2)

Protocol (Structural Typing)

from typing import Protocol
 
class Drawable(Protocol):
    def draw(self) -> None: ...
 
class Circle:
    def draw(self) -> None:
        print("Drawing circle")
 
class Square:
    def draw(self) -> None:
        print("Drawing square")
 
def render(shape: Drawable) -> None:
    shape.draw()
 
# Works with any class that has draw()
render(Circle())
render(Square())

Literal

from typing import Literal
 
Mode = Literal["r", "w", "a"]
 
def open_file(path: str, mode: Mode) -> None:
    ...
 
open_file("data.txt", "r")  # OK
# open_file("data.txt", "x")  # Type error

TypedDict

from typing import TypedDict
 
class UserDict(TypedDict):
    name: str
    age: int
    email: str
 
def process_user(user: UserDict) -> None:
    print(user["name"])
 
# Optional keys
class ConfigDict(TypedDict, total=False):
    debug: bool
    verbose: bool

Final

from typing import Final
 
MAX_SIZE: Final = 100
# MAX_SIZE = 200  # Type error
 
class Config:
    API_URL: Final[str] = "https://api.example.com"

ClassVar

from typing import ClassVar
 
class Counter:
    count: ClassVar[int] = 0  # Class variable
    
    def __init__(self) -> None:
        Counter.count += 1

Self (Python 3.11+)

from typing import Self
 
class Builder:
    def set_name(self, name: str) -> Self:
        self.name = name
        return self
    
    def set_value(self, value: int) -> Self:
        self.value = value
        return self
 
# Enables proper typing for method chaining
builder = Builder().set_name("test").set_value(42)

ParamSpec (Python 3.10+)

from typing import ParamSpec, TypeVar, Callable
 
P = ParamSpec("P")
R = TypeVar("R")
 
def logged(func: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper
 
@logged
def add(a: int, b: int) -> int:
    return a + b

Type Guards (Python 3.10+)

from typing import TypeGuard
 
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in val)
 
def process(items: list[object]) -> None:
    if is_string_list(items):
        # items is now list[str]
        for item in items:
            print(item.upper())

Annotated

from typing import Annotated
 
# Add metadata to types
UserId = Annotated[int, "User ID must be positive"]
Email = Annotated[str, "Must be valid email format"]
 
def create_user(id: UserId, email: Email) -> None:
    ...
 
# Used by validation libraries like Pydantic
from pydantic import Field
Age = Annotated[int, Field(ge=0, le=150)]

Type Aliases

from typing import TypeAlias
 
# Simple alias
Vector: TypeAlias = list[float]
 
# Complex alias
JsonValue: TypeAlias = (
    str | int | float | bool | None |
    list["JsonValue"] | dict[str, "JsonValue"]
)
 
def parse_json(data: str) -> JsonValue:
    import json
    return json.loads(data)

NewType

from typing import NewType
 
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
 
def get_user(user_id: UserId) -> User:
    ...
 
def get_order(order_id: OrderId) -> Order:
    ...
 
# Type checker catches this mistake
user_id = UserId(123)
# get_order(user_id)  # Type error: expected OrderId

cast

from typing import cast
 
# Tell the type checker "trust me"
value = get_value()  # Returns Any
string_value = cast(str, value)
 
# No runtime effect - just for type checkers

TYPE_CHECKING

from typing import TYPE_CHECKING
 
if TYPE_CHECKING:
    # Only imported during type checking, not at runtime
    from expensive_module import HeavyClass
 
def process(obj: "HeavyClass") -> None:
    # Use string annotation for forward reference
    ...

Common Patterns

from typing import overload
 
# Multiple signatures
@overload
def process(x: int) -> int: ...
@overload
def process(x: str) -> str: ...
@overload
def process(x: list[int]) -> list[int]: ...
 
def process(x):
    if isinstance(x, int):
        return x * 2
    elif isinstance(x, str):
        return x.upper()
    else:
        return [i * 2 for i in x]

Best Practices

# Use | instead of Union (3.10+)
def f(x: int | str) -> None: ...
 
# Use built-in generics (3.9+)
def f(items: list[int]) -> dict[str, int]: ...
 
# Return None explicitly
def log(msg: str) -> None:
    print(msg)
 
# Use Self for fluent interfaces (3.11+)
# Use ParamSpec for decorators (3.10+)
# Use TypeGuard for type narrowing (3.10+)

Type hints are documentation that tools can verify. Start with function signatures, then expand to variables where it helps clarity.

React to this post: