Skip to main content

Python Overload Practice Problems & Exercises

Practice: Overload and Type Narrowing

11 problems3 Easy4 Medium4 Hard65–85 min
← Back to lesson

Easy

#1Overloaded double() for str and intEasy
overloadstrintunion

Use @overload to declare that double(x: int) -> int, double(x: str) -> str, and double(x: float) -> float. Implement the actual function.

Python
from typing import Union, overload


@overload
def double(x: int) -> int: ...
@overload
def double(x: str) -> str: ...
@overload
def double(x: float) -> float: ...


def double(x: Union[int, str, float]) -> Union[int, str, float]:
    if isinstance(x, int) and not isinstance(x, bool):
        return x * 2
    elif isinstance(x, float):
        return x * 2.0
    elif isinstance(x, str):
        return x * 2
    raise TypeError(f"Unsupported type: {type(x)}")


print(f"double(5) = {double(5)}")
print(f"double('hi') = {double('hi')}")
print(f"double(3.14) = {double(3.14)}")
Expected Output
double(5) = 10
double('hi') = hihi
double(3.14) = 6.28
Hints

Hint 1: @overload stubs declare the different signatures. The actual implementation uses Union and handles all cases.

Hint 2: The implementation body is NOT decorated with @overload — only the stubs are.


#2isinstance Type NarrowingEasy
type-narrowingisinstanceUnionnarrowing

Write a process function that uses isinstance to narrow a Union[int, str, list] and applies type-specific operations to each branch.

Python
from typing import Union, List


def process(value: Union[int, str, List[int], object]) -> str:
    if isinstance(value, int):
        return f"Processing int: {value} squared = {value ** 2}"
    elif isinstance(value, str):
        return f"Processing str: {value!r} upper = {value.upper()}"
    elif isinstance(value, list):
        return f"Processing list: {value} sum = {sum(value)}"
    else:
        return f"Unsupported: {type(value)}"


print(process(42))
print(process("hello"))
print(process([1, 2, 3]))
print(process({"key": "val"}))
Expected Output
Processing int: 42 squared = 1764
Processing str: 'hello' upper = HELLO
Processing list: [1,2,3] sum = 6
Unsupported: <class 'dict'>
Hints

Hint 1: After isinstance(x, int), the static checker narrows x to int in that branch. You can safely call int-specific operations.

Hint 2: The else branch still has the original Union type, so narrowing is sequential.


#3Literal Type for Config ModeEasy
Literaltype-narrowingconfigUnion

Use Literal types to write a get_config(mode) function where mode can only be "development", "production", or "staging". Return different config dicts for each.

Python
from typing import Literal, Dict, Any


def get_config(
    mode: Literal["development", "production", "staging"]
) -> Dict[str, Any]:
    if mode == "development":
        return {"debug": True, "db": "sqlite:///dev.db", "mode": mode}
    elif mode == "production":
        return {"debug": False, "db": "postgresql://prod/db", "mode": mode}
    else:   # staging — narrowed to exactly "staging"
        return {"debug": True, "db": "postgresql://staging/db", "mode": mode}


for env in ("development", "production", "staging"):
    cfg = get_config(env)  # type: ignore[arg-type]
    print(f"{env} config: debug={cfg['debug']}, db={cfg['db']}")
Expected Output
development config: debug=True, db=sqlite:///dev.db
production config: debug=False, db=postgresql://prod/db
staging config: debug=True, db=postgresql://staging/db
Hints

Hint 1: Declare mode: Literal["development", "production", "staging"] as the parameter type.

Hint 2: Each branch after if mode == "development" narrows mode to exactly that Literal.


Medium

#4TypeGuard for Custom Type PredicateMedium
TypeGuardtype-predicatenarrowingcustom

Write an is_int_list TypeGuard that verifies every element is an int. Use it to narrow a List[Any] before calling sum().

Python
from typing import List, Any
try:
    from typing import TypeGuard
except ImportError:
    from typing_extensions import TypeGuard


def is_int_list(lst: List[Any]) -> "TypeGuard[List[int]]":
    return all(isinstance(item, int) for item in lst)


def process_lists(lists: List[List[Any]]) -> None:
    for lst in lists:
        if is_int_list(lst):
            print(f"{lst} is int list: True")
            print(f"Sum of int list: {sum(lst)}")
        else:
            print(f"{lst} is int list: False")
            print("Mixed list skipped")


