Type Hints in Real Code
Java's type system is mandatory and enforced at compile time. You cannot ship a .class file that references a method with the wrong signature — javac refuses to emit it. Python's type system is optional and checked by external tools. You can write perfectly untyped Python, ship it, and it runs. Types in Python are annotations — metadata attached to variables and functions — not a runtime constraint.
For a JVM engineer, this feels like wearing a seatbelt and then being told it is decorative. But gradual typing — the ability to add types incrementally — is exactly what makes Python type annotations useful in practice.
Annotations are Runtime Metadata
Python annotations are stored in __annotations__ and are not enforced by the interpreter:
def greet(name: str) -> str:
return "Hello, " + name
greet(42) # runs fine at runtime — no TypeError
print(greet.__annotations__) # {'name': <class 'str'>, 'return': <class 'str'>}The type checker (mypy, pyright) is a separate tool that reads these annotations and reports violations statically — the equivalent of javac type checking, but decoupled from execution.
Basic Syntax
# Variables
count: int = 0
name: str = "Alice"
ratio: float = 3.14
active: bool = True
# Functions
def add(a: int, b: int) -> int:
return a + b
# Collections (Python 3.9+ — no import needed)
def process(items: list[str]) -> dict[str, int]:
return {item: len(item) for item in items}
# Optional values
def find_user(user_id: int) -> str | None: # Python 3.10+
...
# Python < 3.10 style
from typing import Optional
def find_user_old(user_id: int) -> Optional[str]:
...Java analogue mapping:
| Java | Python |
|---|---|
String |
str |
int / Integer |
int |
List<String> |
list[str] |
Map<String, Integer> |
dict[str, int] |
Optional<String> |
str | None |
void |
None return type |
Object |
object or Any |
Generics <T> |
TypeVar / Generic[T] |
Generics
from typing import TypeVar, Generic
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:
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
s.push(2)
val: int = s.pop() # mypy knows this is intPython 3.12 introduces type parameter syntax (PEP 695):
# Python 3.12+
class Stack[T]:
def push(self, item: T) -> None: ...
def pop(self) -> T: ...Protocols: Structural Typing
Java uses nominal typing for interfaces — a class must explicitly implement Comparable. Python protocols are structural (duck-typed interfaces) — a class satisfies a protocol if it has the right methods, regardless of inheritance:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
class Square:
def draw(self) -> None:
print("Drawing square")
def render(shape: Drawable) -> None:
shape.draw()
render(Circle()) # OK — Circle satisfies Drawable structurally
render(Square()) # OK — no explicit "implements Drawable" neededThis is the Python equivalent of Java's @FunctionalInterface plus structural duck typing — powerful for testing (mock any protocol without subclassing).
mypy vs pyright
Both are static type checkers. The practical differences:
# mypy
pip install mypy
mypy src/ # check all files
mypy --strict src/ # maximum strictness
# pyright (also powers Pylance in VSCode)
npm install -g pyright # or: pip install pyright
pyright src/Configure mypy in pyproject.toml:
[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = trueGradual Typing in Practice
You do not need to type everything at once. The # type: ignore comment suppresses individual errors; Any is the escape hatch for values you cannot type yet:
from typing import Any
def legacy_parser(raw: Any) -> dict[str, Any]:
# TODO: tighten types when we understand the schema
return raw # type: ignore[return-value]A practical adoption strategy:
- Add
mypy --ignore-missing-importsto CI (no failures on untyped code yet). - Type new functions and modules as you write them.
- Enable
--strictmodule by module using# mypy: strictor per-module overrides. - Replace
Anywith concrete types as you gain understanding of each boundary.
This mirrors how teams adopt @SuppressWarnings suppressions strategically rather than all-or-nothing.
Runtime Type Checking
For validation at runtime (parsing API payloads, configuration), use Pydantic (covered in post 5) or beartype:
from beartype import beartype
@beartype
def add(a: int, b: int) -> int:
return a + b
add(1, "2") # raises BeartypeCallHintParamViolation at runtimebeartype enforces type hints at call time — the closest Python equivalent to Java's runtime reflection-based checks, but with negligible overhead.
Key Takeaways
- Python type hints are annotations, not runtime enforcement — a type checker (mypy/pyright) is the
javacequivalent. - The
|union syntax (str | None) replacesOptional[str]in Python 3.10+; prefer it in new code. - Protocols enable structural typing — a class satisfies an interface without explicit declaration, like duck-typed
implements. - Gradual typing means you can annotate incrementally;
Anyand# type: ignoreare legitimate tools, not signs of failure. - Configure mypy in
pyproject.toml; enablestrictper module rather than globally to avoid being overwhelmed. - For runtime enforcement, reach for Pydantic (data models) or
beartype(function call validation).