Skip to content

Optional and Union

Optional represents values that can be None, while Union represents a value that can be one of several types.

Optional - Nullable Types

Use Optional[T] when a value can be of type T or None.

from typing import Optional

def find_user(user_id: int) -> Optional[str]:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

print(find_user(1))  # Alice
print(find_user(99))  # None
Alice
None

Union - Multiple Possible Types

Use Union[T1, T2] when a value can be one of several types.

from typing import Union

def process_id(value: Union[int, str]) -> str:
    if isinstance(value, int):
        return f"Integer ID: {value}"
    else:
        return f"String ID: {value}"

print(process_id(123))
print(process_id("ABC"))
Integer ID: 123
String ID: ABC

Modern Syntax with |

Python 3.10+ allows using | instead of Union.

# Python 3.10+ syntax
def process_data(value: int | str) -> str:
    return f"Got: {value}"

# Optional is equivalent to T | None
def find_item(item_id: int) -> str | None:
    items = {1: "Item A"}
    return items.get(item_id)

print(process_data(42))
print(find_item(1))
print(find_item(99))
Got: 42
Item A
None

Runnable Example: optional_union_tutorial.py

"""
Tutorial 04: Optional and Union Types
======================================

Level: Intermediate

This tutorial covers Optional and Union types, which allow you to specify
that a variable or parameter can be one of multiple types. These are
essential for handling None values and creating flexible type signatures.

Learning Objectives:
- Master the Optional type for nullable values
- Use Union types for multiple possible types
- Understand the difference between Optional and Union
- Handle None values safely in type-checked code
- Use type narrowing with isinstance checks

Prerequisites:
- Tutorial 01: Basic Type Hints
- Tutorial 02: Function Annotations
- Tutorial 03: Collection Type Hints
"""

from typing import Optional, Union, List, Dict

# =============================================================================
# SECTION 1: The Optional Type
# =============================================================================

"""
Optional[X] is equivalent to Union[X, None].
It indicates that a value can be either type X or None.

Use Optional when:
- A parameter has a default value of None
- A function might return None in some cases
- A variable might not be initialized immediately

Syntax:
- Optional[type] is shorthand for Union[type, None]
"""

def find_user(user_id: int) -> Optional[str]:
    """
    Find a user by ID, return None if not found.

    Parameters:
    - user_id (int): User ID to search for

    Returns:
    - Optional[str]: Username if found, None otherwise
    """
    # Simulated database lookup
    users = {1: "alice", 2: "bob", 3: "charlie"}
    return users.get(user_id)  # Returns None if key not found


def greet_user(name: Optional[str] = None) -> str:
    """
    Greet a user, with optional name parameter.

    Parameters:
    - name (Optional[str]): User's name, or None for generic greeting

    Returns:
    - str: Greeting message
    """
    if name is None:
        return "Hello, guest!"
    return f"Hello, {name}!"


def parse_int(value: str) -> Optional[int]:
    """
    Parse a string to an integer, return None if invalid.

    Parameters:
    - value (str): String to parse

    Returns:
    - Optional[int]: Parsed integer or None if parsing fails
    """
    try:
        return int(value)
    except ValueError:
        return None


# Optional with collections
def get_first_element(items: List[int]) -> Optional[int]:
    """
    Get the first element of a list, or None if list is empty.

    Parameters:
    - items (List[int]): List of integers

    Returns:
    - Optional[int]: First element or None
    """
    if items:
        return items[0]
    return None


def find_max(numbers: List[float]) -> Optional[float]:
    """
    Find maximum value in a list, return None if list is empty.

    Parameters:
    - numbers (List[float]): List of numbers

    Returns:
    - Optional[float]: Maximum value or None
    """
    if not numbers:
        return None
    return max(numbers)


# =============================================================================
# SECTION 2: Working with Optional Values
# =============================================================================

"""
When working with Optional values, you should check for None before using
the value. This is called "type narrowing" - the type checker understands
that after a None check, the value is no longer Optional.
"""

