DocsHub
Advanced

Type Hints

Learn how to use type hints in Python to write clearer, safer, and better-documented code.

Type Hints

Python is dynamically typed — you never have to declare what type a variable holds. But that does not mean you should not. Type hints let you annotate your code with type information, making it clearer to read, easier to debug, and safer to refactor.

# without type hints — what types does this expect?
def create_user(name, age, is_admin):
    ...

# with type hints — immediately clear
def create_user(name: str, age: int, is_admin: bool) -> dict:
    ...

Type hints are completely optional and do not affect runtime behavior. Python ignores them when running your code — they exist for you, your team, and tools like mypy.


Basic syntax

Variables

name: str = "Ali"
age: int = 22
score: float = 88.5
is_active: bool = True

Functions

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

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

def log_event(event: str) -> None:   # returns nothing
    print(f"[LOG] {event}")

The syntax is parameter: type for arguments and -> type after the closing parenthesis for the return type.


Built-in types

Use Python's built-in types directly as annotations — no imports needed:

def process(
    items: list,
    config: dict,
    name: str,
    count: int,
    ratio: float,
    active: bool,
    data: bytes,
) -> tuple:
    ...

Generic types — list, dict, tuple, set

In modern Python (3.9+), use the built-in types directly with square brackets to specify what they contain:

def get_names() -> list[str]:
    return ["Ali", "Sara", "Omar"]

def get_scores() -> dict[str, int]:
    return {"Ali": 88, "Sara": 95}

def get_coords() -> tuple[float, float]:
    return (40.7128, 74.0060)

def get_unique_tags() -> set[str]:
    return {"python", "programming"}

Before Python 3.9, you had to import List, Dict, Tuple, Set from typing:

from typing import List, Dict, Tuple, Set

def get_names() -> List[str]: ...

In Python 3.9+ — just use list[str], dict[str, int] directly. The typing versions still work but are deprecated. Use the modern syntax.


Optional — value or None

X | None means the value can be of type X or None. This is the modern Python 3.10+ syntax:

def find_user(user_id: int) -> dict | None:
    if user_id in database:
        return database[user_id]
    return None

def get_middle_name(name: str) -> str | None:
    parts = name.split()
    if len(parts) > 2:
        return parts[1]
    return None

Before Python 3.10, you used Optional from typing:

from typing import Optional

def find_user(user_id: int) -> Optional[dict]:
    ...

Use X | None in all new code — it is cleaner and requires no import.


Union — multiple possible types

X | Y means the value can be either type X or type Y:

def process_id(user_id: int | str) -> str:
    return str(user_id)

def format_value(value: int | float | str) -> str:
    return str(value)

Before Python 3.10:

from typing import Union

def process_id(user_id: Union[int, str]) -> str: ...

Again — use | in all new code.


Type aliases — naming complex types

Give a name to a complex type so you do not repeat it everywhere:

# define the alias
type Point = tuple[float, float]
type Matrix = list[list[float]]
type UserRecord = dict[str, str | int | bool]

# use it
def distance(a: Point, b: Point) -> float:
    return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5

def multiply_matrices(a: Matrix, b: Matrix) -> Matrix:
    ...

The type keyword for aliases is Python 3.12+. Before that, you wrote:

from typing import TypeAlias

Point: TypeAlias = tuple[float, float]

# or just a plain assignment (works but less explicit)
Point = tuple[float, float]

Callable — typing functions

When a function takes another function as an argument, use Callable:

from typing import Callable

def apply(func: Callable[[int], int], value: int) -> int:
    return func(value)

def run_twice(func: Callable[[], None]) -> None:
    func()
    func()

Callable[[arg_types], return_type] — the first list is argument types, the second is the return type.


TypeVar — generic functions

TypeVar lets you write functions that work with any type while maintaining type consistency:

from typing import TypeVar

T = TypeVar("T")

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

def last(items: list[T]) -> T:
    return items[-1]

T means — whatever type goes in, the same type comes out:

first([1, 2, 3])        # returns int
first(["a", "b", "c"])  # returns str

A more constrained TypeVar — only allow certain types:

from typing import TypeVar

Number = TypeVar("Number", int, float)

def double(value: Number) -> Number:
    return value * 2

Protocol — structural typing

Protocol lets you define an interface — any class that has the required methods satisfies the protocol, regardless of inheritance:

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:
    def draw(self) -> None:
        print("Drawing a circle")

class Square:
    def draw(self) -> None:
        print("Drawing a square")

def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())   # works — Circle has draw()
render(Square())   # works — Square has draw()

Circle and Square do not inherit from Drawable — they just happen to have the same method. This is called structural subtyping or duck typing with type safety.


Annotating classes

class BankAccount:
    owner: str
    balance: float
    transactions: list[tuple[str, float]]

    def __init__(self, owner: str, balance: float = 0.0) -> None:
        self.owner = owner
        self.balance = balance
        self.transactions = []

    def deposit(self, amount: float) -> float:
        self.balance += amount
        self.transactions.append(("deposit", amount))
        return self.balance

    def withdraw(self, amount: float) -> float:
        if amount > self.balance:
            raise ValueError("Insufficient funds.")
        self.balance -= amount
        self.transactions.append(("withdrawal", amount))
        return self.balance

