Skillversitas

Type Hints in OOP

Type hints annotate what types a class, method, and attribute are supposed to deal in. Python does not enforce them at runtime - they are a contract for the reader, the editor, and the static checker - but in OOP they do real work: they make a class's interface legible, they let a checker catch a missing override or a wrong argument before the code runs, and they document the polymorphic boundaries the earlier chapters built. This chapter covers hinting classes (constructor parameters and attributes), methods (returns, the self convention), generics for parameterized types, and the modern Self type for fluent APIs.

Hinting a Class

from __future__ import annotations

class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        self.width: float = width
        self.height: float = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

r = Rectangle(3.0, 4.0)
print(r.area())          # 12.0 - hints change nothing at runtime
print(annotations)       # the module-level dict of what was declared

The annotations are: parameter types in the signature, a -> return type, and optional attribute annotations in the body (which create entries in the class's __annotations__ and, with __future__, are stored as strings). They are invisible to the interpreter - Rectangle("a", "b") still constructs, and the error surfaces later as an unsupported operand - which is exactly why the value is static: a checker reads the hints and reports the mismatch at the call site. The convention: hint everything public. Constructor parameters, method parameters, return types, and the attributes that form the interface. Internal scratch variables need no hint; the interface does.

Method Hints: self, cls, and Returns

from __future__ import annotations
from typing import Generic, TypeVar

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        if not self._items:
            raise IndexError("pop from empty stack")
        return self._items.pop()

    def is_empty(self) -> bool:
        return not self._items

    @classmethod
    def from_iterable(cls, items: list[T]) -> Stack[T]:
        s = cls()
        for i in items:
            s.push(i)
        return s

s = Stack.from_iterable([1, 2, 3])
print(s.pop())           # 3 - the checker knows it is an int

By convention self and cls are not annotated - the checker infers them from the class. Annotate everything else: each parameter, the return, and the generic [T] on the class when it is parameterized (the generics chapter of the type-hinting story; T here is a TypeVar-like placeholder that list[T] binds per-instantiation). The return hint on a method that can fail is a design decision: raise (and hint the happy-path return) versus return Optional[T] (and force the caller to check). For OOP specifically, the hints on a base class's methods are the contract a subclass's override must satisfy - the checker compares them, which is how a wrong-argument-count override is caught before runtime.

The Self Type: Fluent APIs Done Right

from typing import Self

class Query:
    def __init__(self, table: str) -> None:
        self.table = table

    def where(self, clause: str) -> Self:
        # Self says: a subclass returns an instance of ITSELF
        self._where = clause
        return self

    def limit(self, n: int) -> Self:
        self._limit = n
        return self

class SqliteQuery(Query):
    def dialect(self) -> str:
        return "sqlite"

q = SqliteQuery("users").where("active = 1").limit(10)
# without Self, the checker types q as Query after the first call,
# and q.dialect() is an error; with Self, q stays a SqliteQuery

typing.Self (3.11+) names "the type of self" - the actual runtime class, including subclasses. A fluent method returning Self promises "you get back the same kind you called it on," so a chain of .where().limit() on a SqliteQuery stays a SqliteQuery to the checker, and .dialect() remains callable. Before Self, the workarounds (a TypeVar bound to the class, or returning the base) lost the subtype through the chain. For any API built on method chaining, Self is the correct return hint - it is the static counterpart of the dynamic dispatch the polymorphism chapter described.

Optionals, Unions, and Honest Returns

from __future__ import annotations
from typing import Optional

class Registry:
    def __init__(self) -> None:
        self._items: dict[str, object] = {}

    def register(self, name: str, obj: object) -> None:
        self._items[name] = obj

    def get(self, name: str) -> Optional[object]:
        # Optional[object] == object | None: the honest "may be missing"
        return self._items.get(name)

    def resolve(self, name: str) -> object:
        # the variant that refuses to be missing: raises instead
        if name not in self._items:
            raise KeyError(name)
        return self._items[name]

r = Registry()
print(r.get("nope"))        # None - the checker knows to handle it
# r.resolve("nope") is typed to always return - the checker trusts the raise

The two honest patterns for "might not be there": Optional[T] (equivalently T | None) returns None and forces every caller to handle the absence - the checker makes the handling visible; or a non-Optional return that raises on absence, which the checker treats as "always a value." Pick per method based on how common the absence is: rare-and-fatal means raise, expected-and-routine means None. The anti-pattern is a method that "usually" returns a value and None in edge cases, hinted as the non-Optional type - the lie that turns a type system into a rumor. The hints are only as good as the honesty of the returns they describe.

Summary

Hint the interface: constructor parameters, attribute annotations, method parameters, and return types, with self/cls left unannotated by convention. Use Self for fluent methods so subclass chains keep their static type, and choose the honest return pattern for absence - Optional[T] for expected, raise for fatal. Python does not enforce hints at runtime, so their value is entirely static and collaborative: the checker catches a wrong-arity override and a missing None check before the code runs, and the reader sees the contract in the signature. In OOP the payoff is direct - the hinted base-class methods are the contract the override must satisfy, which is the same polymorphic boundary the dispatch chapters described, now made visible and checkable.