data = [
    [1, 2, 3],
    [1, "a", 3],
]

process_lists(data)
Expected Output
[1, 2, 3] is int list: True
[1, 'a', 3] is int list: False
Sum of int list: 6
Mixed list skipped
Hints

Hint 1: Annotate the guard function as def is_int_list(lst: List[Any]) -> TypeGuard[List[int]].

Hint 2: After the if is_int_list(x) check, the static checker narrows x to List[int] in the true branch.


#5Overloaded get() with Optional DefaultMedium
overloadOptionaldefaultdict-like

Implement an overloaded SafeDict.get(key, default=...) where the return type is V (not Optional[V]) when a non-None default is provided.

Python
from typing import TypeVar, Dict, Optional, overload, Union

K = TypeVar("K")
V = TypeVar("V")


class SafeDict(Dict[str, int]):
    @overload
    def get(self, key: str) -> Optional[int]: ...
    @overload
    def get(self, key: str, default: None) -> Optional[int]: ...
    @overload
    def get(self, key: str, default: int) -> int: ...

    def get(self, key: str, default: Optional[int] = None) -> Optional[int]:
        return super().get(key, default)


d = SafeDict({"a": 1, "b": 2, "c": 3})

print(f"get('a') = {d.get('a')}")
print(f"get('z') = {d.get('z')}")
print(f"get('z', 0) = {d.get('z', 0)}")
print(f"get('a', 99) = {d.get('a', 99)}")
Expected Output
get('a') = 1
get('z') = None
get('z', 0) = 0
get('a', 99) = 1
Hints

Hint 1: Three overloads: get(key) -> Optional[V], get(key, None) -> Optional[V], get(key, default: V) -> V.

Hint 2: The default=None overload returns Optional[V]; the default: V overload guarantees a V is returned.


#6Tagged Union Narrowing with DiscriminantMedium
Literaltagged-uniondiscriminantnarrowing

Use Literal discriminants in TypedDict shapes to enable exhaustive narrowing. Write an area(shape) function that handles all three tagged union variants.

Python
from typing import Union, Literal
import math
try:
    from typing import TypedDict
except ImportError:
    from typing_extensions import TypedDict


class CircleShape(TypedDict):
    kind: Literal["circle"]
    radius: float


class RectangleShape(TypedDict):
    kind: Literal["rectangle"]
    width: float
    height: float


class TriangleShape(TypedDict):
    kind: Literal["triangle"]
    base: float
    height: float


Shape = Union[CircleShape, RectangleShape, TriangleShape]


def area(shape: Shape) -> float:
    if shape["kind"] == "circle":
        return math.pi * shape["radius"] ** 2
    elif shape["kind"] == "rectangle":
        return shape["width"] * shape["height"]
    else:   # triangle — narrowed by exhaustion
        return 0.5 * shape["base"] * shape["height"]


shapes: list = [
    {"kind": "circle", "radius": 5.0},
    {"kind": "rectangle", "width": 4.0, "height": 6.0},
    {"kind": "triangle", "base": 3.0, "height": 4.0},
]

for s in shapes:
    print(f"{s['kind'].capitalize()} area: {area(s)}")  # type: ignore[arg-type]
Expected Output
Circle area: 78.53981633974483
Rectangle area: 24
Triangle area: 6.0
Hints

Hint 1: Each shape has a kind: Literal["circle"] / kind: Literal["rectangle"] / kind: Literal["triangle"]. This discriminant enables exhaustive narrowing.

Hint 2: After if shape['kind'] == 'circle', the checker knows it's the Circle TypedDict.


#7Overloaded parse() — List vs Single ValueMedium
overloadListsingle-valueUnion

Use @overload to write a parse function that accepts either a single string or a list of strings and converts them to int or float depending on the second argument.

Python
from typing import List, Union, Type, overload


@overload
def parse(value: str, type_: Type[int]) -> int: ...
@overload
def parse(value: List[str], type_: Type[int]) -> List[int]: ...
@overload
def parse(value: str, type_: Type[float]) -> float: ...
@overload
def parse(value: List[str], type_: Type[float]) -> List[float]: ...


def parse(
    value: Union[str, List[str]],
    type_: Union[Type[int], Type[float]] = int,
) -> Union[int, float, List[int], List[float]]:
    if isinstance(value, list):
        return [type_(v) for v in value]  # type: ignore[arg-type]
    return type_(value)  # type: ignore[arg-type]


