Skip to content
aviral gupta

// A2.5 · ~30 min · Advanced

Overloads and type narrowing

After this lesson you can give a function one signature per argument type with @overload, predict how mypy narrows a union after isinstance, None checks and match, and write a TypeIs predicate that narrows both branches.

Lesson 5 of 6 in A2 Typing

You will be able to

  • Write @overload signatures with one implementation, and read the errors mypy reports for them
  • Predict how mypy narrows a union after isinstance, is None, truth tests, type() and match
  • Write predicate functions with TypeIs, and know when TypeGuard is needed instead
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson A2.1: find returns an int or None. Which error code does mypy report for the last line?

    def find(items: list[str], name: str) -> int | None:
        return items.index(name) if name in items else None
    
    
    print(find(["a"], "a") + 1)
  2. Predict · Activity 2 of 7

    Predict before you read on: double(3) returns 6 at runtime. What error does mypy report for the last line?

    def double(value: int | str) -> int | str:
        return value * 2
    
    
    print(double(3) + 1)
  3. Practice · Activity 3 of 7

    The second stub already has it: fill in the decorator that gives double one signature per argument type.

    @____
    def double(value: int) -> int: ...
    @overload
    def double(value: str) -> str: ...
    def double(value: int | str) -> int | str:
        return value * 2
    @
  4. Practice · Activity 4 of 7

    What type does mypy reveal for v?

    def f(v: int | str | None) -> None:
        if isinstance(v, int):
            pass
        elif v is None:
            pass
        else:
            reveal_type(v)
  5. Practice · Activity 5 of 7

    v is int | float | str, w is str | None and u is int | str; is_str returns TypeIs[str] and maybe_str returns TypeGuard[str]. Match each check to the type mypy gives inside its if.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. is_short returns False for long strings. What type does mypy reveal in the else branch?

    from typing import TypeIs
    
    
    def is_short(value: object) -> TypeIs[str]:
        return isinstance(value, str) and len(value) < 5
    
    
    def show(value: str | int) -> None:
        if is_short(value):
            print("short text", value)
        else:
            reveal_type(value)
  7. Apply · Activity 7 of 7

    Mini-task. Write pick(items, where) for a list[str]: an int index returns one str, a slice returns a list[str]. Give it two @overload stubs and one implementation, so that pick(names, 0).upper() and len(pick(names, slice(1, 3))) both pass mypy --strict. Print both for names = ["Ada", "Alan", "Grace"].

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Overloads and narrowing together

double has two overloads, so double(value) + 1 type-checks once value is known to be an int. is_pair is a TypeIs predicate. describe narrows its four-way union step by step: is None, then is_pair, then isinstance, and what is left must be a str. The program passes mypy --strict.

main.py

from typing import TypeIs, get_overloads, overload


@overload
def double(value: int) -> int: ...
@overload
def double(value: str) -> str: ...
def double(value: int | str) -> int | str:
    return value * 2


def is_pair(value: object) -> TypeIs[tuple[int, int]]:
    return isinstance(value, tuple) and len(value) == 2 and all(isinstance(x, int) for x in value)


def describe(value: int | str | tuple[int, int] | None) -> str:
    if value is None:
        return "nothing"
    if is_pair(value):
        left, right = value
        return f"pair summing to {left + right}"
    if isinstance(value, int):
        return f"number {double(value) + 1}"
    return f"text {double(value).upper()}"


for value in (20, "ab", (3, 4), None):
    print(describe(value))
print(len(get_overloads(double)))

Run it with

python main.py

Output

number 41
text ABAB
pair summing to 7
nothing
2
  • double(value) + 1 and double(value).upper() each pick the matching overload; with only the union signature, both lines would be errors.
  • is_pair returns True for every tuple[int, int], which is what makes its False branch safe.
  • After the three checks, mypy knows the last line only sees a str.
  • get_overloads(double) returns the two stubs; the implementation is not among them.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

An overloaded converter

to_int turns a str into an int and passes None through. With its union signature, mypy rejects total(), which only ever passes strings. Add two @overload stubs, str to int and None to None, above the implementation, so that total() type-checks. Keep the implementation as it is.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Put @overload and a def with the same name above the implementation, twice. The stub bodies are just ...

  2. Hint 2

    Write None, not NoneType, in the second stub: def to_int(value: None) -> None: ...

  3. Hint 3

    The implementation keeps its union signature and no decorator; it must be the last definition.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from typing import overload


@overload
def to_int(value: str) -> int: ...
@overload
def to_int(value: None) -> None: ...
def to_int(value: str | None) -> int | None:
    if value is None:
        return None
    return int(value)


def total(texts: list[str]) -> int:
    return sum(to_int(text) for text in texts)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from typing import overload


def to_int(value: str | None) -> int | None:
    if value is None:
        return None
    return int(value)


def total(texts: list[str]) -> int:
    return sum(to_int(text) for text in texts)

test_main.py

from typing import get_overloads, get_type_hints

from main import to_int, total


def test_overloads():
    """to_int has two overloads above the implementation: str to int and None to None"""
    stubs = [stub for stub in get_overloads(to_int) if stub.__code__.co_firstlineno < to_int.__code__.co_firstlineno]
    got = [(get_type_hints(stub)["value"], get_type_hints(stub)["return"]) for stub in stubs]
    assert sorted(got, key=repr) == sorted([(str, int), (type(None), type(None))], key=repr), f"the overloads are {got!r}"


