[Python Series 17] Define Interfaces with Type Hints and `typing.Protocol`

한국어 버전

Python is a dynamic language, but adding type hints lets editors and static analyzers catch more mistakes early. This lesson walks through the essential features of the typing module and shows how to design interfaces using Protocol. Do not try to memorize every term at once—proceed in this order: “leave expectations on function parameters,” “document dictionary shapes,” then “describe interfaces by behavior instead of names.”

Key terms

  1. TypedDict: a tool that defines dictionary keys and value types like a lightweight class
  2. Static analyzer: tools such as mypy or pyright that read hints without running your code and report type issues

Core ideas

Study notes

  • Time required: 70–80 minutes (core sections)
  • Prerequisites: function/class design, exception handling, a pytest-ready codebase
  • Goal: annotate critical functions, build a Protocol-based interface, and run a static check
  • Type hints document the types a function or variable expects.
  • Protocols are structural interfaces that describe duck typing statically.
  • TypedDict documents the structure of dictionaries.
  • Static analyzers such as mypy and pyright read hints and flag errors.
  • Finish the Core sections first; “optional extension” parts can wait.

Code examples

Type-hint fundamentals (Core)

def grade(score: int) -> str:
    if score >= 90:
        return "A"
    if score >= 80:
        return "B"
    return "C"
  • score: int annotates a parameter.
  • -> str documents the return type.

The interpreter does not enforce hints, but tools like mypy and pyright do.

Annotate CLI parameters (Core)

Think about a Typer command handler: if you confuse strings for numbers or forget to handle None, the CLI stops immediately.

from mealbot.notifier import SlackNotifier


def send_meal(school_code: str, *, limit: int | None = None) -> list[str]:
    """Return a list of messages for the given school."""
    data = fetch_meal(school_code)
    menu: list[str] = data["menu"]
    if limit is not None:
        menu = menu[:limit]
    return menu


def push_to_slack(menu: list[str], notifier: SlackNotifier) -> None:
    for line in menu:
        notifier.send(line)
  • The signature alone tells you what goes in and out.
  • limit: int | None makes it obvious the caller must handle the optional case.
  • Specific types like list[str] feed editor auto-completion.

Collections and optional types (Core)

from typing import List, Dict, Optional


users: List[str] = ["민지", "준호"]
scores: Dict[str, int] = {"민지": 92}
maybe_score: Optional[int] = scores.get("지훈")
  • Optional[int] means “either int or None.”
  • On Python 3.9+, you can write list[str] and dict[str, int] directly.

Type aliases and TypedDict (Optional)

from typing import TypedDict


class User(TypedDict):
    name: str
    is_active: bool


def deactivate(user: User) -> User:
    user["is_active"] = False
    return user

TypedDict locks in the dictionary structure and documents the keys.

Introducing Protocol (Core → Plus)

typing.Protocol describes duck typing as an abstract interface. Any class that matches the method signatures is considered compatible, regardless of inheritance.

from typing import Protocol


class Notifier(Protocol):
    def send(self, message: str) -> None:
        ...


def broadcast(notifier: Notifier, payload: str) -> None:
    notifier.send(payload)


class SlackNotifier:
    def send(self, message: str) -> None:
        print(f"slack: {message}")


class SMSNotifier:
    def send(self, message: str) -> None:
        print(f"sms: {message}")

Even though SlackNotifier and SMSNotifier never inherit from Notifier, static analyzers accept them because they implement send. In other words, you declare “what an object can do,” not “what it is named.” Test doubles from the CLI exercise only need to match the same signature.

Structural subtyping and runtime_checkable (Optional)

from typing import runtime_checkable


@runtime_checkable
class Exporter(Protocol):
    def export(self, data: dict) -> bytes:
        ...


def run_export(obj: Exporter):
    assert isinstance(obj, Exporter)
    return obj.export({})

Adding @runtime_checkable allows isinstance checks, though it can only verify method presence. For complex constraints, consider abc.ABC.

Integrate the type checker (Core → Plus)

If you have never run a static check, aim for a single command such as uv run mypy src. Later you can wire it into CI or your IDE.

  • Install with uv add mypy and run mypy src.
  • VS Code and PyCharm can lint on save.
  • In CI, raise the bar with mypy --strict.
direction: right

editor: "VS Code/PyCharm
write hints"
checker: "mypy/pyright
static analysis"
ci: "CI pipeline
`uv run mypy`"
runtime: "Runtime
pytest/execute"

editor -> checker: "lint on save"
checker -> ci: "share rules"
ci -> runtime: "deploy checked code"

Connecting your editor, static checker, and CI keeps everyone on the same contract.

Why it matters

  • Type hints are optional but dramatically improve collaboration.
  • Protocols let you define interfaces by behavior.
  • Wiring a static checker into CI keeps the safety net active.

Practice

  • Follow along: Annotate grade and broadcast, then run mypy to confirm they pass.
  • Extend: Define a Protocol, swap Slack/SMS implementations, and experiment with TypedDict or runtime_checkable.
  • Debug: Deliberately assign a wrong return type, observe the mypy error, then fix it and note the difference.
  • Definition of done: Core functions and Protocols type-check cleanly, and optional sections are logged as future reading.

Wrap-up

Next, dive into Python's async/await syntax to write asynchronous code.

💬 댓글

이 글에 대한 의견을 남겨주세요