Python Type Narrowing and @overload Patterns
Master Python type narrowing with isinstance, TypeGuard, TypeIs, and use @overload to write precise function signatures that type checkers understand.
What you'll learn
- ✓Narrow union types with isinstance, assert, and pattern matching
- ✓Write custom type guards with TypeGuard and TypeIs
- ✓Use @overload to provide precise return types for different inputs
- ✓Combine narrowing and overloads for type-safe APIs
Prerequisites
- •Python type hints basics
- •Familiarity with mypy or pyright
Python’s type system supports union types like str | int, but working with unions means you need to tell the type checker which branch you are in. That process is called type narrowing. When narrowing alone is not enough, @overload lets you declare multiple function signatures so the type checker picks the right return type based on the arguments. Together, these features let you write code that is both flexible and precisely typed.
Type Narrowing Basics
Type narrowing happens when the type checker can prove that a variable has a more specific type than its declaration suggests. The simplest form uses isinstance:
def process(value: str | int) -> str:
if isinstance(value, str):
# Type checker knows value is str here
return value.upper()
else:
# Type checker knows value is int here
return str(value * 2)
After the isinstance check, the type checker narrows str | int to just str in the if-branch and int in the else-branch.
Narrowing with isinstance and Tuples
You can check multiple types at once:
from collections.abc import Sequence
def stringify(value: str | int | float | list[str]) -> str:
if isinstance(value, (int, float)):
# value is int | float
return f"{value:.2f}"
elif isinstance(value, str):
return value
else:
# value is list[str]
return ", ".join(value)
Narrowing with Truthiness
Type checkers understand None checks:
def greet(name: str | None) -> str:
if name is None:
return "Hello, stranger!"
# name is str here (None eliminated)
return f"Hello, {name.title()}!"
def get_first(items: list[str]) -> str | None:
return items[0] if items else None
def use_first(items: list[str]) -> str:
first = get_first(items)
if first is not None:
return first.upper() # narrowed to str
return "EMPTY"
Narrowing with assert
assert statements also narrow types:
def process_data(data: dict[str, str | int | None]) -> int:
value = data.get("count")
assert isinstance(value, int), f"Expected int, got {type(value)}"
# value is int here
return value * 10
Narrowing with Match Statements
Python 3.10+ structural pattern matching works with type narrowing:
from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
type Shape = Circle | Rectangle
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
# shape is Circle here
return 3.14159 * r ** 2
case Rectangle(width=w, height=h):
# shape is Rectangle here
return w * h
TypeGuard: Custom Type Narrowing Functions
Sometimes isinstance is not enough. You need a custom function that checks something more complex. TypeGuard (Python 3.10+, or typing_extensions) lets you write these:
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
"""Check if all elements are strings."""
return all(isinstance(item, str) for item in val)
def process_items(items: list[object]) -> str:
if is_string_list(items):
# items is list[str] here
return ", ".join(items)
return "mixed types"
TypeGuard with Complex Types
from typing import TypeGuard, Any
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def is_valid_user_data(data: dict[str, Any]) -> TypeGuard[dict[str, str]]:
"""Validate that the dict has the right shape for a User."""
required = {"name", "email"}
return (
required.issubset(data.keys())
and all(isinstance(data[k], str) for k in required)
)
def create_user(data: dict[str, Any]) -> User | None:
if is_valid_user_data(data):
# data is dict[str, str] here
return User(name=data["name"], email=data["email"])
return None
TypeGuard Limitations
TypeGuard narrows only the first argument, and only in the True branch. The False branch gets no narrowing. The type checker trusts your function completely, so an incorrect TypeGuard can introduce type unsafety.
TypeIs: Stricter Type Narrowing (Python 3.12+)
TypeIs is a newer, stricter version of TypeGuard. The key difference: TypeIs narrows in both branches (true and false), and the narrowed type must be a subtype of the input type:
from typing import TypeIs
class Animal:
name: str
class Dog(Animal):
breed: str
class Cat(Animal):
indoor: bool
def is_dog(animal: Animal) -> TypeIs[Dog]:
return isinstance(animal, Dog)
def handle_animal(animal: Dog | Cat) -> str:
if is_dog(animal):
# animal is Dog
return f"Dog: {animal.breed}"
else:
# animal is Cat (narrowed in the False branch too!)
return f"Cat: indoor={animal.indoor}"
Prefer TypeIs over TypeGuard when the narrowed type is a subtype of the input. It provides stronger guarantees and narrows both branches.
@overload: Multiple Signatures
@overload lets you declare different return types based on input types. This is essential for functions where the return type depends on the arguments:
from typing import overload
@overload
def get_item(index: int) -> str: ...
@overload
def get_item(index: slice) -> list[str]: ...
def get_item(index: int | slice) -> str | list[str]:
items = ["a", "b", "c", "d", "e"]
return items[index]
# Type checker knows the exact return type:
single: str = get_item(0) # str
multiple: list[str] = get_item(slice(0, 3)) # list[str]
The @overload decorated functions are never called. They exist only for the type checker. The actual implementation is the function without @overload.
@overload with Literal Types
A powerful pattern uses Literal to choose return types:
from typing import overload, Literal
@overload
def fetch_data(format: Literal["json"]) -> dict: ...
@overload
def fetch_data(format: Literal["text"]) -> str: ...
@overload
def fetch_data(format: Literal["bytes"]) -> bytes: ...
def fetch_data(format: str) -> dict | str | bytes:
data = b'{"key": "value"}'
if format == "json":
import json
return json.loads(data)
elif format == "text":
return data.decode()
else:
return data
result_json = fetch_data("json") # type checker knows this is dict
result_text = fetch_data("text") # type checker knows this is str
result_bytes = fetch_data("bytes") # type checker knows this is bytes
@overload with Optional Parameters
from typing import overload
@overload
def find_user(user_id: int) -> "User": ...
@overload
def find_user(user_id: int, default: "User") -> "User": ...
@overload
def find_user(user_id: int, default: None) -> "User | None": ...
def find_user(user_id: int, default: "User | None" = None) -> "User | None":
users = {1: User("Alice"), 2: User("Bob")}
return users.get(user_id, default)
class User:
def __init__(self, name: str):
self.name = name
@overload on Methods
from typing import overload, Literal
class Config:
def __init__(self):
self._data: dict[str, str | int | bool] = {}
@overload
def get(self, key: str, type: Literal["str"]) -> str: ...
@overload
def get(self, key: str, type: Literal["int"]) -> int: ...
@overload
def get(self, key: str, type: Literal["bool"]) -> bool: ...
def get(self, key: str, type: str = "str") -> str | int | bool:
value = self._data[key]
if type == "int":
return int(value)
elif type == "bool":
return bool(value)
return str(value)
Combining Narrowing and Overloads
The most powerful patterns combine both techniques. Here is a real-world example of a response parser:
from typing import overload, TypeGuard, Literal, Any
from dataclasses import dataclass
@dataclass
class SuccessResponse:
data: dict[str, Any]
status: Literal["ok"] = "ok"
@dataclass
class ErrorResponse:
message: str
code: int
status: Literal["error"] = "error"
type APIResponse = SuccessResponse | ErrorResponse
def is_success(response: APIResponse) -> TypeGuard[SuccessResponse]:
return response.status == "ok"
def is_error(response: APIResponse) -> TypeGuard[ErrorResponse]:
return response.status == "error"
@overload
def parse_response(raw: dict, strict: Literal[True]) -> SuccessResponse: ...
@overload
def parse_response(raw: dict, strict: Literal[False] = ...) -> APIResponse: ...
def parse_response(raw: dict, strict: bool = False) -> APIResponse:
if "error" in raw:
response = ErrorResponse(message=raw["error"], code=raw.get("code", 500))
if strict:
raise ValueError(f"API error: {response.message}")
return response
return SuccessResponse(data=raw)
# Usage with full type safety
result = parse_response({"key": "value"}, strict=False)
if is_success(result):
print(result.data) # type checker knows this is SuccessResponse
elif is_error(result):
print(result.message) # type checker knows this is ErrorResponse
Discriminated Unions
A discriminated union uses a shared field (the discriminator) to distinguish between types. Type checkers handle these natively:
from dataclasses import dataclass
from typing import Literal
@dataclass
class TextMessage:
type: Literal["text"]
content: str
@dataclass
class ImageMessage:
type: Literal["image"]
url: str
width: int
height: int
@dataclass
class VideoMessage:
type: Literal["video"]
url: str
duration: float
type Message = TextMessage | ImageMessage | VideoMessage
def render_message(msg: Message) -> str:
match msg.type:
case "text":
# msg is TextMessage
return f"<p>{msg.content}</p>"
case "image":
# msg is ImageMessage
return f'<img src="{msg.url}" width="{msg.width}">'
case "video":
# msg is VideoMessage
return f'<video src="{msg.url}" data-duration="{msg.duration}">'
This pattern is type-safe and exhaustive. If you add a new message type but forget to handle it, the type checker will warn you.
Exhaustiveness Checking with assert_never
assert_never (Python 3.11+) ensures you handle every case in a union:
from typing import assert_never, Literal
type Direction = Literal["north", "south", "east", "west"]
def move(direction: Direction) -> tuple[int, int]:
match direction:
case "north":
return (0, 1)
case "south":
return (0, -1)
case "east":
return (1, 0)
case "west":
return (-1, 0)
case _ as unreachable:
assert_never(unreachable)
If you remove the "west" case, the type checker flags unreachable as type Literal["west"] instead of Never, catching the missing branch at type-check time rather than runtime.
Practical Guidelines
Use narrowing before accessing type-specific attributes. Do not suppress type errors with # type: ignore when a simple isinstance check makes the code both safer and clearer.
Prefer TypeIs over TypeGuard when the narrowed type is a subtype. You get narrowing in both branches.
Use @overload sparingly. It adds complexity. Only reach for it when a function genuinely returns different types based on inputs and users need the type checker to know which type they get.
Keep overload signatures ordered from most specific to least specific. Type checkers match overloads top-to-bottom.
Test with both mypy and pyright. They have slightly different narrowing rules. Code that passes both is more robust.
Wrapping Up
Type narrowing and @overload are the tools that make Python’s type system practical for complex code. Narrowing converts broad union types into specific ones using isinstance, is None checks, match statements, and custom guards. @overload tells the type checker exactly what return type to expect for each input combination. Combined with discriminated unions and assert_never, you can write Python that is as precisely typed as statically typed languages while keeping the flexibility that makes Python productive. The investment pays off in fewer runtime errors, better IDE autocompletion, and code that documents its own contracts.
Related articles
- Python Introduction to Python Type Hints
A practical introduction to Python type hints — basic syntax, common collection types, Optional and Union, type-checking with mypy, and how hints improve code without changing runtime behaviour.
- Python Python Protocol Classes: Structural Subtyping Guide
Learn how to use Python Protocol classes for structural subtyping, duck typing with static checks, and writing flexible interfaces without inheritance.
- Python Python ABCs vs Protocols: Choosing the Right Abstraction
Understand the difference between Abstract Base Classes and Protocols in Python. Learn when nominal typing beats structural typing and vice versa.
- Python Advanced Python Dataclasses: Beyond the Basics
Go beyond basic dataclass usage with post-init processing, field factories, inheritance, frozen instances, and custom serialization.