Ch 2.5 Introduction to Type Hints
Type hints are a way to explicitly express type information in Python code. They are ignored at runtime, but they greatly improve IDE support and code readability.
1. What Are Type Hints?
Python is a dynamically typed language, so you are not required to specify types. However, since PEP 484 (Python 3.5+) introduced type hints, they have become essential in large-scale projects.
# Without type hints — unclear what to pass
def calculate_discount(price, rate):
return price * (1 - rate)
# With type hints — a clear contract
def calculate_discount(price: float, rate: float) -> float:
return price * (1 - rate)
Key characteristics of type hints:
- They are not enforced at runtime — they are only hints!
- IDEs (VS Code, PyCharm) use them for auto-completion and error detection
- Static analysis tools like
mypycatch type errors early - They serve as documentation — you can understand how to use a function just from its signature
# Type hints are not enforced at runtime
def add(a: int, b: int) -> int:
return a + b
# This still runs! (no runtime error)
result = add("hello", " world")
print(result) # hello world
# But mypy will catch the error:
# error: Argument 1 to "add" has incompatible type "str"; expected "int"
2. Variable Annotations
# Basic variable annotations
name: str = "Alice"
age: int = 30
height: float = 165.5
is_active: bool = True
# Declare without initializing (type only)
email: str # no value yet
phone_number: str | None
# Local variable annotations inside a function
def process() -> None:
count: int = 0
items: list[str] = []
mapping: dict[str, int] = {}
3. Function Signature Type Hints
# Parameter types + return type
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
# Functions with no return value use -> None
def log_message(message: str) -> None:
print(f"[LOG] {message}")
# Parameters with default values
def repeat(text: str, count: int = 3) -> str:
return text * count
# Variable-length arguments
def sum_all(*args: int) -> int:
return sum(args)
def format_data(**kwargs: str) -> str:
return ", ".join(f"{k}={v}" for k, v in kwargs.items())
print(sum_all(1, 2, 3, 4, 5)) # 15
print(format_data(name="Alice", city="Seoul")) # name=Alice, city=Seoul
4. Basic Type Hints
# Built-in types
x: int = 42
y: float = 3.14
z: str = "hello"
flag: bool = True
nothing: None = None
# A function that can return None
def find_user(user_id: int) -> str | None:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id)
result = find_user(1)
print(result) # Alice
result2 = find_user(99)
print(result2) # None
5. Optional and Union (Python 3.10+ syntax)
# Python 3.9 and below — use the typing module
from typing import Optional, Union
# Optional[str] = str | None
def get_name(user_id: int) -> Optional[str]:
return None if user_id == 0 else "Alice"
# Union[int, str] = int | str
def process(value: Union[int, str]) -> str:
return str(value)
# Python 3.10+ — use the | operator directly (recommended)
def get_name_v2(user_id: int) -> str | None:
return None if user_id == 0 else "Alice"
def process_v2(value: int | str) -> str:
return str(value)
# Multiple type union
def parse(data: int | float | str | None) -> str:
if data is None:
return "null"
return str(data)
6. Generic Type Hints
# Python 3.9+ — use brackets directly on built-in collections
# (Previously: from typing import List, Dict, Tuple, Set)
# Lists
scores: list[int] = [85, 90, 78]
names: list[str] = ["Alice", "Bob"]
# Dictionaries
config: dict[str, str] = {"host": "localhost", "port": "5432"}
user_scores: dict[str, int] = {"Alice": 95, "Bob": 87}
# Tuples (fixed length, type per position)
point: tuple[int, int] = (3, 5)
rgb: tuple[int, int, int] = (255, 128, 0)
person: tuple[str, int, float] = ("Alice", 30, 165.5)
# Variable-length tuple
numbers: tuple[int, ...] = (1, 2, 3, 4, 5)
# Nested collections
matrix: list[list[int]] = [[1, 2, 3], [4, 5, 6]]
graph: dict[str, list[str]] = {"A": ["B", "C"], "B": ["D"]}
# Sets
unique_ids: set[int] = {1, 2, 3}
# Python 3.9 and below — use typing module
from typing import List, Dict, Tuple, Set
scores_old: List[int] = [85, 90]
config_old: Dict[str, str] = {"key": "value"}
7. Type Checking with mypy
# Install mypy
pip install mypy
# Run type checking
mypy script.py
# Strict mode (recommended)
mypy --strict script.py
# Configure mypy via settings file (pyproject.toml)
# Add mypy configuration to pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
# Examples of type errors caught by mypy
def get_length(text: str) -> int:
return len(text)
# Error 1: wrong type passed
# get_length(42)
# error: Argument 1 to "get_length" has incompatible type "int"; expected "str"
# Error 2: return type mismatch
def get_name() -> str:
return None # Error! Returning None, expected str
# error: Incompatible return value type (got "None", expected "str")
# Correct code
def get_name_safe() -> str | None:
return None # str | None is OK
# Error 3: using None without checking
def process_user(user_id: int) -> str:
user = get_name_safe()
return user.upper() # Error! user could be None
# error: Item "None" of "str | None" has no attribute "upper"
# Correct handling
def process_user_safe(user_id: int) -> str:
user = get_name_safe()
if user is None:
return "Unknown"
return user.upper() # Now user is guaranteed to be str
Any, Use TYPE_CHECKING to Resolve Circular ImportsAvoid overusing Any:
from typing import Any
# Bad example — using Any disables type checking
def process(data: Any) -> Any:
return data
# Good example — use specific types
from typing import TypeVar
T = TypeVar("T") # Generic type variable
def identity(value: T) -> T:
"""Returns the input value as-is."""
return value
result: int = identity(42) # OK
result2: str = identity("hello") # OK
TYPE_CHECKING guard to resolve circular imports:
# models.py
from __future__ import annotations # for Python versions before 3.10
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# This import runs only during type checking (not at runtime)
from .service import UserService
class User:
def __init__(self, name: str) -> None:
self.name = name
def process(self, service: "UserService") -> None:
# Importing UserService at runtime would cause a circular import
# Solved with the TYPE_CHECKING guard + string annotation
service.handle(self)
Callable type hints:
from collections.abc import Callable
# Specify argument types and return type
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
def add(x: int, y: int) -> int:
return x + y
print(apply(add, 3, 4)) # 7
print(apply(lambda x, y: x * y, 3, 4)) # 12
TypedDict for explicit dictionary type hints:
from typing import TypedDict
class UserConfig(TypedDict):
name: str
age: int
email: str | None
def create_user(config: UserConfig) -> str:
return f"{config['name']} ({config['age']})"
user: UserConfig = {"name": "Alice", "age": 30, "email": None}
print(create_user(user)) # Alice (30)
You now understand type hints. The variables, basic types, collections, type casting, and type hints covered in Chapter 2 form the foundation of Python programming. Chapter 3 takes an in-depth look at operators and expressions.