def process_optional_string(text: Optional[str]) -> int:
    """
    Process a string that might be None.

    Demonstrates proper handling of Optional values.

    Parameters:
    - text (Optional[str]): String to process, or None

    Returns:
    - int: Length of string, or 0 if None
    """
    # Check for None before using the value
    if text is None:
        return 0

    # After the None check, text is known to be str (not Optional[str])
    return len(text)  # Type checker knows text is str here


def get_initials(name: Optional[str]) -> str:
    """
    Get initials from a name, return empty string if name is None.

    Parameters:
    - name (Optional[str]): Full name or None

    Returns:
    - str: Initials or empty string
    """
    if name is None:
        return ""

    words = name.split()
    if not words:
        return ""

    return "".join(word[0].upper() for word in words)


def double_if_present(value: Optional[int]) -> Optional[int]:
    """
    Double a number if present, otherwise return None.

    Parameters:
    - value (Optional[int]): Number to double, or None

    Returns:
    - Optional[int]: Doubled value or None
    """
    if value is not None:
        return value * 2
    return None


# =============================================================================
# SECTION 3: The Union Type
# =============================================================================

"""
Union[X, Y] indicates that a value can be either type X or type Y (or both).
Union can include more than two types.

Use Union when:
- A value can legitimately be one of several types
- Different return types are possible based on input
- An API accepts multiple input types

Syntax: Union[type1, type2, ...]
"""

def process_id(user_id: Union[int, str]) -> str:
    """
    Process a user ID that can be either int or str.

    Parameters:
    - user_id (Union[int, str]): User ID as integer or string

    Returns:
    - str: Formatted user ID
    """
    # Use isinstance to check which type we have
    if isinstance(user_id, int):
        return f"ID-{user_id:06d}"
    else:  # user_id is str
        return f"ID-{user_id}"


def add_numbers(a: Union[int, float], b: Union[int, float]) -> Union[int, float]:
    """
    Add two numbers that can be int or float.

    Parameters:
    - a (Union[int, float]): First number
    - b (Union[int, float]): Second number

    Returns:
    - Union[int, float]: Sum (int if both are int, otherwise float)
    """
    result = a + b
    # If both inputs are int, result is int; otherwise float
    if isinstance(a, int) and isinstance(b, int):
        return result
    return float(result)


def format_value(value: Union[int, float, str]) -> str:
    """
    Format a value that can be int, float, or str.

    Parameters:
    - value (Union[int, float, str]): Value to format

    Returns:
    - str: Formatted string
    """
    if isinstance(value, str):
        return f'"{value}"'
    elif isinstance(value, int):
        return f"{value}"
    else:  # float
        return f"{value:.2f}"


# Union with None (equivalent to Optional)
def divide(a: float, b: float) -> Union[float, None]:
    """
    Divide two numbers, return None if division by zero.

    This is equivalent to: -> Optional[float]

    Parameters:
    - a (float): Numerator
    - b (float): Denominator

    Returns:
    - Union[float, None]: Result or None if b is zero
    """
    if b == 0:
        return None
    return a / b


# =============================================================================
# SECTION 4: Type Narrowing with isinstance
# =============================================================================

"""
When working with Union types, use isinstance() to narrow the type.
Type checkers understand isinstance checks and treat the variable as
the specific type in each branch.
"""

def process_data(data: Union[List[int], Dict[str, int]]) -> int:
    """
    Process data that can be either a list or a dictionary.

    Parameters:
    - data (Union[List[int], Dict[str, int]]): Input data

    Returns:
    - int: Sum of all values
    """
    if isinstance(data, list):
        # Type checker knows data is List[int] here
        return sum(data)
    else:
        # Type checker knows data is Dict[str, int] here
        return sum(data.values())


