Skip to content
Codeloom
Python

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.

·9 min read · By Codeloom
Advanced 13 min read

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.