def test_values():
    """to_int("42") is 42 and to_int(None) is None"""
    got = to_int("42"), to_int(None)
    assert got == (42, None), f"to_int gave {got!r}"


def test_total():
    """total adds the numbers"""
    got = total(["1", "2", "39"])
    assert got == 42, f"total returned {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Rendering config values

A config value is a str, an int or a list[str]. Change is_text_list to return TypeIs[list[str]] instead of TypeGuard, so both branches narrow. Then finish render: a list is joined with ", ", an int is formatted with thousands separators (f"{value:,}"), and a str is returned as it is, with no str() call: mypy should know it is a str.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Only the return annotation of is_text_list changes: -> TypeIs[list[str]].

  2. Hint 2

    After the list case, add if isinstance(value, int): return f"{value:,}".

  3. Hint 3

    With TypeGuard, return value at the end would be an error, because mypy would still see str | int | list[str]. With TypeIs, only str is left.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from typing import TypeGuard, TypeIs

type Value = str | int | list[str]


def is_text_list(value: Value) -> TypeIs[list[str]]:
    return isinstance(value, list)


def render(value: Value) -> str:
    if is_text_list(value):
        return ", ".join(value)
    if isinstance(value, int):
        return f"{value:,}"
    return value
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from typing import TypeGuard, TypeIs

type Value = str | int | list[str]


def is_text_list(value: Value) -> TypeGuard[list[str]]:
    return isinstance(value, list)


def render(value: Value) -> str:
    if is_text_list(value):
        return ", ".join(value)
    return str(value)

test_main.py

from typing import TypeIs, get_origin, get_type_hints

from main import is_text_list, render


def test_typeis():
    """is_text_list is annotated to return TypeIs[...]"""
    got = get_type_hints(is_text_list)["return"]
    assert get_origin(got) is TypeIs, f"is_text_list returns {got!r}, expected TypeIs[list[str]]"


def test_list():
    """A list is joined with a comma and a space"""
    got = render(["a", "b"])
    assert got == "a, b", f"render(['a', 'b']) returned {got!r}"


def test_int():
    """An int gets thousands separators"""
    got = render(1234567)
    assert got == "1,234,567", f"render(1234567) returned {got!r}"


def test_str():
    """A str comes back unchanged"""
    got = render("debug")
    assert got == "debug", f"render('debug') returned {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Overload stubs without an implementation

from typing import overload


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


print(double(3))

What Python prints

NotImplementedError: You should not call an overloaded function. A series of @overload-decorated functions outside a stub module should always be followed by an implementation that is not @overload-ed.

Why, and the fix

The stubs are only signatures. Add one definition without @overload, after the stubs, that handles every case: def double(value: int | str) -> int | str: return value * 2. mypy reports the missing implementation too: An overloaded function outside a stub file must have an implementation [no-overload-impl].

An implementation that skips a case

from typing import overload


@overload
def to_int(value: str) -> int: ...
@overload
def to_int(value: None) -> None: ...
def to_int(value: str | None) -> int | None:
    return int(value)


print(to_int(None))

What Python prints

TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'

Why, and the fix

The overloads promise that to_int(None) returns None, but the implementation never checks. Narrow first: if value is None: return None, then return int(value). mypy catches this: it reports the return line with an [arg-type] error, because int() does not accept str | None.

A TypeIs function that returns False for some matches

from typing import TypeIs


def is_short(value: object) -> TypeIs[str]:
    return isinstance(value, str) and len(value) < 5


def show(value: str | int) -> None:
    if is_short(value):
        print("short text", value)
    else:
        print(value + 1)


show(41)
show("a longer text")

What Python prints

TypeError: can only concatenate str (not "int") to str

Why, and the fix

mypy accepts value + 1, because TypeIs[str] says a False result means "not a str". is_short also returns False for long strings, so that promise is broken. Keep the predicate exact (return isinstance(value, str)) and test the length separately, or use a plain bool return, which narrows nothing.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

@overload: one signature per case

def double(value: int | str) -> int | str loses the link between argument and result: double(3) + 1 is an error, because the result might be a str. Overloads restore it. Write one @overload stub per case, ending in ..., then exactly one implementation without the decorator. mypy picks the first stub that matches a call; the implementation is what runs, and it must accept every stub's arguments. typing.get_overloads() lists the stubs at runtime.

Narrowing: mypy follows your checks

Inside if isinstance(value, int):, mypy treats a value of type int | str as int, and in the else branch as str. The same works for value is None, for match value: case str():, and for truth tests: after if not text: a str | None is Literal[''] | None. type(value) is int narrows only the if branch, because a subclass such as bool would take the else branch. Narrowing is a static analysis: nothing is converted or checked at runtime.

TypeIs: your own narrowing function

A function returning bool tells mypy nothing. Annotate its return as TypeIs[str] and a call in an if narrows like isinstance: to str when True, and without str when False. The narrowed type must be a subtype of the parameter type. TypeGuard[list[str]] allows narrowing list[object] to list[str], but narrows only the True branch. Both are promises: if the function returns False for some str, the else branch is wrong.

Sources

Last reviewed September 29, 2026