42. A Gentle Introduction to Type Hints (Optional)
Throughout this book, you've written Python code without specifying what types of data your variables hold or what types your functions accept and return. Python has worked perfectly fine this way—it's a dynamically typed language, meaning types are determined at runtime as your program executes. This flexibility is one of Python's greatest strengths, allowing you to write code quickly and expressively.
However, as programs grow larger and more complex, this flexibility can sometimes make code harder to understand and maintain. When you see a function like def process_data(items):, you might wonder: What kind of data does items contain? A list of strings? A dictionary? Something else entirely?
Type hints (also called type annotations) provide a way to document the expected types in your code. They're optional additions to Python that can make your code clearer, help catch errors earlier, and enable powerful IDE features—all without changing how Python actually runs your code.
This chapter introduces type hints gently, showing you what they are, why they exist, and how to use them effectively. Because type hints are optional and don't affect how Python executes your code, this entire chapter is marked as optional. You can write excellent Python programs without ever using type hints. But understanding them will help you read modern Python code and decide when they might benefit your own projects.
42.1) Why Type Hints Were Added to Python
Python was designed as a dynamically typed language from the beginning. For decades, Python programmers wrote code without any type information, and this worked wonderfully for countless projects. So why were type hints added to Python in 2015 (with Python 3.5)?
The Challenge of Large Codebases
As Python became more popular for large-scale applications, teams encountered challenges:
# In a large codebase, what does this function expect and return?
def calculate_discount(customer, items, code):
# ... 50 lines of code ...
return resultWithout reading the entire function body or its documentation, you can't tell:
- Is
customera dictionary, a custom object, or something else? - Is
itemsa list, a tuple, or a set? - What type is
code—a string, an integer? - What does the function return—a number, a dictionary, or maybe
None?
In small programs, this ambiguity is manageable. You can easily look at how the function is used elsewhere. But in a codebase with thousands of functions across dozens of files, this becomes difficult.
The Solution: Optional Type Hints
Python's creators decided to add an optional system for documenting types. The key word is "optional"—type hints are completely voluntary. You can use them when they help, ignore them when they don't, and mix annotated and non-annotated code freely.
Here's a simple example to show the basic syntax:
# Without type hints
def add(a, b):
return a + b
# With type hints
def add(a: int, b: int) -> int:
return a + bThe syntax is straightforward:
- Colon (
:) after a parameter shows what type it should be:a: int - Arrow (
->) before the colon shows what type the function returns:-> int
Now let's see this with our earlier example:
def calculate_discount(customer: dict, items: list, code: str) -> float:
# ... 50 lines of code ...
return resultNow it's immediately clear: customer is a dictionary, items is a list, code is a string, and the function returns a float.
Don't worry if this syntax looks unfamiliar—we'll explore it in detail in sections 42.3-42.6. For now, just notice how you can tell at a glance what the function expects and what it returns.
With or without type hints, the function works exactly the same way—Python doesn't check these types at runtime. (We'll explore this important point in detail in section 42.2)
A Gradual, Pragmatic Approach
Python's type hint system was designed to be:
- Optional: You never have to use type hints
- Gradual: You can add hints to some parts of your code and not others
- Non-intrusive: Hints don't change how Python executes your code
- Tool-friendly: External tools can check hints, but Python itself ignores them at runtime
This pragmatic approach lets Python remain flexible while providing benefits for those who want them.
42.2) The Golden Rule: No Enforcement at Runtime
The most important thing to understand about type hints is this: Python does not enforce type hints at runtime. They are purely informational. Let's see what this shocking reality means in practice.
Type Hints Don't Prevent Incorrect Types
Consider this function with type hints:
def greet(name: str) -> str:
return f"Hello, {name}!"
# This works fine, even though 42 is not a string
result = greet(42)
print(result) # Output: Hello, 42!The type hint clearly says name should be a string, but Python happily accepts the integer 42 and runs the function. Python doesn't check the type hint—it just uses the value you provide.
This is fundamentally different from languages like Java or C++, where the compiler checks types before running your code and refuses to run if there's a type mismatch. Python's approach is more permissive: it trusts you to provide the right types, but doesn't force you to.
The Problem: Dynamic Typing Risks Remain
Here's the real challenge: even with type hints, Python's dynamic typing means you can still make type mistakes that only appear at runtime:
def calculate_total(prices: list) -> float:
"""Calculate the sum of prices."""
return sum(prices)
# This works fine
print(calculate_total([10.99, 5.50, 3.25])) # Output: 19.74
# But this fails at runtime!
print(calculate_total("not a list")) # TypeError: 'str' object is not iterableThe type hint clearly says prices should be a list, but Python doesn't stop you from passing a string. The error only appears when the code actually runs and tries to use sum() on the string.
This is frustrating! We added type hints to catch these problems, but the risks of dynamic typing are still there. Type errors can hide in your code until runtime, potentially appearing in production when a user does something unexpected.
So if type hints don't prevent runtime errors, what's the point of using them?
So What Are Type Hints For?
Type hints may not change Python's runtime behavior, but they serve a crucial purpose—they provide information to humans and tools, not to Python itself:
- Documentation: They tell you what types a function expects and returns
- IDE Support: Your editor can use hints to provide autocomplete and show warnings
- Static Analysis: External tools (like mypy) can check your code for type errors before you run it
- Code Understanding: They make large codebases easier to read and maintain
Think of type hints as comments that tools can understand. They don't change how Python runs, but they help you write better code.
But how does this actually help us catch those runtime errors we just saw?
The Solution: Type Hints + IDE Support
This is where type hints truly shine. While Python doesn't enforce them at runtime, your IDE can catch mistakes before you even run the code:
def add_numbers(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
# Your IDE will show a warning here (before you run the code)
result = add_numbers("Hello", "World") # IDE: Warning - expected int, got strYour code editor sees the type hints and can warn you about type mismatches as you type, long before you run the code. This catches many bugs during development instead of in production.
Modern Python development typically works like this:
- You write code with type hints
- Your IDE shows warnings when types don't match
- You fix the issues before running the code
- Runtime errors from type mismatches become much rarer
The type hint doesn't prevent the error at runtime, but your IDE uses it to prevent you from writing buggy code in the first place!
The Best of Both Worlds
Type hints give Python the best of both worlds—catching most errors early while maintaining flexibility:
Development Safety: Your IDE and type checkers catch most type errors during development, so you find bugs early.
def process(data: list) -> list:
return [x * 2 for x in data]
# If you accidentally pass a string:
process("hello") # IDE warns: expected list, got str
# You fix it before running the code!Runtime Flexibility: Python still runs code with type mismatches, which can be useful for quick prototyping or when you intentionally want to accept multiple types.
def add_numbers(a: int, b: int) -> int:
return a + b
# Python will run this, even though types don't match
print(add_numbers(5.5, 3.2)) # Output: 8.7 (works!)
print(add_numbers("Hi", " there")) # Output: Hi there (also works!)This flexibility means you're not locked into a rigid type system. When you need to break the rules (for testing, prototyping, or legitimate use cases), Python lets you. But when you're writing production code, your IDE keeps you safe.
Remember the Golden Rule: Type hints don't change Python's runtime behavior—they just give you and your tools the information needed to catch problems early. You still need to be careful, but now you have powerful allies watching your back.
42.3) Annotating Functions: Parameters and Return Values
The most common use of type hints is annotating function parameters and return values. This tells readers (and tools) what types a function expects and produces. Let's start with the simplest case and build up gradually.
The Basics: Parameter Annotations
To add a type hint to a parameter, place a colon after the parameter name, followed by the type:
def greet(name: str):
"""Greet a person by name."""
return f"Hello, {name}!"
# Usage
message = greet("Alice")
print(message) # Output: Hello, Alice!The syntax name: str means "the parameter name should be a string." You can add type hints to multiple parameters:
def calculate_area(width: float, height: float):
"""Calculate the area of a rectangle."""
return width * height
# Usage
area = calculate_area(5.0, 3.0)
print(area) # Output: 15.0Here, both width and height are annotated as float. The function works the same way as before—type hints don't change behavior—but now your IDE knows what types to expect.
Adding Return Type Annotations
To specify what type a function returns, add -> type after the parameter list and before the colon:
def get_full_name(first: str, last: str) -> str:
"""Combine first and last names."""
return f"{first} {last}"
# Usage
name = get_full_name("John", "Doe")
print(name) # Output: John DoeThe -> str means "this function returns a string." Return type annotations are especially helpful when the return type isn't obvious from the function name:
def is_adult(age: int) -> bool:
"""Check if someone is an adult (18 or older)."""
return age >= 18
# Usage
adult = is_adult(25)
print(adult) # Output: TrueWithout looking at the implementation, you immediately know this function returns a boolean value.
Putting It Together: A Complete Function
Most functions will have both parameter and return type annotations. Here's what a fully annotated function looks like:
def calculate_discount(price: float, discount_percent: float) -> float:
"""Calculate the discounted price."""
discount_amount = price * (discount_percent / 100)
return price - discount_amount
# Usage
original_price = 100.0
discount = 20.0
final_price = calculate_discount(original_price, discount)
print(f"Final price: ${final_price:.2f}") # Output: Final price: $80.00This function signature tells you everything you need to know:
- It takes two
floatparameters:priceanddiscount_percent - It returns a
floatvalue - You don't need to read the implementation to understand how to use this function
Let's see another example with different types:
def repeat_message(message: str, times: int) -> str:
"""Repeat a message a specified number of times."""
return message * times
# Usage
repeated = repeat_message("Hello! ", 3)
print(repeated) # Output: Hello! Hello! Hello! The type hints make it clear that you pass a string and an integer, and get back a string.
Working with Default Values
When a parameter has a default value, place the type hint between the parameter name and the default value:
def create_greeting(name: str, formal: bool = False) -> str:
"""Create a greeting message."""
if formal:
return f"Good day, {name}."
return f"Hi, {name}!"
# Usage
print(create_greeting("Alice")) # Output: Hi, Alice!
print(create_greeting("Bob", formal=True)) # Output: Good day, Bob.The syntax formal: bool = False means "formal is a boolean with a default value of False."
You can have multiple parameters with defaults, all annotated:
def format_price(amount: float, currency: str = "USD", decimals: int = 2) -> str:
"""Format a price with currency symbol."""
if currency == "USD":
symbol = "$"
elif currency == "EUR":
symbol = "€"
else:
symbol = currency
return f"{symbol}{amount:.{decimals}f}"
# Usage
print(format_price(99.99)) # Output: $99.99
print(format_price(99.99, "EUR")) # Output: €99.99
print(format_price(99.995, "USD", 3)) # Output: $99.995Each parameter clearly shows its type and default value, making the function easy to understand and use.
Special Case: Functions That Don't Return Values
Some functions only perform actions (like printing or writing to a file) without returning a value. To make it clear that these functions return nothing, use -> None:
def print_report(title: str, data: list) -> None:
"""Print a formatted report."""
print(f"=== {title} ===")
for item in data:
print(f" - {item}")
# No return statement, so returns None implicitly
# Usage
print_report("Sales Data", [100, 150, 200])Output:
=== Sales Data ===
- 100
- 150
- 200The -> None annotation explicitly indicates that this function doesn't return a meaningful value.
Why use -> None?
- Clarity: It makes your intent explicit—this function is for actions, not results
- IDE Support: Your IDE can warn you if you accidentally try to use the return value
42.4) Simple Variable Annotations
While type hints are most commonly used with functions, you can also annotate variables. Let's see how this works and when it's actually useful.
Basic Variable Annotation Syntax
To annotate a variable, use the same colon syntax as with function parameters:
# Annotating variables
name: str = "Alice"
age: int = 30
height: float = 5.7
is_student: bool = True
print(f"{name} is {age} years old") # Output: Alice is 30 years oldThe syntax name: str = "Alice" means "the variable name is a string and has the value 'Alice'." The annotation doesn't change how the variable works—it's purely informational.
Variable Annotations Are Often Skipped
In practice, variable annotations are rarely used. The reason is simple: Python can infer the type from the value, so annotations are usually redundant:
# These annotations are unnecessary
name: str = "Alice" # Obviously a string
count: int = 0 # Obviously an int
prices: list = [10.99, 5.50] # Obviously a list
settings: dict = {} # Obviously a dict
# Just write this instead
name = "Alice"
count = 0
prices = [10.99, 5.50]
settings = {}When you write name = "Alice", both you and your IDE immediately know it's a string. The annotation adds no useful information.
In real-world Python code, you'll rarely see variable annotations. This is normal and expected. Function annotations are far more important and common.
The One Useful Case: Declaring Variables Before Assignment
There is one situation where variable annotations are genuinely useful: when you need to declare a variable before assigning it a value.
def calculate_statistics(numbers: list) -> dict:
"""Calculate basic statistics from a list of numbers."""
# Declare variables before using them
total: float
count: int
average: float
# Now assign values
total = sum(numbers)
count = len(numbers)
average = total / count if count > 0 else 0.0
return {
"total": total,
"count": count,
"average": average
}
# Usage
result = calculate_statistics([10, 20, 30, 40])
print(f"Average: {result['average']}") # Output: Average: 25.0Without annotations, you can't declare a variable without also assigning it a value. The annotations let you specify the types upfront, which can make the code structure clearer.
This is the main practical use case for variable annotations.
Remember: Variables Can Be Reassigned to Different Types
Even with a type annotation, you can reassign a variable to a different type:
# Start with a string
value: str = "hello"
print(value) # Output: hello
# Reassign to a different type - Python allows this
value = 42
print(value) # Output: 42
# Another type change - still allowed
value = [1, 2, 3]
print(value) # Output: [1, 2, 3]Your IDE or static type checker will warn you about these type changes, but Python itself doesn't prevent them. Type hints guide you toward consistency but don't enforce it at runtime.
42.5) Handling "None": Optional Types and the | Operator
One of the most common patterns in Python is a function that might return a value or might return None. For example, searching for an item might succeed (returning the item) or fail (returning None). Type hints provide clear ways to express this pattern.
The Problem: Functions That Might Return None
Consider this function that searches for a user:
def find_user_by_email(email: str) -> dict:
"""Find a user by email address."""
users = [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"}
]
for user in users:
if user["email"] == email:
return user
return None # Type mismatch! This contradicts the -> dict hint
# Usage
user = find_user_by_email("alice@example.com")
if user:
print(f"Found: {user['name']}") # Output: Found: Alice
else:
print("User not found")The type hint -> dict is misleading because the function can return None. A static type checker would warn you that returning None doesn't match the declared return type of dict.
Solution: Using the | Operator for Optional Types
Python 3.10 introduced the | operator for type hints, which means "or." You can use it to indicate that a function might return one type or another:
def find_user_by_email(email: str) -> dict | None:
"""Find a user by email address. Returns None if not found."""
users = [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"}
]
for user in users:
if user["email"] == email:
return user
return None
# Usage
user = find_user_by_email("alice@example.com")
if user:
print(f"Found: {user['name']}") # Output: Found: Alice
missing = find_user_by_email("charlie@example.com")
if missing is None:
print("User not found") # Output: User not foundThe type hint -> dict | None means "this function returns either a dictionary or None." This accurately describes the function's behavior.
Note: In older Python code (before 3.10), you might see Optional[dict] from the typing module instead of dict | None. They mean the same thing, but | is the modern, preferred syntax.
Using | with Multiple Types
You can use | to indicate more than two possible types:
def parse_value(text: str) -> int | float | None:
"""Parse a string into a number. Returns None if parsing fails."""
try:
# Try parsing as integer first
if '.' not in text:
return int(text)
# Otherwise parse as float
return float(text)
except ValueError:
return None
# Usage
print(parse_value("42")) # Output: 42 (int)
print(parse_value("3.14")) # Output: 3.14 (float)
print(parse_value("invalid")) # Output: NoneThe type hint -> int | float | None means the function can return an integer, a float, or None.
Checking for None: Best Practices
When a function can return None, always check for None before using the result. Otherwise, you risk errors when trying to use None as if it were the expected type:
def get_user_age(user_id: int) -> int | None:
"""Get user's age. Returns None if user not found."""
users = {1: 25, 2: 30, 3: 35}
return users.get(user_id)
# Always check for None before using the value
age = get_user_age(1)
if age is not None:
print(f"User is {age} years old") # Output: User is 25 years old
if age >= 18:
print("User is an adult") # Output: User is an adult
else:
print("User not found")
# For non-existent users
age = get_user_age(999)
if age is None:
print("User not found") # Output: User not foundThe key is using if age is not None: or if age is None: to explicitly check before using the value.
Optional Parameters with | None
You can use | with parameters too, often combined with default values:
def format_name(first: str, middle: str | None = None, last: str = "") -> str:
"""Format a full name. Middle name is optional."""
if middle and last:
return f"{first} {middle} {last}"
elif last:
return f"{first} {last}"
return first
# Usage
print(format_name("John", "Q", "Doe")) # Output: John Q Doe
print(format_name("Jane", None, "Smith")) # Output: Jane Smith
print(format_name("Prince")) # Output: PrinceThe type hint middle: str | None = None indicates that middle can be a string or None, with None as the default value. This is a common pattern for optional parameters.
42.6) Reading Common Type Hints: list, dict, tuple
As you read Python code written by others, you'll encounter type hints for collections like lists, dictionaries, and tuples. Modern Python provides clear ways to specify not just that something is a list, but what type of items the list contains.
Note: The syntax shown here (list[int], dict[str, int], etc.) works in Python 3.9+. In older code, you might see List[int] and Dict[str, int] (capitalized) from the typing module—they work the same way.
Basic Collection Type Hints
The simplest collection type hints just specify the collection type:
def print_items(items: list) -> None:
"""Print all items in a list."""
for item in items:
print(item)
def get_user_settings() -> dict:
"""Get user settings as a dictionary."""
return {"theme": "dark", "notifications": True}
def get_position() -> tuple:
"""Get x, y position."""
return (10, 20)These hints tell you the collection type but not what's inside.
Lists: Specifying Item Types
To specify what type of items a list contains, use square brackets:
def calculate_total(prices: list[float]) -> float:
"""Calculate the total of all prices."""
return sum(prices)
# Usage
total = calculate_total([10.99, 5.50, 3.25])
print(f"Total: ${total:.2f}") # Output: Total: $19.74The type hint list[float] means "a list containing floats." This is more informative than just list.
Here's another example with strings:
def format_names(names: list[str]) -> str:
"""Format a list of names as a comma-separated string."""
return ", ".join(names)
# Usage
students = ["Alice", "Bob", "Charlie"]
print(format_names(students)) # Output: Alice, Bob, CharlieThe type hint list[str] means "a list containing strings."
Dictionaries: Specifying Key and Value Types
For dictionaries, specify both the key type and value type:
def get_student_grades() -> dict[str, int]:
"""Get student names mapped to their grades."""
return {
"Alice": 95,
"Bob": 87,
"Charlie": 92
}
# Usage
grades = get_student_grades()
for name, grade in grades.items():
print(f"{name}: {grade}")Output:
Alice: 95
Bob: 87
Charlie: 92The type hint dict[str, int] means "a dictionary with string keys and integer values."
Here's an example where values can be multiple types:
def get_user_data(user_id: int) -> dict[str, str | int]:
"""Get user data. Values can be strings or integers."""
return {
"name": "Alice",
"email": "alice@example.com",
"age": 30,
"id": 12345
}
# Usage
user = get_user_data(1)
print(f"{user['name']} is {user['age']} years old") # Output: Alice is 30 years oldThe type hint dict[str, str | int] means "a dictionary with string keys and values that are either strings or integers."
Tuples: Fixed and Variable Length
Tuples are different from lists because they often have a fixed structure. You can specify the type of each position:
def get_user_info(user_id: int) -> tuple[str, int, bool]:
"""
Get user information as a tuple.
Returns: (name, age, is_active)
"""
return ("Alice", 30, True)
# Usage
name, age, active = get_user_info(1)
print(f"{name}, {age}, active: {active}") # Output: Alice, 30, active: TrueThe type hint tuple[str, int, bool] means "a tuple with exactly three elements: a string, an integer, and a boolean, in that order."
For tuples of variable length with items of the same type, use ellipsis (...):
# Fixed-length tuple: exactly 2 floats
def get_2d_point() -> tuple[float, float]:
"""Get 2D coordinates (x, y)."""
return (10.5, 20.3)
# Variable-length tuple: any number of floats
def get_coordinates() -> tuple[float, ...]:
"""Get coordinates. Can be 2D, 3D, or any dimension."""
return (10.5, 20.3, 15.7) # 3D in this case
# Usage
point = get_2d_point()
coords = get_coordinates()
print(f"2D point: {point}") # Output: 2D point: (10.5, 20.3)
print(f"Coordinates: {coords}") # Output: Coordinates: (10.5, 20.3, 15.7)The type hint tuple[float, ...] means "a tuple containing any number of floats." The ... means "any number of this type."
Nested Collections
You can nest type hints for complex data structures. Let's start with a simple example:
def get_scores_by_student() -> dict[str, list[int]]:
"""Get test scores for each student."""
return {
"Alice": [95, 87, 92],
"Bob": [88, 91, 85],
"Charlie": [90, 88, 94]
}
# Usage
scores = get_scores_by_student()
for name, tests in scores.items():
average = sum(tests) / len(tests)
print(f"{name}: {average:.1f}")Output:
Alice: 91.3
Bob: 88.0
Charlie: 90.7The type hint dict[str, list[int]] means "a dictionary with string keys and list-of-integers values."
Here's a more complex example:
def get_student_records() -> list[dict[str, str | int]]:
"""Get a list of student records."""
return [
{"name": "Alice", "age": 20, "major": "CS"},
{"name": "Bob", "age": 21, "major": "Math"},
{"name": "Charlie", "age": 19, "major": "Physics"}
]
# Usage
students = get_student_records()
for student in students:
print(f"{student['name']}, {student['age']}, {student['major']}")Output:
Alice, 20, CS
Bob, 21, Math
Charlie, 19, PhysicsThe type hint list[dict[str, str | int]] means "a list of dictionaries, where each dictionary has string keys and values that are either strings or integers."
Reading Type Hints: A Quick Reference
When you encounter type hints in code, here's how to read them:
Collections:
list[int]- "a list of integers"dict[str, float]- "a dictionary with string keys and float values"tuple[str, int]- "a tuple with exactly two items: a string, then an integer"tuple[float, ...]- "a tuple containing any number of floats"
Optional and Multiple Types:
int | None- "an integer or None"str | int | float- "a string, integer, or float"
Nested:
list[dict[str, int]]- "a list of dictionaries (each dict has string keys and integer values)"dict[str, list[float]]- "a dictionary with string keys and list-of-floats values"
Note: In older code (Python < 3.10), you might see Union[int, str] instead of int | str, or Optional[int] instead of int | None. They mean the same thing.