Skip to main content
Python for the JVM Engineer

Type Hints in Real Code

Ravinder··5 min read
PythonJVMJavatype hintsmypypyrightstatic analysis
Share:
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 int

Python 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" needed

This 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:

flowchart LR A["Python source\n+ type hints"] --> B["mypy\n(Python, Dropbox origin)"] A --> C["pyright\n(TypeScript/Rust, Microsoft)"] B --> D["type errors\n(slower, mature)"] C --> E["type errors\n(faster, strict, VSCode default)"]
# 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 = true

Gradual 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:

  1. Add mypy --ignore-missing-imports to CI (no failures on untyped code yet).
  2. Type new functions and modules as you write them.
  3. Enable --strict module by module using # mypy: strict or per-module overrides.
  4. Replace Any with 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 runtime

beartype 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 javac equivalent.
  • The | union syntax (str | None) replaces Optional[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; Any and # type: ignore are legitimate tools, not signs of failure.
  • Configure mypy in pyproject.toml; enable strict per module rather than globally to avoid being overwhelmed.
  • For runtime enforcement, reach for Pydantic (data models) or beartype (function call validation).
Share: