JEPA4Japan · tutorials

Type Hints and Protocols

1,333 words 7 min read #Python

Document expectations with annotations, unions, generics, and structural interfaces.

Course progress Course outline 24 of 24 lessons available

Type hints are sticky notes, not guards

A type hint tells readers and static tools what a value should be. It does not normally stand at the door and stop a different runtime value.

  1. Annotation name: str
  2. Type checker reads before running
  3. Python uses the actual object
Hints describe a static contract; runtime validation is a separate job.
def repeat(label: str, times: int) -> str:
    return label * times


print(repeat("go", 3))
print(repeat(2, 3))
gogogo
6

A checker should flag the second call, yet Python performs integer multiplication. Validate data from users, files, and networks yourself. Annotations provide no automatic conversion either.

Describe boxes and maybes

Collection hints describe both the container and its contents:

def total_by_topic(sessions: list[tuple[str, int]]) -> dict[str, int]:
    totals: dict[str, int] = {}
    for topic, minutes in sessions:
        totals[topic] = totals.get(topic, 0) + minutes
    return totals


print(total_by_topic([("Python", 20), ("Python", 25)]))
{'Python': 45}

The hint does not inspect every item at runtime. Ask only for capabilities you need: a read-only function that merely loops can accept Iterable[str]; one that indexes can accept Sequence[str]; a function that mutates specifically may need list[str].

str | None means a string or None. It is the modern spelling of Optional[str]. int | str expresses the same union idea as Union[int, str].

def author_label(author: str | None) -> str:
    if author is None:
        return "Unknown"
    return author.strip().title()

After the None branch returns, a checker can narrow author to str. Optional does not mean omittable: calling author_label() still needs an argument. Use author: str | None = None when omission is allowed.

  1. Union int | str
  2. Check isinstance(value, int)
  3. Int branch integer operations are safe
  4. Else branch string operations are safe
Runtime evidence narrows a union for both Python and the checker.

A type alias gives a repeated shape a domain name:

from typing import TypeAlias

Session: TypeAlias = tuple[str, int]

Python 3.12 also supports type Session = tuple[str, int]. An alias is vocabulary, not a new runtime class or validator. Use a dataclass when fields need names or rules.

Describe behavior and preserve relationships

Callable[[str], str] describes something callable with one string that returns a string:

from collections.abc import Callable


def clean_all(words: list[str], clean: Callable[[str], str]) -> list[str]:
    return [clean(word) for word in words]

It can accept a function, bound method, or callable object with that contract.

A TypeVar keeps a relationship instead of naming one concrete type:

from collections.abc import Sequence
from typing import TypeVar

T = TypeVar("T")


def first_or_none(items: Sequence[T]) -> T | None:
    return items[0] if items else None

For list[int], the result is int | None; for list[str], it is str | None. Returning object | None would lose that useful connection. Add a type variable only when it expresses a real input-output relationship.

A Protocol is a capability card

A Protocol names the small behavior a consumer needs. Classes satisfy it structurally by having compatible members; they do not need to inherit from it.

  1. Protocol requires summary()
  2. Article has that capability
  3. Video has it too
Compatible shape, not a shared family tree, satisfies a protocol.
from typing import Protocol


class Summarizable(Protocol):
    def summary(self) -> str:
        ...


def show(item: Summarizable) -> None:
    print(item.summary())

Keep protocols small. @runtime_checkable permits a limited isinstance() presence check, but it does not validate full method signatures or annotated value types:

from typing import Protocol, runtime_checkable


@runtime_checkable
class Fetcher(Protocol):
    def fetch(self) -> list[str]:
        ...


class WrongShape:
    def fetch(self, required_argument):
        return 42


print(isinstance(WrongShape(), Fetcher))

This can print True because fetch exists. Static checking and runtime data validation still matter.

Tiny project: Typed Notification Digest

Create notification_digest.py. from __future__ import annotations lets this modern notation run on older supported interpreters too. The project combines a type alias, callable, generic helper, protocol, and real runtime validation.

from __future__ import annotations

from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import Protocol, TypeVar

RawNotice = tuple[str, int]
T = TypeVar("T")


@dataclass(frozen=True)
class Notice:
    source: str
    message: str
    score: int

    def __post_init__(self) -> None:
        if not self.source.strip() or not self.message.strip():
            raise ValueError("text must not be blank")
        if isinstance(self.score, bool) or not isinstance(self.score, int):
            raise TypeError("score must be an integer")
        if not 0 <= self.score <= 100:
            raise ValueError("score must be from 0 to 100")


class NoticeSource(Protocol):
    def fetch(self) -> Iterable[Notice]:
        ...


class MemorySource:
    def __init__(self, name: str, records: list[RawNotice]) -> None:
        self.name = name
        self.records = records

    def fetch(self) -> Iterable[Notice]:
        for message, score in self.records:
            yield Notice(self.name, message, score)


def first_or_none(items: Sequence[T]) -> T | None:
    return items[0] if items else None


def collect(
    sources: Iterable[NoticeSource], keep: Callable[[Notice], bool]
) -> list[Notice]:
    notices = [
        notice
        for source in sources
        for notice in source.fetch()
        if keep(notice)
    ]
    return sorted(notices, key=lambda notice: (-notice.score, notice.source))


def format_digest(notices: Sequence[Notice]) -> str:
    if not notices:
        return "No notices."
    lines = ["Notification digest"]
    lines.extend(
        f"- {notice.score:03d} | {notice.source}: {notice.message}"
        for notice in notices
    )
    top = first_or_none(notices)
    if top is not None:
        lines.append(f"Top source: {top.source}")
    return "\n".join(lines)


sources: list[NoticeSource] = [
    MemorySource("Python", [("Typing notes", 88), ("Style guide", 62)]),
    MemorySource("Testing", [("Regression suite", 91)]),
]
notices = collect(sources, lambda notice: notice.score >= 70)
print(format_digest(notices))
print(f"Empty helper: {first_or_none([]) is None}")

Run python3 notification_digest.py and check:

Notification digest
- 091 | Testing: Regression suite
- 088 | Python: Typing notes
Top source: Testing
Empty helper: True

MemorySource never inherits from NoticeSource; its compatible fetch() is enough for static structural typing. Boundary reminder: hints do not enforce the score range—the dataclass checks it at runtime. Passing a bad callback can also still fail while running.

Three tiny missions

  1. Narrow a union. Accept int | str; zero-pad integers and uppercase nonblank strings after an isinstance() check.
  2. Keep the type. Write last_or_none(items: Sequence[T]) -> T | None and try integers and Notice objects.
  3. Add a source. Make a tuple-backed class with fetch() without inheriting from NoticeSource.

You are ready for Chapter 17 when…

  • you can explain that hints normally do not enforce runtime values;
  • you can annotate collections by the capabilities they need;
  • you can use Optional/Union ideas and narrow with real checks;
  • you know a type alias names a shape but does not validate it;
  • you can annotate a callback with Callable;
  • you can use TypeVar to preserve an input-output relationship;
  • you can define and structurally satisfy a small Protocol;
  • you know runtime_checkable checks presence, not full signatures;
  • you can run the digest and see Empty helper: True.

Next, you will see how iterators and generators let a source produce values one at a time.