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.

Mental Model

Optional[X] is shorthand for Union[X, None] — it says "this value is either an X or None." Union[X, Y] says "this value is either an X or a Y." In Python 3.10+ you can write X | Y instead, which reads more naturally. These annotations make nullable and multi-type values explicit so type checkers can warn you when you forget a None check.

Optional - Nullable Types

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

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

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

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

```python """ 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}")

```


Exercises

Exercise 1. Write a function find_user(user_id: int) -> Optional[str] that returns a name if the user exists or None otherwise. Simulate with a dictionary lookup.

Solution to Exercise 1

```python from typing import Optional

USERS = {1: "Alice", 2: "Bob"}

def find_user(user_id: int) -> Optional[str]: return USERS.get(user_id)

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


Exercise 2. Rewrite Union[int, float, str] using the modern | syntax (Python 3.10+). Then write a function stringify(value: int | float | str) -> str that converts any of those types to a string.

Solution to Exercise 2

```python def stringify(value: int | float | str) -> str: return str(value)

print(stringify(42)) # "42" print(stringify(3.14)) # "3.14" print(stringify("hello")) # "hello" ```


Exercise 3. Predict whether mypy reports an error:

```python from typing import Optional

def greet(name: Optional[str]) -> str: return "Hello, " + name ```

Solution to Exercise 3

Yes, mypy reports an error. name has type Optional[str] (i.e., str | None), so concatenating with + could fail if name is None. The fix is to check for None first:

python def greet(name: Optional[str]) -> str: if name is None: return "Hello, stranger" return "Hello, " + name


Exercise 4. Write a function safe_divide(a: float, b: float) -> float | None that returns None instead of raising ZeroDivisionError. Annotate it properly and write a caller that handles the None case.

Solution to Exercise 4

```python def safe_divide(a: float, b: float) -> float | None: if b == 0: return None return a / b

result = safe_divide(10, 3) if result is not None: print(f"Result: {result:.2f}") else: print("Cannot divide by zero") ```