Python Union Type Hints: A Detailed Tutorial

Union type hints in Python allow you to specify that a variable, parameter, or return value can be of multiple types.

This feature enhances code readability and helps catch type-related bugs early in development.

 

 

Declare Union types

To declare a simple union type for a variable that can be either an integer or a float:

from typing import Union
temperature: Union[float, int] = 23.5
temperature = 24  # This is also valid
print(f"Current temperature: {temperature}")

Output:

Current temperature: 24

The variable temperature can accept both float and integer values.

You can use Union with multiple types in a function that processes customer data:

from typing import Union, List, Dict
def process_customer_data(data: Union[Dict[str, str], List[str]]) -> str:
    if isinstance(data, dict):
        return f"Customer: {data['name']}, Age: {data['age']}"
    return f"Customer info: {', '.join(data)}"
customer_dict = {"name": "Amr", "age": "30"}
customer_list = ["Amr", "30", "Cairo"]
print(process_customer_data(customer_dict))
print(process_customer_data(customer_list))

Output:

Customer: Amr, Age: 30
Customer info: Amr, 30, Cairo

The function accepts either a dictionary or a list as input and processes the data accordingly.

 

Using the pipe (|) operator

In Python 3.10+, you can use the more concise pipe operator for union types:

def calculate_price(quantity: int | float, price: int | float) -> float:
    return quantity * price
print(calculate_price(2.5, 10))
print(calculate_price(3, 12.5))

Output:

25.0
37.5

The pipe operator provides a cleaner syntax while maintaining the same functionality as the Union type.

 

Type Checking with Union Types

You can use Mypy to check types in a function that processes payment amounts:

from typing import Union
def process_payment(amount: Union[int, float, str]) -> float:
    if isinstance(amount, str):
        return float(amount.replace('$', ''))
    return float(amount)
payment1 = process_payment("$99.99")
payment2 = process_payment(100)
print(f"Processed payments: ${payment1}, ${payment2}")

Output:

Processed payments: $99.99, $100.0

Type checkers verify that all operations are valid for each possible type in the Union.

 

Union Types in Function Signatures

Here’s a function that handles different types of user IDs:

from typing import Union
def get_user(user_id: Union[int, str]) -> dict:
    users = {
        1: {"name": "Heba", "age": 28},
        "A123": {"name": "Karim", "age": 35}
    }
    return users[user_id]
print(get_user(1))
print(get_user("A123"))

Output:

{'name': 'Heba', 'age': 28}
{'name': 'Karim', 'age': 35}

The function accepts both integer and string IDs.

 

Union Types with Generic Types

Combine Union with other complex types

Here’s an example using Union with List and Dict for processing customer orders:

from typing import Union, List, Dict
OrderData = Union[List[Dict[str, str]], Dict[str, List[str]]]
def process_order(order: OrderData) -> str:
    if isinstance(order, list):
        return f"Multiple orders: {len(order)} items"
    return f"Single order with {len(order['items'])} items"
order1 = [{"product": "Laptop", "price": "1200"}, {"product": "Mouse", "price": "25"}]
order2 = {"customer": "Aisha", "items": ["Laptop", "Mouse"]}
print(process_order(order1))
print(process_order(order2))

Output:

Multiple orders: 2 items
Single order with 2 items

The OrderData type alias allows for flexible order data structures.

Nested Union types

Here’s how to handle nested product data with different types:

from typing import Union, Dict, List
ProductPrice = Union[int, float, str]
ProductData = Dict[str, Union[str, ProductPrice, List[str]]]
def format_product(product: ProductData) -> str:
    price = product['price']
    if isinstance(price, str):
        price = float(price.replace('$', ''))
    features = ', '.join(product.get('features', []))
    return f"Product: {product['name']}, Price: ${price}, Features: {features}"
product = {
    'name': 'Smart Watch',
    'price': '$199.99',
    'features': ['Heart Rate Monitor', 'GPS', 'Water Resistant']
}
print(format_product(product))

Output:

Product: Smart Watch, Price: $199.99, Features: Heart Rate Monitor, GPS, Water Resistant

 

Conditional Union types

Here’s how to use TypeGuard for runtime type checking:

from typing import TypeGuard, Union
def is_positive_number(value: Union[int, float]) -> TypeGuard[float]:
    return isinstance(value, (int, float)) and value > 0
def process_value(value: Union[int, float]) -> str:
    if is_positive_number(value):
        return f"Valid positive number: {float(value)}"
    return "Invalid value"
print(process_value(42))
print(process_value(-10))

Output:

Valid positive number: 42.0
Invalid value

TypeGuard helps narrow down the type of a value at runtime.

 

When to use Union types?

Use Union types when you need to handle multiple valid types for a single variable or parameter.

For example, a function that can process both JSON strings and dictionaries:

from typing import Union
import json
def process_config(config: Union[str, dict]) -> dict:
    if isinstance(config, str):
        return json.loads(config)
    return config
json_config = '{"debug": true, "port": 8080}'
dict_config = {"debug": False, "port": 9090}
print(process_config(json_config))
print(process_config(dict_config))

Output:

{'debug': True, 'port': 8080}
{'debug': False, 'port': 9090}

The function handles both string and dictionary inputs.

 

Avoid circular imports with type hints

To avoid circular imports, use string literals for forward references:

from typing import Union
class Node:
    def __init__(self, value: int, next_node: Union[None, 'Node'] = None):
        self.value = value
        self.next_node = next_node
node1 = Node(1)
node2 = Node(2, node1)
print(f"Node value: {node2.value}, Next node value: {node2.next_node.value}")

Output:

Node value: 2, Next node value: 1

String literals in type hints prevent circular import issues.

Leave a Reply

Your email address will not be published. Required fields are marked *