Union Types, Optional, and Literal Types Guide
Python functions often accept or return values of multiple types, or values that might be None. Union types, Optional types, and Literal types give you precise ways to express these constraints. This article teaches you to type functions that accept multiple types, handle null values safely, and enforce specific literal values.
Union Types: Accepting Multiple Types
A Union type says a value can be one of several types:
from typing import Union
def process(value: Union[int, str]) -> None:
"""Accept an integer or a string."""
if isinstance(value, int):
print(f"Number: {value * 2}")
else:
print(f"Text: {value.upper()}")
process(42) # OK: int
process("hello") # OK: str
process(3.14) # mypy: error: float is not compatible with Union[int, str]
In Python 3.10+, use the pipe syntax for brevity:
def process(value: int | str) -> None:
"""Accept an integer or a string."""
# Same logic as above
pass
The pipe syntax is equivalent to Union and is more readable. Both are accepted by modern mypy.
Type Narrowing with Union Types
When a variable has a Union type, mypy initially knows only that it could be one of the types. To use type-specific methods, narrow the type with isinstance() or type() checks:
from typing import Union
def display(value: Union[int, str]) -> None:
"""Display a value, handling both int and str."""
if isinstance(value, int):
# Inside this block, mypy knows value is int
print(f"Integer: {value + 10}")
elif isinstance(value, str):
# Inside this block, mypy knows value is str
print(f"String: {value.upper()}")
# Outside the blocks, mypy still knows value is Union[int, str]
# You can also use type() for exact type checking
def process(value: Union[int, bool]) -> None:
if type(value) is int:
# Narrows to int (excluding bool, which is a subclass of int)
print(f"True integer: {value}")
elif type(value) is bool:
print(f"Boolean: {value}")
Type narrowing is essential for safely using Union types. mypy understands common patterns like isinstance(), type(), and is None checks.
Optional Types: The Most Common Union
Optional[T] means "T or None"—it's the most common Union. It's equivalent to Union[T, None]:
from typing import Optional
def get_user_age(user_id: int) -> Optional[int]:
"""Return a user's age, or None if not found."""
# Simulate a database lookup
if user_id == 1:
return 25
return None
# Caller must handle the None case
age = get_user_age(1)
if age is not None:
# Safe: age is definitely an int here
print(f"Age: {age}")
else:
print("User not found")
# This would be an error without the None check:
# print(age + 10) # mypy: error: Unsupported operand type(s) for +: "int" and "None"
In Python 3.10+, use T | None syntax:
def get_user_age(user_id: int) -> int | None:
"""Return a user's age, or None if not found."""
if user_id == 1:
return 25
return None
Working with Optional Values
Four common patterns for handling Optional values:
from typing import Optional
def get_age(user_id: int) -> Optional[int]:
return 25 if user_id == 1 else None
# Pattern 1: Explicit None check
age = get_age(1)
if age is None:
age = 0 # Default fallback
# Pattern 2: Using `or` for default values
age = get_age(1) or 0
# Pattern 3: Using `if` expression (ternary)
age = get_age(1) if get_age(1) is not None else 0
# Pattern 4: Using `??` (not valid Python; shown for illustration)
# Python has no null-coalescing operator; use `or` instead
age = get_age(1) or 18 # Returns 25 if not None, else 18
Be careful with falsy values and or: age = get_age(1) or 0 returns 0 if age is 0 (falsy), not just if it's None. For None-only checks, use explicit is None.
Literal Types: Specific Values Only
Literal types restrict a value to specific literal values:
from typing import Literal
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
"""Set the logging level to one of four values."""
print(f"Log level: {level}")
set_log_level("debug") # OK
set_log_level("info") # OK
set_log_level("verbose") # mypy: error: Incompatible argument type
Literal types are useful for string-based enums, status codes, and restricted sets of values:
from typing import Literal
class Response:
def __init__(self, status: Literal[200, 404, 500]) -> None:
"""Only accept valid HTTP status codes."""
self.status = status
resp_ok = Response(200) # OK
resp_notfound = Response(404) # OK
resp_bad = Response(418) # mypy: error: Literal type doesn't match
Literal types work with Union for multiple specific values:
from typing import Literal, Union
def configure(mode: Literal["dark", "light"] | Literal["auto"]) -> None:
"""Configure the theme mode."""
pass
# More concise:
def configure(mode: Literal["dark", "light", "auto"]) -> None:
pass
Practical Example: Configuration Validation
Here's a real-world example combining Union, Optional, and Literal:
from typing import Literal, Optional, Union
class ServerConfig:
"""Server configuration with type-safe fields."""
def __init__(
self,
host: str,
port: int,
debug: bool,
ssl_protocol: Optional[Literal["TLS1.2", "TLS1.3"]] = None,
worker_type: Literal["sync", "async"] = "sync",
max_connections: Union[int, Literal["unlimited"]] = 100,
) -> None:
self.host = host
self.port = port
self.debug = debug
self.ssl_protocol = ssl_protocol
self.worker_type = worker_type
self.max_connections = max_connections
def get_max_connections(self) -> int:
"""Get max connections as an integer."""
if self.max_connections == "unlimited":
return 999999
return self.max_connections
# Type-safe configuration
config = ServerConfig(
host="localhost",
port=8080,
debug=True,
ssl_protocol="TLS1.3", # OK: one of the Literal values
worker_type="async", # OK
max_connections="unlimited", # OK: matches Literal["unlimited"]
)
# mypy catches invalid values
# bad_config = ServerConfig("localhost", 8080, True, ssl_protocol="SSL2.0") # Error
# bad_worker = ServerConfig("localhost", 8080, True, worker_type="threaded") # Error
Type narrowing works with Literal types too:
from typing import Literal
def handle_response(code: Literal[200, 404, 500]) -> str:
if code == 200:
return "Success"
elif code == 404:
return "Not found"
else: # mypy knows code is 500
return "Server error"
Comparing Union Types: Union vs. Overload vs. Literal
Three ways to type functions that accept different inputs:
| Approach | Use Case | Example |
|---|---|---|
| Union | Any of multiple types | value: int | str |
| Literal | One of specific literal values | mode: Literal["dark", "light"] |
| Overload (next article) | Different signatures for different inputs | def func(x: int) -> str: ... and def func(x: str) -> int: ... |
Use Union for flexible type acceptance. Use Literal for restricted literal values. Use Overload (coming next) for different return types based on input type.
Key Takeaways
Union[T1, T2]accepts values of type T1 or T2; useT1 | T2in Python 3.10+Optional[T]is shorthand forUnion[T, None]—always check for None before using the value- Type narrowing with
isinstance()andis Nonehelps mypy understand which type you have Literal[value1, value2]restricts a value to specific literal values- Combine Union, Optional, and Literal for precise type specifications
- Always handle None and multiple types with explicit checks for type safety
Frequently Asked Questions
When should I use Union vs. Optional?
Use Optional[T] when a value might be None. Use Union[T1, T2] when a value can be one of multiple non-None types. Optional[T] is a special case of Union.
Can I use Union with more than two types?
Yes: Union[int, str, float, bool] or int | str | float | bool (Python 3.10+). However, many unions suggest the function is doing too much; consider splitting into separate functions.
What's the difference between Union[int, None] and Optional[int]?
They are identical. Optional[int] is just syntactic sugar for Union[int, None]. Use whichever is more readable.
Can I narrow a Union with isinstance()?
Yes, that's the recommended way. isinstance() is the safest and most Pythonic form of type narrowing.
Are Literal types checked at runtime?
No. Literal types are compile-time only. At runtime, any value matching the Literal type's value is accepted. Use runtime validation (e.g., assert level in ["debug", "info"]) if you need runtime enforcement.
Further Reading
- Python
typing.UnionDocumentation — Official reference for Union and Optional - Python
typing.LiteralDocumentation — Full Literal specification - PEP 604: Union Types (X | Y syntax) — Specification for the pipe operator in Python 3.10+
- mypy Union and Optional Guide — mypy-specific behavior and narrowing rules