def get_length(obj: Union[str, List[int], Dict[str, int]]) -> int:
    """
    Get the length of various objects.

    Parameters:
    - obj (Union[str, List[int], Dict[str, int]]): Object to measure

    Returns:
    - int: Length of the object
    """
    # isinstance works with all these types
    return len(obj)


def stringify(value: Union[int, float, bool, None]) -> str:
    """
    Convert various types to string with specific formatting.

    Parameters:
    - value (Union[int, float, bool, None]): Value to convert

    Returns:
    - str: String representation
    """
    if value is None:
        return "null"
    elif isinstance(value, bool):
        return "true" if value else "false"
    elif isinstance(value, int):
        return str(value)
    else:  # float
        return f"{value:.2f}"


# =============================================================================
# SECTION 5: Complex Optional and Union Patterns
# =============================================================================

"""
Optional and Union can be combined with collections and other types
to create complex type signatures.
"""

# Optional collection
def get_tags(item_id: int) -> Optional[List[str]]:
    """
    Get tags for an item, return None if item doesn't exist.

    Parameters:
    - item_id (int): Item ID

    Returns:
    - Optional[List[str]]: List of tags or None if item not found
    """
    # Simulated database lookup
    items = {
        1: ["python", "programming"],
        2: ["web", "javascript"]
    }
    return items.get(item_id)


# List of optional values
def parse_numbers(values: List[str]) -> List[Optional[int]]:
    """
    Parse a list of strings to integers, None for invalid strings.

    Parameters:
    - values (List[str]): Strings to parse

    Returns:
    - List[Optional[int]]: List of parsed integers (None for invalid)
    """
    result: List[Optional[int]] = []
    for value in values:
        try:
            result.append(int(value))
        except ValueError:
            result.append(None)
    return result


# Union of collections
def process_input(data: Union[List[int], Dict[str, int], int]) -> int:
    """
    Process input that can be a list, dict, or single integer.

    Parameters:
    - data (Union[List[int], Dict[str, int], int]): Input data

    Returns:
    - int: Processed result
    """
    if isinstance(data, int):
        return data
    elif isinstance(data, list):
        return sum(data)
    else:  # Dict[str, int]
        return sum(data.values())


# Optional with Union
def flexible_lookup(key: Union[int, str]) -> Optional[str]:
    """
    Lookup that accepts multiple key types and might return None.

    Parameters:
    - key (Union[int, str]): Lookup key

    Returns:
    - Optional[str]: Found value or None
    """
    int_map = {1: "one", 2: "two", 3: "three"}
    str_map = {"a": "alpha", "b": "beta", "c": "gamma"}

    if isinstance(key, int):
        return int_map.get(key)
    else:
        return str_map.get(key)


# =============================================================================
# SECTION 6: Best Practices
# =============================================================================

"""
PRACTICE 1: Prefer Optional over Union[X, None]
"""

# GOOD: Clear and concise
def find_item(item_id: int) -> Optional[str]:
    pass

# OKAY: More verbose but equivalent
def find_item_verbose(item_id: int) -> Union[str, None]:
    pass


"""
PRACTICE 2: Always check for None before using Optional values
"""

def safe_processing(value: Optional[str]) -> str:
    # GOOD: Check before use
    if value is None:
        return "default"
    return value.upper()


"""
PRACTICE 3: Use Union sparingly - consider if a common interface would work
"""

# Sometimes Union indicates a design problem
# Consider if these could share a common interface instead
def process_mixed(data: Union[List[int], Dict[str, int]]) -> int:
    # Needs isinstance checks throughout
    if isinstance(data, list):
        return sum(data)
    return sum(data.values())


"""
PRACTICE 4: Document what None means
"""

def search(query: str, max_results: Optional[int] = None) -> List[str]:
    """
    Search for items matching query.

    Parameters:
    - query (str): Search query
    - max_results (Optional[int]): Maximum results to return.
                                    None means no limit.

    Returns:
    - List[str]: Search results
    """
    # Implementation would use max_results appropriately
    return []


