Python is dynamically typed — we don’t have to declare types. But starting with Python 3.5, we can optionally add type hints. They don’t change how the code runs. They’re notes for humans, IDEs, and type checkers like mypy.
Basic Type Hints
We add types to function parameters and return values with : and ->.
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
age: int = 25
name: str = "Manish"
is_active: bool = True
If we pass wrong types, Python won’t stop us. Type hints are not enforced at runtime. But our IDE will highlight the mistake, and mypy will catch it.
Common Types
For simple types, we just use the built-in names:
int,float,str,bool— basic typesNone— for functions that return nothingbytes— for binary data
def process(data: str) -> None:
print(data) # returns None implicitly
Collections
For containers, Python 3.9+ lets us use built-in types directly. Before 3.9, we import from typing.
# Python 3.9+ (preferred)
def get_names() -> list[str]:
return ["Alice", "Bob"]
scores: dict[str, int] = {"math": 95, "science": 88}
point: tuple[float, float] = (3.14, 2.71)
# Python 3.5-3.8 (use typing imports)
from typing import List, Dict, Tuple
def get_names() -> List[str]:
return ["Alice", "Bob"]
Optional and Union
When a value could be one of several types, we use Union. When it could be a type or None, we use Optional.
from typing import Optional, Union
def find_user(user_id: int) -> Optional[str]:
# Returns str or None
if user_id == 1:
return "Manish"
return None
def parse(value: Union[str, int]) -> str:
return str(value)
# Python 3.10+ — cleaner syntax with |
def find_user(user_id: int) -> str | None:
...
def parse(value: str | int) -> str:
return str(value)
Optional[str] is just shorthand for Union[str, None].
TypeVar: Generic Functions
When we want a function that works with any type but preserves the type relationship:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
# Type checker knows: first([1, 2, 3]) returns int
# Type checker knows: first(["a", "b"]) returns str
Protocol: Structural Typing
Protocols let us define “interfaces” without inheritance. If an object has the right methods, it matches.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
def render(shape: Drawable) -> None:
shape.draw()
render(Circle()) # works — Circle has a draw() method
Circle doesn’t inherit from Drawable. It just happens to have the right method. This is structural typing — “if it fits, it works.”
Using mypy
mypy is the most popular static type checker. It reads our type hints and reports errors without running the code.
# example.py
def double(x: int) -> int:
return x * 2
result: str = double(5) # mypy will flag this
$ mypy example.py
error: Incompatible types in assignment (expression has type "int", variable has type "str")
Common Patterns
from typing import Callable, Any
# Function that takes a callback
def retry(func: Callable[..., Any], times: int = 3) -> Any:
for _ in range(times):
try:
return func()
except Exception:
continue
# Type alias
UserID = int
Scores = dict[str, list[int]]
def get_scores(user: UserID) -> Scores:
return {"math": [90, 85, 92]}
In simple language, type hints are like lane markings on a road. The car can still drive anywhere, but the markings help everyone stay safe. We write them for our future selves, our teammates, and our tools.