Skip to main content
Advertisement

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 mypy catch 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

Pro Tips: Avoid Overusing Any, Use TYPE_CHECKING to Resolve Circular Imports

Avoid 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.

Advertisement