# =============================================================================
# SECTION 7: Practical Examples
# =============================================================================

def safe_divide(a: float, b: float, default: Optional[float] = None) -> Optional[float]:
    """
    Safely divide two numbers.

    Parameters:
    - a (float): Numerator
    - b (float): Denominator
    - default (Optional[float]): Default value for division by zero

    Returns:
    - Optional[float]: Result or default or None
    """
    if b == 0:
        return default
    return a / b


def extract_numbers(text: str) -> List[Union[int, float]]:
    """
    Extract all numbers (int or float) from text.

    Parameters:
    - text (str): Input text

    Returns:
    - List[Union[int, float]]: Extracted numbers
    """
    numbers: List[Union[int, float]] = []
    for word in text.split():
        try:
            if '.' in word:
                numbers.append(float(word))
            else:
                numbers.append(int(word))
        except ValueError:
            continue
    return numbers


def merge_configs(
    default: Dict[str, Union[str, int]],
    override: Optional[Dict[str, Union[str, int]]] = None
) -> Dict[str, Union[str, int]]:
    """
    Merge configuration dictionaries.

    Parameters:
    - default (Dict[str, Union[str, int]]): Default configuration
    - override (Optional[Dict[str, Union[str, int]]]): Override values

    Returns:
    - Dict[str, Union[str, int]]: Merged configuration
    """
    result = default.copy()
    if override is not None:
        result.update(override)
    return result


def calculate_average(numbers: List[Union[int, float]]) -> Optional[float]:
    """
    Calculate average of numbers, return None if list is empty.

    Parameters:
    - numbers (List[Union[int, float]]): List of numbers

    Returns:
    - Optional[float]: Average or None
    """
    if not numbers:
        return None
    return sum(numbers) / len(numbers)


# =============================================================================
# SECTION 8: Testing and Examples
# =============================================================================

if __name__ == "__main__":
    print("=== Optional and Union Types Examples ===\n")

    # Optional examples
    print("Optional Types:")
    print(f"  Find user 1: {find_user(1)}")
    print(f"  Find user 999: {find_user(999)}")
    print(f"  Greet with name: {greet_user('Alice')}")
    print(f"  Greet without name: {greet_user()}")
    print(f"  Parse '123': {parse_int('123')}")
    print(f"  Parse 'abc': {parse_int('abc')}")
    print()

    # Union examples
    print("Union Types:")
    print(f"  Process ID (int): {process_id(42)}")
    print(f"  Process ID (str): {process_id('user-abc')}")
    print(f"  Add 2 + 3: {add_numbers(2, 3)}")
    print(f"  Add 2.5 + 3.5: {add_numbers(2.5, 3.5)}")
    print(f"  Format int: {format_value(42)}")
    print(f"  Format float: {format_value(3.14159)}")
    print(f"  Format str: {format_value('hello')}")
    print()

    # Type narrowing
    print("Type Narrowing:")
    print(f"  Process list: {process_data([1, 2, 3, 4])}")
    print(f"  Process dict: {process_data({'a': 1, 'b': 2})}")
    print()

    # Complex patterns
    print("Complex Patterns:")
    print(f"  Get tags (exists): {get_tags(1)}")
    print(f"  Get tags (missing): {get_tags(3)}")
    parsed = parse_numbers(["1", "2", "abc", "3"])
    print(f"  Parse numbers: {parsed}")
    print()

    # Practical examples
    print("Practical Examples:")
    print(f"  Safe divide 10/2: {safe_divide(10, 2)}")
    print(f"  Safe divide 10/0: {safe_divide(10, 0)}")
    print(f"  Safe divide 10/0 (default 0): {safe_divide(10, 0, 0.0)}")
    nums = extract_numbers("I have 3 apples and 2.5 oranges")
    print(f"  Extract numbers: {nums}")
    avg = calculate_average([1, 2.5, 3, 4.5])
    print(f"  Average: {avg}")