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 = TrueFunctions
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 NoneBefore 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 strA more constrained TypeVar — only allow certain types:
from typing import TypeVar
Number = TypeVar("Number", int, float)
def double(value: Number) -> Number:
return value * 2Protocol — 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.balanceSelf — 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 10Literal — 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.pyExample — 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.pyOutput:
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
| What | Python 3.10+ syntax |
|---|---|
| List of strings | list[str] |
| Dict string to int | dict[str, int] |
| Tuple of two floats | tuple[float, float] |
| Set of strings | set[str] |
| Optional string | str | None |
| String or int | str | int |
| Any callable | Callable[..., ReturnType] |
| No return | -> None |
| Type alias | type Point = tuple[float, float] |
| Generic function | TypeVar("T") |
| Structural interface | Protocol |
| Specific values | Literal["a", "b"] |
| Typed dict | TypedDict |
| Returns self | Self (3.11+) |
Summary
Type hints make your code:
- Clearer — anyone reading knows what types to expect
- Safer — tools like
mypycatch 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.