print(f"parse('42') = {parse('42', int)}")
print(f"parse(['1','2','3']) = {parse(['1','2','3'], int)}")
print(f"parse('3.14', float) = {parse('3.14', float)}")
print(f"parse(['1.0','2.0'], float) = {parse(['1.0','2.0'], float)}")
Expected Output
parse('42') = 42
parse(['1','2','3']) = [1, 2, 3]
parse('3.14', float) = 3.14
parse(['1.0','2.0'], float) = [1.0, 2.0]
Hints

Hint 1: Four overloads: parse(str, int) -> int, parse(List[str], int) -> List[int], parse(str, float) -> float, parse(List[str], float) -> List[float].

Hint 2: The implementation uses isinstance to distinguish str from list, then delegates to the type_ callable.


Hard

#8Exhaustive Match with Literal UnionHard
Literalexhaustive-matchNoReturnassert_never

Write an exhaustive currency formatter using Literal union. Add an assert_never helper in the default branch to catch any future unhandled currency codes at compile time.

Python
from typing import Literal, Union, NoReturn


Currency = Literal["USD", "EUR", "GBP", "JPY"]


def assert_never(x: NoReturn) -> NoReturn:
    raise AssertionError(f"Unhandled currency: {x!r}")


def format_amount(amount: float, currency: Currency) -> str:
    if currency == "USD":
        return f"${amount:.2f}"
    elif currency == "EUR":
        return f"{amount:.2f} EUR"
    elif currency == "GBP":
        return f"{amount:.2f} GBP"
    elif currency == "JPY":
        return f"{int(amount)} JPY"
    else:
        assert_never(currency)


currencies: list = ["USD", "EUR", "GBP", "JPY"]
for c in currencies:
    print(f"{c}: {format_amount(100.0, c)}")   # type: ignore[arg-type]
Expected Output
USD: $100.00
EUR: 100.00 EUR
GBP: 100.00 GBP
JPY: 100 JPY
Hints

Hint 1: Use a Literal union for currency codes. Write an assert_never(x: NoReturn) helper that raises AssertionError — it is called in the else branch to catch unhandled cases.

Hint 2: A static checker will flag the else branch as unreachable if all Literal values are covered.


#9TypeGuard for Nested Dict StructureHard
TypeGuardTypedDictnestedvalidation

Write a TypeGuard-based validator for a nested EventPayload TypedDict. Use it to safely access deeply nested fields after narrowing.

Python
from typing import Any, Dict
try:
    from typing import TypedDict, TypeGuard
except ImportError:
    from typing_extensions import TypedDict, TypeGuard


class UserInfo(TypedDict):
    id: int
    name: str


class EventPayload(TypedDict):
    event: str
    user: UserInfo
    timestamp: float


def is_event_payload(data: Dict[str, Any]) -> "TypeGuard[EventPayload]":
    if not isinstance(data, dict):
        return False
    if "event" not in data or not isinstance(data["event"], str):
        return False
    if "timestamp" not in data or not isinstance(data["timestamp"], (int, float)):
        return False
    user = data.get("user")
    if not isinstance(user, dict):
        return False
    if "id" not in user or not isinstance(user["id"], int):
        return False
    if "name" not in user or not isinstance(user["name"], str):
        return False
    return True


def handle_event(data: Dict[str, Any]) -> None:
    if is_event_payload(data):
        print(f"Valid payload: True")
        print(f"user_id = {data['user']['id']}")
        print(f"event = {data['event']}")
    else:
        print(f"Invalid payload: False")
        print("Processing skipped")


valid = {
    "event": "login",
    "user": {"id": 42, "name": "Alice"},
    "timestamp": 1711000000.0,
}

invalid = {
    "event": "login",
    "user": "not-a-dict",  # wrong type
    "timestamp": 1711000000.0,
}

handle_event(valid)
handle_event(invalid)
Expected Output
Valid payload: True
user_id = 42
event = login
Invalid payload: False
Processing skipped
Hints

Hint 1: The TypeGuard function checks for the presence of all required keys and their types recursively.

Hint 2: Once the guard passes, the checker narrows the dict to the TypedDict shape inside the if block.


