Python & AI Tutorials Logo
Python Programming

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:

python
# In a large codebase, what does this function expect and return?
def calculate_discount(customer, items, code):
    # ... 50 lines of code ...
    return result

Without reading the entire function body or its documentation, you can't tell:

  • Is customer a dictionary, a custom object, or something else?
  • Is items a 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:

python
# Without type hints
def add(a, b):
    return a + b
 
# With type hints
def add(a: int, b: int) -> int:
    return a + b

The 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:

python
def calculate_discount(customer: dict, items: list, code: str) -> float:
    # ... 50 lines of code ...
    return result

Now 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:

  1. Optional: You never have to use type hints
  2. Gradual: You can add hints to some parts of your code and not others
  3. Non-intrusive: Hints don't change how Python executes your code
  4. 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.

No Thanks

Yes Please

Python Code

Add Type Hints?

✓ Works Perfectly
✓ Keep It Simple

Add Type Hints

✓ Same Runtime Behavior

✓ Better IDE Support

✓ Catch Errors Earlier

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:

python
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:

python
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 iterable

The 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:

  1. Documentation: They tell you what types a function expects and returns
  2. IDE Support: Your editor can use hints to provide autocomplete and show warnings
  3. Static Analysis: External tools (like mypy) can check your code for type errors before you run it
  4. 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:

python
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 str

Your 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:

  1. You write code with type hints
  2. Your IDE shows warnings when types don't match
  3. You fix the issues before running the code
  4. 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.

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

python
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:

python
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:

python
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.0

Here, 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:

python
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 Doe

The -> str means "this function returns a string." Return type annotations are especially helpful when the return type isn't obvious from the function name:

python
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: True

Without 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:

python
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.00

This function signature tells you everything you need to know:

  • It takes two float parameters: price and discount_percent
  • It returns a float value
  • You don't need to read the implementation to understand how to use this function

Let's see another example with different types:

python
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:

python
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:

python
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.995

Each 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:

python
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
  - 200

The -> 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:

python
# 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 old

The 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:

python
# 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.

python
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.0

Without 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:

python
# 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:

python
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:

python
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 found

The 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:

python
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: None

The 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:

python
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 found

The 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:

python
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: Prince

The 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:

python
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:

python
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.74

The type hint list[float] means "a list containing floats." This is more informative than just list.

Here's another example with strings:

python
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, Charlie

The 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:

python
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: 92

The type hint dict[str, int] means "a dictionary with string keys and integer values."

Here's an example where values can be multiple types:

python
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 old

The 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:

python
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: True

The 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 (...):

python
# 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:

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

The type hint dict[str, list[int]] means "a dictionary with string keys and list-of-integers values."

Here's a more complex example:

python
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, Physics

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

© 2025. Primesoft Co., Ltd.
support@primesoft.ai