Skip to main content

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

  • @overload for declaring multiple function signatures
  • Type narrowing with isinstance, match, and assignment
  • TypeGuard (PEP 647) for custom type narrowing functions
  • TypeIs (PEP 742) for safer, bidirectional type narrowing
  • assert_never and the Never type 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/case from Python 3.10+)
  • Familiarity with isinstance and 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.

danger

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:

  1. At least two @overload signatures required
  2. The implementation must be compatible with ALL overloads
  3. The implementation must NOT have @overload
  4. Overloaded signatures must use ... (ellipsis) as the body
  5. 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
tip

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"
note

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

danger

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

FeatureTypeGuard (PEP 647)TypeIs (PEP 742)
True branch narrowingYesYes
False branch narrowingNoYes
Can narrow to unrelated typeYesNo
Safety guaranteeWeaker (trusts user)Stronger (type must be compatible)
Available sincePython 3.10Python 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
tip

Prefer TypeIs over TypeGuard when possible. TypeIs is safer because:

  1. It narrows both branches
  2. It requires the guard type to be compatible with the input type
  3. It is closer to how isinstance behaves 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

tip

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":
return User(id=id, name="Alice", email="[email protected]")
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

  • @overload declares 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
  • isinstance and match/case provide built-in type narrowing
  • TypeGuard (PEP 647) enables custom type narrowing functions but only narrows the True branch
  • TypeIs (PEP 742) is safer than TypeGuard -- it narrows both branches and requires type compatibility
  • assert_never catches unhandled union members at type-checking time -- use it in every exhaustive match
  • Combine Literal types with @overload to 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:

  1. a / b always returns float in Python, even for int / int. So the first overload claiming to return int is incorrect at runtime (10 / 3 returns 3.333...). If you want integer division, use //.

  2. 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.

  3. Missing overload for float with default: There is no (float, float, float) -> float overload.

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 = dispatcher.execute("create_user", {"name": "Alice", "email": "[email protected]"})
# 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:

  1. Use @overload with Literal types for each command name
  2. Each command maps to a specific input dict shape and return type
  3. Use assert_never for exhaustiveness if an unknown command is passed
  4. Add a register method 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()
user: User = dispatcher.execute("create_user", {"name": "Alice", "email": "[email protected]"})
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.

© 2026 EngineersOfAI. All rights reserved.