#10Overloaded async/sync Dual InterfaceHard
overloadasyncsyncdual-interfaceAwaitable

Use @overload to declare a fetch function that returns str when async_mode=False and a Coroutine when async_mode=True. Show both call paths work.

Python
import asyncio
from typing import Union, overload
from typing import Coroutine, Any


@overload
def fetch(url: str, async_mode: "Literal[False]" = ...) -> str: ...
@overload
def fetch(url: str, async_mode: "Literal[True]") -> Coroutine[Any, Any, str]: ...


from typing import Literal


@overload
def fetch(url: str, async_mode: Literal[False] = ...) -> str: ...
@overload
def fetch(url: str, async_mode: Literal[True]) -> Coroutine[Any, Any, str]: ...


def fetch(
    url: str,
    async_mode: bool = False,
) -> Union[str, Coroutine[Any, Any, str]]:
    async def _async_fetch() -> str:
        await asyncio.sleep(0)   # simulate async I/O
        return f"data for {url}"

    if async_mode:
        return _async_fetch()
    else:
        return f"data for {url}"


# Sync path
sync_result = fetch("/api/users", async_mode=False)
print(f"sync fetch: {sync_result}")

# Async path
async def main() -> None:
    async_result = await fetch("/api/orders", async_mode=True)
    print(f"async fetch: {async_result}")

asyncio.run(main())
print("Both modes work: True")
Expected Output
sync fetch: data for /api/users
async fetch: data for /api/orders
Both modes work: True
Hints

Hint 1: Two overloads: fetch(url, async_mode=False) -> str and fetch(url, async_mode=True) -> Coroutine[Any, Any, str].

Hint 2: The implementation checks async_mode and either returns directly or returns a coroutine object.


#11Full Narrowing Pipeline — Validate then ProcessHard
TypeGuardoverloadLiteralnarrowingpipeline

Chain three narrowing techniques — TypeGuard, Literal status narrowing, and @overload — in a single order-processing pipeline.

Python
from typing import Dict, Any, List, Literal, Union, overload
try:
    from typing import TypedDict, TypeGuard
except ImportError:
    from typing_extensions import TypedDict, TypeGuard


OrderStatus = Literal["pending", "confirmed", "shipped", "cancelled"]


class OrderRequest(TypedDict):
    order_id: int
    customer: str
    items: List[str]
    status: str


def is_order_request(data: Dict[str, Any]) -> "TypeGuard[OrderRequest]":
    required = {"order_id": int, "customer": str, "items": list, "status": str}
    for key, expected in required.items():
        if key not in data or not isinstance(data[key], expected):
            return False
    return True


@overload
def format_order(order: OrderRequest, status: Literal["pending"]) -> str: ...
@overload
def format_order(order: OrderRequest, status: Literal["confirmed"]) -> str: ...
@overload
def format_order(order: OrderRequest, status: str) -> str: ...


def format_order(order: OrderRequest, status: str) -> str:
    return (
        f"Order {order['order_id']} for {order['customer']} is {status} "
        f"with {len(order['items'])} items"
    )


def process_order(raw: Dict[str, Any]) -> str:
    # Step 1: TypeGuard narrowing
    if not is_order_request(raw):
        return "Invalid order"
    print(f"Step 1: raw dict validated: True")

    # Step 2: now narrowed to OrderRequest
    order: OrderRequest = raw
    print(f"Step 2: typed as OrderRequest")

    # Step 3: Literal narrowing on status
    status = order["status"]
    if status == "pending":
        print(f"Step 3: status narrowed to 'pending'")
        return format_order(order, "pending")
    elif status == "confirmed":
        return format_order(order, "confirmed")
    else:
        return format_order(order, status)


raw_order = {
    "order_id": 123,
    "customer": "Alice",
    "items": ["widget", "gadget"],
    "status": "pending",
}

result = process_order(raw_order)
print(f"Result: {result}")
Expected Output
Step 1: raw dict validated: True
Step 2: typed as OrderRequest
Step 3: status narrowed to 'pending'
Result: Order 123 for Alice is pending with 2 items
Hints

Hint 1: Chain three narrowing steps: is_order_request TypeGuard, then Literal narrowing on status, then overloaded format_order.

Hint 2: Each step demonstrates a different narrowing technique — TypeGuard, Literal comparison, and overload.

© 2026 EngineersOfAI. All rights reserved.