Overload and Type Narrowing
Predict mypy's behavior on this code:
import json
from typing import Any
def parse(data: str | bytes) -> dict[str, Any] | list[Any]:
return json.loads(data)
result = parse('{"key": "value"}')
# What is the type of `result`?
# Can you call result["key"] without a type error?
The type of result is dict[str, Any] | list[Any]. You cannot call result["key"] without a type error -- mypy sees a union and does not know which branch applies. You would need isinstance(result, dict) to narrow the type first.
Now imagine you want parse to return dict when given a string starting with { and list when given a string starting with [. Without @overload, there is no way to express this relationship between input and output. That is the problem this lesson solves.
What You Will Learn
@overloadfor declaring multiple function signatures- Type narrowing with
isinstance,match, and assignment TypeGuard(PEP 647) for custom type narrowing functionsTypeIs(PEP 742) for safer, bidirectional type narrowingassert_neverand theNevertype for exhaustiveness checking- Real-world overloads:
json.loads, API response parsing, database queries
Prerequisites
- TypeVar and generics from Lesson 1
- Union types and Optional
- Pattern matching basics (
match/casefrom Python 3.10+) - Familiarity with
isinstanceand type guards in normal Python code
Part 1 -- @overload Fundamentals
The Problem: Input-Dependent Return Types
Many functions return different types based on their inputs:
# Without @overload:
def get_value(key: str, default: int | None = None) -> int | None:
data = {"age": 30, "score": 95}
return data.get(key, default)
result = get_value("age") # type is int | None -- but we know it's int
result = get_value("missing") # type is int | None -- correct here
# The caller always gets the union, even when the result is obvious
Basic @overload Usage
from typing import overload
@overload
def get_value(key: str) -> int | None: ...
@overload
def get_value(key: str, default: int) -> int: ...
def get_value(key: str, default: int | None = None) -> int | None:
data = {"age": 30, "score": 95}
return data.get(key, default)
# Now the type checker can distinguish:
a = get_value("age") # int | None -- no default, might be missing
b = get_value("age", 0) # int -- default provided, always returns int
c = get_value("missing", -1) # int -- default guarantees non-None
The @overload decorated signatures are only for the type checker. They are never called at runtime. The actual implementation (without @overload) is the one that runs.
The overloaded signatures are checked at type-checking time only. At runtime, Python calls the implementation function directly. The @overload signatures are never executed.
Rules:
- At least two
@overloadsignatures required - The implementation must be compatible with ALL overloads
- The implementation must NOT have
@overload - Overloaded signatures must use
...(ellipsis) as the body - All overloads and the implementation must be adjacent (no other code between them)
Overload Resolution Order
The type checker tries overloads from top to bottom and picks the first match:
from typing import overload
@overload
def process(data: str) -> list[str]: ...
@overload
def process(data: bytes) -> list[bytes]: ...
@overload
def process(data: str | bytes) -> list[str] | list[bytes]: ...
def process(data: str | bytes) -> list[str] | list[bytes]:
if isinstance(data, str):
return data.split()
return data.split(b" ")
x = process("hello world") # list[str] -- matches first overload
y = process(b"hello world") # list[bytes] -- matches second overload
Order overloads from most specific to most general. The type checker picks the first match, so if a general overload comes first, the specific ones are unreachable.
Part 2 -- Overload Patterns
Pattern: Literal Type Overloads
Use Literal to branch on specific argument values:
from typing import overload, Literal
@overload
def fetch(url: str, format: Literal["json"]) -> dict[str, object]: ...
@overload
def fetch(url: str, format: Literal["text"]) -> str: ...
@overload
def fetch(url: str, format: Literal["bytes"]) -> bytes: ...
def fetch(url: str, format: str) -> dict[str, object] | str | bytes:
import urllib.request
# Simulated implementation
if format == "json":
return {"data": "value"}
elif format == "text":
return "response text"
else:
return b"raw bytes"
data = fetch("https://api.example.com", "json") # dict[str, object]
text = fetch("https://api.example.com", "text") # str
raw = fetch("https://api.example.com", "bytes") # bytes
Pattern: Optional Parameter Overloads
from typing import overload
@overload
def find_user(user_id: int) -> "User": ...
@overload
def find_user(user_id: int, *, include_deleted: Literal[True]) -> "User | DeletedUser": ...
def find_user(
user_id: int, *, include_deleted: bool = False
) -> "User | DeletedUser":
# Implementation
...
Pattern: Return Type Based on Input Type
from typing import overload, TypeVar, Sequence
@overload
def head(items: list[int]) -> int: ...
@overload
def head(items: list[str]) -> str: ...
@overload
def head(items: str) -> str: ...
def head(items: list[int] | list[str] | str) -> int | str:
if isinstance(items, str):
return items[0]
return items[0]
x = head([1, 2, 3]) # int
y = head(["a", "b", "c"]) # str
z = head("hello") # str
Pattern: json.loads-Style Overloads
The stdlib's json.loads uses overloads to express that strings and bytes both work:
from typing import overload, Any
@overload
def parse_config(source: str) -> dict[str, Any]: ...
@overload
def parse_config(source: bytes) -> dict[str, Any]: ...
@overload
def parse_config(source: dict[str, Any]) -> dict[str, Any]: ...
def parse_config(source: str | bytes | dict[str, Any]) -> dict[str, Any]:
import json
if isinstance(source, dict):
return source
if isinstance(source, bytes):
source = source.decode("utf-8")
return json.loads(source)
# All return dict[str, Any]:
a = parse_config('{"debug": true}')
b = parse_config(b'{"debug": true}')
c = parse_config({"debug": True})
Part 3 -- Type Narrowing Fundamentals
Type narrowing is the process by which the type checker refines a variable's type based on control flow.
Built-in Narrowing with isinstance
def process(value: int | str | list[int]) -> str:
if isinstance(value, int):
# value is narrowed to int
return str(value * 2)
elif isinstance(value, str):
# value is narrowed to str
return value.upper()
else:
# value is narrowed to list[int]
return str(sum(value))
process(42) # "84"
process("hello") # "HELLO"
process([1, 2, 3]) # "6"
Narrowing with is None / is not None
def get_name(user: "User | None") -> str:
if user is None:
return "Anonymous"
# user is narrowed to User
return user.name
Narrowing with match/case
from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
@dataclass
class Triangle:
base: float
height: float
Shape = Circle | Rectangle | Triangle
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return 3.14159 * r ** 2
case Rectangle(width=w, height=h):
return w * h
case Triangle(base=b, height=h):
return 0.5 * b * h
Narrowing with Truthiness
def first_or_default(items: list[str] | None) -> str:
if items:
# items is narrowed to list[str] (non-empty, non-None)
return items[0]
return "default"
Truthiness narrowing has limits. if items: removes None from the type, but it also implies the list is non-empty. However, the type checker only narrows away None -- it does not track emptiness as a type-level property.
Part 4 -- TypeGuard (PEP 647)
The Problem: Custom Type Checks
isinstance works for concrete types, but what about more complex checks?
def is_string_list(val: list[object]) -> bool:
return all(isinstance(x, str) for x in val)
items: list[object] = ["a", "b", "c"]
if is_string_list(items):
# items is still list[object] -- the type checker doesn't know!
print(items[0].upper()) # ERROR: object has no attribute 'upper'
The function correctly checks at runtime, but the type checker cannot use it for narrowing.
TypeGuard Solves This
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
items: list[object] = ["a", "b", "c"]
if is_string_list(items):
# items is now narrowed to list[str]
print(items[0].upper()) # OK!
else:
# items is still list[object]
pass
TypeGuard[X] tells the type checker: "If this function returns True, the first argument's type is X."
TypeGuard with Custom Types
from typing import TypeGuard, Any
from dataclasses import dataclass
@dataclass
class ValidUser:
name: str
email: str
age: int
def is_valid_user(data: dict[str, Any]) -> TypeGuard[ValidUser]:
"""Check if a dict has the shape of a ValidUser."""
return (
isinstance(data.get("name"), str)
and isinstance(data.get("email"), str)
and isinstance(data.get("age"), int)
)
def process_user(data: dict[str, Any]) -> str:
if is_valid_user(data):
# data is narrowed to ValidUser
return f"User: {data.name} ({data.email})"
return "Invalid user data"
TypeGuard Limitations
TypeGuard has a critical limitation: it only narrows in the True branch. In the False branch, the variable retains its original type -- it is NOT narrowed to "everything except the guard type."
from typing import TypeGuard
def is_str(x: str | int) -> TypeGuard[str]:
return isinstance(x, str)
value: str | int = get_value()
if is_str(value):
# value is str
pass
else:
# value is STILL str | int -- NOT int!
# TypeGuard does not narrow the else branch
pass
Part 5 -- TypeIs (PEP 742)
TypeIs: The Improved TypeGuard
TypeIs (Python 3.13, available in typing_extensions for earlier versions) fixes TypeGuard's limitation:
from typing_extensions import TypeIs
def is_str(x: str | int) -> TypeIs[str]:
return isinstance(x, str)
value: str | int = "hello"
if is_str(value):
# value is str
print(value.upper())
else:
# value is int -- TypeIs narrows BOTH branches!
print(value + 1)
TypeGuard vs TypeIs
| Feature | TypeGuard (PEP 647) | TypeIs (PEP 742) |
|---|---|---|
| True branch narrowing | Yes | Yes |
| False branch narrowing | No | Yes |
| Can narrow to unrelated type | Yes | No |
| Safety guarantee | Weaker (trusts user) | Stronger (type must be compatible) |
| Available since | Python 3.10 | Python 3.13 / typing_extensions |
from typing import TypeGuard
from typing_extensions import TypeIs
# TypeGuard can narrow to an UNRELATED type (unsafe):
def is_int(x: str) -> TypeGuard[int]: # Allowed! (but wrong)
return x.isdigit()
# TypeIs REQUIRES the narrowed type to be compatible:
def is_int_safe(x: str | int) -> TypeIs[int]: # OK: int is part of str | int
return isinstance(x, int)
# def is_int_bad(x: str) -> TypeIs[int]: # ERROR: int is not a subtype of str
# return False
Prefer TypeIs over TypeGuard when possible. TypeIs is safer because:
- It narrows both branches
- It requires the guard type to be compatible with the input type
- It is closer to how
isinstancebehaves mentally
Use TypeGuard only when you need to narrow to a type that is not a subtype of the input (e.g., narrowing dict[str, Any] to a typed dataclass).
TypeIs with Discriminated Unions
from typing_extensions import TypeIs
from dataclasses import dataclass
@dataclass
class SuccessResponse:
status: str # "success"
data: dict[str, object]
@dataclass
class ErrorResponse:
status: str # "error"
message: str
code: int
ApiResponse = SuccessResponse | ErrorResponse
def is_success(response: ApiResponse) -> TypeIs[SuccessResponse]:
return response.status == "success"
def handle(response: ApiResponse) -> str:
if is_success(response):
# response is SuccessResponse
return str(response.data)
else:
# response is ErrorResponse
return f"Error {response.code}: {response.message}"
Part 6 -- assert_never and Exhaustiveness Checking
The Never Type
Never (or NoReturn in older versions) represents a type that has no values. A function returning Never always raises an exception or runs forever:
from typing import Never
def unreachable(message: str) -> Never:
raise AssertionError(f"This should never happen: {message}")
assert_never for Exhaustive Matching
assert_never ensures you have handled all cases in a union:
from typing import assert_never
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius
class Rectangle:
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
class Triangle:
def __init__(self, base: float, height: float) -> None:
self.base = base
self.height = height
Shape = Circle | Rectangle | Triangle
def area(shape: Shape) -> float:
if isinstance(shape, Circle):
return 3.14159 * shape.radius ** 2
elif isinstance(shape, Rectangle):
return shape.width * shape.height
elif isinstance(shape, Triangle):
return 0.5 * shape.base * shape.height
else:
assert_never(shape)
If all cases are handled, shape in the else branch has type Never, and assert_never accepts it silently. But if you add a new shape:
class Pentagon:
def __init__(self, side: float) -> None:
self.side = side
Shape = Circle | Rectangle | Triangle | Pentagon # Added Pentagon
# Now mypy reports:
# error: Argument 1 to "assert_never" has incompatible type "Pentagon"; expected "Never"
The type checker catches the unhandled case at type-checking time -- before any tests run.
assert_never with match/case
from typing import assert_never
from enum import Enum
class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
def hex_code(color: Color) -> str:
match color:
case Color.RED:
return "#FF0000"
case Color.GREEN:
return "#00FF00"
case Color.BLUE:
return "#0000FF"
case _ as unreachable:
assert_never(unreachable)
Exhaustiveness in Practice
Always add assert_never in the final else/default branch when handling union types. It is zero cost at runtime (only triggers on bugs) and gives you compile-time guarantees that you have handled every case.
Part 7 -- Real-World Patterns
Pattern: API Response Parsing with Overload
from typing import overload, Literal, Any, TypeVar
from dataclasses import dataclass
T = TypeVar("T")
@dataclass
class User:
id: str
name: str
email: str
@dataclass
class Course:
id: str
title: str
price: float
@overload
def api_get(endpoint: Literal["/users"], id: str) -> User: ...
@overload
def api_get(endpoint: Literal["/courses"], id: str) -> Course: ...
def api_get(endpoint: str, id: str) -> User | Course:
"""Fetch a resource from the API with type-safe return."""
import json
# Simulated API call
if endpoint == "/users":
elif endpoint == "/courses":
return Course(id=id, title="Python Advanced", price=99.0)
raise ValueError(f"Unknown endpoint: {endpoint}")
user = api_get("/users", "u-1") # type is User
course = api_get("/courses", "c-1") # type is Course
print(user.email) # OK -- mypy knows it's User
print(course.price) # OK -- mypy knows it's Course
Pattern: Configuration Loader with Narrowing
from typing import overload, Literal, Any, TypeGuard
from dataclasses import dataclass
@dataclass
class DatabaseConfig:
host: str
port: int
database: str
@dataclass
class CacheConfig:
backend: str
ttl: int
@dataclass
class AppConfig:
database: DatabaseConfig
cache: CacheConfig
debug: bool
def is_database_config(data: dict[str, Any]) -> TypeGuard[DatabaseConfig]:
return all(k in data for k in ("host", "port", "database"))
def is_cache_config(data: dict[str, Any]) -> TypeGuard[CacheConfig]:
return all(k in data for k in ("backend", "ttl"))
def load_section(raw: dict[str, Any], section: str) -> DatabaseConfig | CacheConfig:
data = raw.get(section, {})
if section == "database" and is_database_config(data):
return DatabaseConfig(**data)
elif section == "cache" and is_cache_config(data):
return CacheConfig(**data)
raise ValueError(f"Invalid config section: {section}")
Pattern: Event Handler with Exhaustive Dispatch
from typing import assert_never
from dataclasses import dataclass
@dataclass
class ClickEvent:
x: int
y: int
@dataclass
class KeyEvent:
key: str
modifiers: list[str]
@dataclass
class ScrollEvent:
delta: float
direction: str
UIEvent = ClickEvent | KeyEvent | ScrollEvent
def handle_event(event: UIEvent) -> str:
match event:
case ClickEvent(x=x, y=y):
return f"Clicked at ({x}, {y})"
case KeyEvent(key=k, modifiers=mods):
mod_str = "+".join(mods) + "+" if mods else ""
return f"Key pressed: {mod_str}{k}"
case ScrollEvent(delta=d, direction=dir):
return f"Scrolled {dir} by {d}"
case _ as unreachable:
assert_never(unreachable)
# If you add ResizeEvent to UIEvent but forget to handle it here,
# mypy catches it immediately.
Pattern: Overloaded Method on a Class
from typing import overload, Literal
class DataStore:
def __init__(self) -> None:
self._data: dict[str, str | int | list[str]] = {}
@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["list"]) -> list[str]: ...
def get(self, key: str, type: str) -> str | int | list[str]:
value = self._data[key]
if type == "str":
return str(value)
elif type == "int":
return int(value) # type: ignore[arg-type]
elif type == "list":
if isinstance(value, list):
return value
return [str(value)]
raise ValueError(f"Unknown type: {type}")
store = DataStore()
name: str = store.get("name", "str")
age: int = store.get("age", "int")
tags: list[str] = store.get("tags", "list")
Key Takeaways
@overloaddeclares multiple function signatures that the type checker uses to infer return types based on input types- Overloads are resolved top-to-bottom; order from most specific to most general
- The implementation function (without
@overload) is the only one called at runtime isinstanceandmatch/caseprovide built-in type narrowingTypeGuard(PEP 647) enables custom type narrowing functions but only narrows the True branchTypeIs(PEP 742) is safer thanTypeGuard-- it narrows both branches and requires type compatibilityassert_nevercatches unhandled union members at type-checking time -- use it in every exhaustive match- Combine
Literaltypes with@overloadto express "when this argument is X, return type Y" - These tools transform runtime-only checks into compile-time guarantees
Graded Practice Challenges
Level 1 -- Predict the Type Checker Output
Question 1: What does mypy infer for result?
from typing import overload, Literal
@overload
def convert(value: str, to: Literal["int"]) -> int: ...
@overload
def convert(value: str, to: Literal["float"]) -> float: ...
@overload
def convert(value: str, to: Literal["bool"]) -> bool: ...
def convert(value: str, to: str) -> int | float | bool:
if to == "int": return int(value)
if to == "float": return float(value)
return value.lower() in ("true", "1", "yes")
result = convert("42", "int")
Answer
result is int. The first overload matches because "int" is Literal["int"]. mypy resolves the return type to int.
Question 2: Does this code pass mypy?
from typing import assert_never
def describe(value: int | str) -> str:
if isinstance(value, int):
return f"number: {value}"
elif isinstance(value, str):
return f"text: {value}"
assert_never(value)
Answer
Yes, this passes mypy. After handling int and str, the type of value is narrowed to Never (all possibilities exhausted). assert_never(Never) is valid. Note: the else is implicit here -- the assert_never is reached only if neither branch executes, at which point value has type Never.
Question 3: What is the type of x in the else branch?
from typing import TypeGuard
def is_positive_int(val: int | str | None) -> TypeGuard[int]:
return isinstance(val, int) and val > 0
val: int | str | None = get_something()
if is_positive_int(val):
x = val # What type?
else:
x = val # What type?
Answer
In the if branch, x is int (TypeGuard narrows to the guard type).
In the else branch, x is int | str | None -- the original type, unchanged. TypeGuard does NOT narrow the else branch. This is the key difference from TypeIs, which would narrow the else branch to str | None.
Level 2 -- Debug and Fix
This overloaded function has type errors. Find and fix them:
from typing import overload
@overload
def safe_divide(a: int, b: int) -> int: ...
@overload
def safe_divide(a: float, b: float) -> float: ...
@overload
def safe_divide(a: int, b: int, default: int) -> int: ...
def safe_divide(
a: int | float,
b: int | float,
default: int | float | None = None,
) -> int | float:
try:
return a / b
except ZeroDivisionError:
if default is not None:
return default
raise
# Usage:
safe_divide(10, 3) # Should be int
safe_divide(10.0, 3.0) # Should be float
safe_divide(10, 0, -1) # Should be int
safe_divide(10, 0) # Should raise
Answer
Several issues:
-
a / balways returnsfloatin Python, even forint / int. So the first overload claiming to returnintis incorrect at runtime (10 / 3returns3.333...). If you want integer division, use//. -
The overloads are ambiguous:
safe_divide(10, 3)matches BOTH the first overload (int, int) and could match a more general pattern. The third overload(int, int, int)has a different number of args, which is fine. -
Missing overload for float with default: There is no
(float, float, float) -> floatoverload.
Fix:
from typing import overload
@overload
def safe_divide(a: int, b: int) -> float: ...
@overload
def safe_divide(a: float, b: float) -> float: ...
@overload
def safe_divide(a: int, b: int, default: float) -> float: ...
@overload
def safe_divide(a: float, b: float, default: float) -> float: ...
def safe_divide(
a: int | float,
b: int | float,
default: float | None = None,
) -> float:
try:
return a / b
except ZeroDivisionError:
if default is not None:
return default
raise
Or, if you truly want integer division for int inputs:
@overload
def safe_divide(a: int, b: int) -> int: ...
@overload
def safe_divide(a: float, b: float) -> float: ...
def safe_divide(a: int | float, b: int | float) -> int | float:
if isinstance(a, int) and isinstance(b, int):
return a // b
return a / b
Level 3 -- Design Challenge
Build a type-safe command dispatcher where:
# Each command has a specific input and output type:
# result should be typed as User, not Any
result2 = dispatcher.execute("delete_user", {"user_id": "u-1"})
# result2 should be typed as bool, not Any
Requirements:
- Use
@overloadwithLiteraltypes for each command name - Each command maps to a specific input dict shape and return type
- Use
assert_neverfor exhaustiveness if an unknown command is passed - Add a
registermethod that maintains type safety
Hint
from typing import overload, Literal, Any, assert_never
from dataclasses import dataclass
@dataclass
class User:
name: str
email: str
class CommandDispatcher:
@overload
def execute(
self, command: Literal["create_user"], payload: dict[str, str]
) -> User: ...
@overload
def execute(
self, command: Literal["delete_user"], payload: dict[str, str]
) -> bool: ...
def execute(self, command: str, payload: dict[str, str]) -> User | bool:
if command == "create_user":
return User(
name=payload["name"],
email=payload["email"],
)
elif command == "delete_user":
print(f"Deleting {payload['user_id']}")
return True
else:
# For true exhaustiveness, you would need a Literal union
raise ValueError(f"Unknown command: {command}")
dispatcher = CommandDispatcher()
deleted: bool = dispatcher.execute("delete_user", {"user_id": "u-1"})
For a more scalable approach, combine with a generic registry pattern using TypeVar and Protocol.
What's Next
In the next lesson, Runtime Type Checking, we explore what happens when static types meet runtime reality -- using get_type_hints(), beartype, typeguard, and Pydantic to validate data at system boundaries.
