Skip to main content

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:

ApproachUse CaseExample
UnionAny of multiple typesvalue: int | str
LiteralOne of specific literal valuesmode: Literal["dark", "light"]
Overload (next article)Different signatures for different inputsdef 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; use T1 | T2 in Python 3.10+
  • Optional[T] is shorthand for Union[T, None]—always check for None before using the value
  • Type narrowing with isinstance() and is None helps 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