Self — annotating methods that return self

In Python 3.11+ use Self from typing for methods that return an instance of their own class:

from typing import Self

class QueryBuilder:
    def __init__(self) -> None:
        self.filters: list[str] = []
        self.limit_val: int | None = None

    def filter(self, condition: str) -> Self:
        self.filters.append(condition)
        return self

    def limit(self, n: int) -> Self:
        self.limit_val = n
        return self

    def build(self) -> str:
        query = "SELECT * FROM users"
        if self.filters:
            query += " WHERE " + " AND ".join(self.filters)
        if self.limit_val:
            query += f" LIMIT {self.limit_val}"
        return query


query = (
    QueryBuilder()
    .filter("age > 18")
    .filter("is_active = true")
    .limit(10)
    .build()
)

print(query)
# SELECT * FROM users WHERE age > 18 AND is_active = true LIMIT 10

Literal — restrict to specific values

Literal means the value must be one of a specific set:

from typing import Literal

def set_direction(direction: Literal["north", "south", "east", "west"]) -> None:
    print(f"Moving {direction}")

def create_user(role: Literal["admin", "editor", "viewer"]) -> dict:
    return {"role": role}

A type checker will catch create_user("superuser") as an error.


TypedDict — typed dictionaries

When a dictionary has a known structure, use TypedDict:

from typing import TypedDict

class UserRecord(TypedDict):
    name: str
    age: int
    email: str
    is_active: bool

def create_user(name: str, age: int, email: str) -> UserRecord:
    return {
        "name": name,
        "age": age,
        "email": email,
        "is_active": True
    }

Your editor and type checker now know exactly what keys a UserRecord has.


Running mypy — static type checking

Type hints alone do nothing at runtime. To actually check them, use mypy:

pip install mypy
mypy your_file.py

Example — a file with a type error:

# app.py
def add(a: int, b: int) -> int:
    return a + b

result = add("hello", 5)   # wrong — passing str instead of int
print(result)
mypy app.py

Output:

app.py:4: error: Argument 1 to "add" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)

Caught before you even run the code.


Gradual typing — add hints incrementally

You do not have to annotate everything at once. Python supports gradual typing — annotate the parts that matter most and leave the rest:

# fully annotated
def get_user(user_id: int) -> dict[str, str | int]:
    ...

# partially annotated — return type known, args not yet
def process(data) -> list[str]:
    ...

# not annotated — totally fine, especially for internal helpers
def helper(x):
    ...

A good approach — annotate public APIs and function signatures first. Internal helpers can come later.


A complete real example

A typed data pipeline:

from typing import TypedDict, Callable
from functools import reduce

class SaleRecord(TypedDict):
    rep: str
    region: str
    amount: float
    closed: bool

type SalesData = list[SaleRecord]
type Transform = Callable[[SaleRecord], SaleRecord]

def filter_closed(sales: SalesData) -> SalesData:
    return [s for s in sales if s["closed"]]

def apply_bonus(rate: float) -> Transform:
    def transform(sale: SaleRecord) -> SaleRecord:
        return {**sale, "amount": round(sale["amount"] * (1 + rate), 2)}
    return transform

def total_revenue(sales: SalesData) -> float:
    return reduce(lambda acc, s: acc + s["amount"], sales, 0.0)

def top_rep(sales: SalesData) -> str:
    return max(sales, key=lambda s: s["amount"])["rep"]


sales: SalesData = [
    {"rep": "Ali",    "region": "North", "amount": 12000, "closed": True},
    {"rep": "Sara",   "region": "South", "amount": 8500,  "closed": True},
    {"rep": "Omar",   "region": "North", "amount": 15000, "closed": False},
    {"rep": "Fatima", "region": "South", "amount": 9200,  "closed": True},
]

closed = filter_closed(sales)
boosted = list(map(apply_bonus(0.05), closed))

print(f"Total: ${total_revenue(boosted):,.2f}")
print(f"Top rep: {top_rep(boosted)}")

Quick reference — modern type hint syntax

WhatPython 3.10+ syntax
List of stringslist[str]
Dict string to intdict[str, int]
Tuple of two floatstuple[float, float]
Set of stringsset[str]
Optional stringstr | None
String or intstr | int
Any callableCallable[..., ReturnType]
No return-> None
Type aliastype Point = tuple[float, float]
Generic functionTypeVar("T")
Structural interfaceProtocol
Specific valuesLiteral["a", "b"]
Typed dictTypedDict
Returns selfSelf (3.11+)

Summary

Type hints make your code:

  • Clearer — anyone reading knows what types to expect
  • Safer — tools like mypy catch bugs before runtime
  • Better documented — the signature tells the story
  • Editor-friendly — autocomplete and error highlighting work better

You do not have to annotate everything. Start with function signatures — especially public APIs — and work inward from there.

On this page