Techniques for narrowing types in conditional branches.
def process(value: str | int | list[str]) -> str:
if isinstance(value, str):
# value is str here
return value.upper()
elif isinstance(value, int):
# value is int here
return str(value * 2)
else:
# value is list[str] here
return ", ".join(value)
def greet(name: str | None) -> str:
if name is None:
return "Hello, stranger"
# name is str here (not None)
return f"Hello, {name}"
# Also works with truthiness
def greet_truthy(name: str | None) -> str:
if name:
# name is str here
return f"Hello, {name}"
return "Hello, stranger"
def process(data: dict | None) -> str:
assert data is not None
# data is dict here
return str(data.get("key"))
def validate(value: int | str) -> int:
assert isinstance(value, int), "Must be int"
# value is int here
return value * 2
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
"""Check if all elements are strings."""
return all(isinstance(x, str) for x in val)
def process(items: list[object]) -> str:
if is_string_list(items):
# items is list[str] here
return ", ".join(items)
return "Not all strings"
# With TypeVar
from typing import TypeVar
T = TypeVar("T")
def is_not_none(val: T | None) -> TypeGuard[T]:
return val is not None
def process_optional(value: str | None) -> str:
if is_not_none(value):
# value is str here
return value.upper()
return "default"
from typing import TypeIs
# TypeIs narrows more aggressively than TypeGuard
def is_str(val: object) -> TypeIs[str]:
return isinstance(val, str)
def process(value: object) -> str:
if is_str(value):
# value is str here
return value.upper()
return "not a string"
from typing import Literal, TypedDict
class SuccessResult(TypedDict):
status: Literal["success"]
data: dict
class ErrorResult(TypedDict):
status: Literal["error"]
message: str
Result = SuccessResult | ErrorResult
def handle_result(result: Result) -> str:
if result["status"] == "success":
# result is SuccessResult
return str(result["data"])
else:
# result is ErrorResult
return f"Error: {result['message']}"
def describe(value: int | str | list[int]) -> str:
match value:
case int(n):
return f"Integer: {n}"
case str(s):
return f"String: {s}"
case [first, *rest]:
return f"List starting with {first}"
case _:
return "Unknown"
from typing import Protocol
class HasName(Protocol):
name: str
def greet(obj: object) -> str:
if hasattr(obj, "name") and isinstance(obj.name, str):
# Type checkers may not narrow here
# Use Protocol + isinstance instead
return f"Hello, {obj.name}"
return "Hello"
from collections.abc import Callable
def execute(func_or_value: Callable[[], int] | int) -> int:
if callable(func_or_value):
# func_or_value is Callable[[], int]
return func_or_value()
else:
# func_or_value is int
return func_or_value
from typing import Literal, Never
def assert_never(value: Never) -> Never:
raise AssertionError(f"Unexpected value: {value}")
Status = Literal["pending", "active", "closed"]
def handle_status(status: Status) -> str:
if status == "pending":
return "Waiting..."
elif status == "active":
return "In progress"
elif status == "closed":
return "Done"
else:
# If we add a new status, type checker will error here
assert_never(status)
from typing import TypeGuard
def is_valid(item: str | None) -> TypeGuard[str]:
return item is not None
def process_items(items: list[str | None]) -> list[str]:
result: list[str] = []
for item in items:
if is_valid(item):
# item is str here
result.append(item.upper())
return result
# Or use filter with type guard
def process_items_functional(items: list[str | None]) -> list[str]:
valid_items = filter(is_valid, items)
return [item.upper() for item in valid_items]
class Animal:
pass
class Dog(Animal):
def bark(self) -> str:
return "Woof!"
class Cat(Animal):
def meow(self) -> str:
return "Meow!"
def make_sound(animal: Animal) -> str:
if isinstance(animal, Dog):
return animal.bark() # animal is Dog
elif isinstance(animal, Cat):
return animal.meow() # animal is Cat
return "..."
def unwrap_or_default(value: T | None, default: T) -> T:
if value is not None:
return value
return default
# With early return
def process(data: dict | None) -> dict:
if data is None:
return {}
# data is dict for rest of function
return {k: v.upper() for k, v in data.items()}
def get_nested(data: dict, *keys: str) -> object | None:
result: object = data
for key in keys:
if not isinstance(result, dict):
return None
result = result.get(key)
if result is None:
return None
return result
is None or is not None