DEV Community

aykhlf yassir
aykhlf yassir

Posted on

Python Internals: Type Hints

From dynamic chaos to static certainty: mastering MyPy and strict mode


The "I Forgot What This Returns" Problem

You're six months into a project. You need to call a function someone else wrote:

def process(data):
    # ... 50 lines of code ...
    return result
Enter fullscreen mode Exit fullscreen mode

What does data need to be? A list? A dict? A pandas DataFrame? A custom class?

What does result actually contain? A boolean? A tuple? None?

You have three options:

  1. Read all 50 lines of implementation code
  2. Find example usage somewhere else in the codebase
  3. Run it and hope for the best

All three are terrible.

The Solution

def process(data: pd.DataFrame) -> tuple[bool, str]:
    # ... 50 lines of code ...
    return result
Enter fullscreen mode Exit fullscreen mode

Now you know instantly:

  • Input: pandas DataFrame
  • Output: tuple of (bool, str)

No runtime cost. No reading code.

The Misconception

Many developers think type hints are for Python—that they change how the code runs. They don't.

def add(a: int, b: int) -> int:
    return a + b

print(add("hello", "world"))  # "helloworld" - Python doesn't care!
Enter fullscreen mode Exit fullscreen mode

Python completely ignores type hints at runtime. They're not constraints; they're documentation with a superpower: they can be automatically verified.

Type hints are for:

  1. Your IDE - Autocomplete and error detection
  2. MyPy - Static type checker that finds bugs before runtime
  3. Other developers - Understanding your code without reading it
  4. Future you - Remembering what you meant six months ago

The Modern Syntax (Python 3.10+)

If you learned Python type hints before 2021, forget what you know. The syntax changed dramatically in Python 3.9 and 3.10.

The Old Way (Verbose and Ugly)

from typing import List, Dict, Union, Optional, Tuple

def get_user(user_id: Union[int, None]) -> Dict[str, Union[str, int]]:
    pass

def process_items(items: Optional[List[str]]) -> Tuple[bool, str]:
    pass
Enter fullscreen mode Exit fullscreen mode

This required importing half of the typing module and using capital letters for built-in types (List instead of list).

The New Way (Clean and Native)

def get_user(user_id: int | None) -> dict[str, str | int]:
    pass

def process_items(items: list[str] | None) -> tuple[bool, str]:
    pass
Enter fullscreen mode Exit fullscreen mode

What changed:

  1. Union syntax: Union[int, None]int | None
  2. Optional is dead: Optional[str]str | None
  3. Lowercase collections: List[int]list[int], Dict[str, int]dict[str, int]
  4. Tuple syntax: Tuple[bool, str]tuple[bool, str]

The Compatibility Timeline

  • Python 3.9: Can use list[int], dict[str, int] but not | for unions
  • Python 3.10+: Can use | for unions, making Union and Optional obsolete
  • Python 3.12+: New generic syntax for classes (we'll cover this)

If you're on Python 3.10+, never import Union or Optional again.

Generic Collections: The Cheat Sheet

# Lists - homogeneous collection
numbers: list[int] = [1, 2, 3]
names: list[str] = ["Alice", "Bob"]
mixed: list[int | str] = [1, "two", 3]

# Dictionaries - key and value types
scores: dict[str, int] = {"Alice": 100, "Bob": 95}
config: dict[str, str | int | bool] = {"debug": True, "port": 8080}

# Tuples - fixed length and types
point: tuple[float, float] = (1.0, 2.0)
user: tuple[int, str, bool] = (1, "Alice", True)

# Tuples - variable length, same type
coordinates: tuple[float, ...] = (1.0, 2.0, 3.0, 4.0)  # Any number of floats

# Sets
unique_ids: set[int] = {1, 2, 3}
Enter fullscreen mode Exit fullscreen mode

The ellipsis (...) in tuple types means "zero or more elements of this type":

  • tuple[int, int] - exactly 2 integers
  • tuple[int, ...] - any number of integers

The "Chicken and Egg" Problem: Forward References

Try to write a method that returns the same type as the class:

class Money:
    def __init__(self, amount: float, currency: str):
        self.amount = amount
        self.currency = currency

    def __add__(self, other: Money) -> Money:  # NameError!
        return Money(self.amount + other.amount, self.currency)
Enter fullscreen mode Exit fullscreen mode

Boom. NameError: name 'Money' is not defined.

Why? Because when Python is executing the class body, the class doesn't exist yet. It's being defined. So other: Money tries to reference a name that doesn't exist.

The Old Fix (Quotes)

The traditional workaround was to use strings:

class Money:
    def __add__(self, other: "Money") -> "Money":  # Works!
        return Money(self.amount + other.amount, self.currency)
Enter fullscreen mode Exit fullscreen mode

Strings aren't evaluated at import time they're just stored. MyPy evaluates them later when the class exists.

But this is ugly and error-prone (typos don't cause errors until type checking).

The New Fix (Future Annotations)

Add one line at the top of your file:

from __future__ import annotations

class Money:
    def __init__(self, amount: float, currency: str):
        self.amount = amount
        self.currency = currency

    def __add__(self, other: Money) -> Money:  # Works!
        return Money(self.amount + other.amount, self.currency)
Enter fullscreen mode Exit fullscreen mode

This directive tells Python: "Treat all type annotations as strings automatically."

Under the hood, Python stores Money as the string "Money", but you write it without quotes. MyPy evaluates it later when type checking.

Why This Matters

Without this, you'd need quotes everywhere:

class TreeNode:
    def __init__(self, value: int):
        self.value = value
        self.left: "TreeNode | None" = None
        self.right: "TreeNode | None" = None

    def add_child(self, node: "TreeNode") -> None:
        pass
Enter fullscreen mode Exit fullscreen mode

With from __future__ import annotations:

from __future__ import annotations

class TreeNode:
    def __init__(self, value: int):
        self.value = value
        self.left: TreeNode | None = None
        self.right: TreeNode | None = None

    def add_child(self, node: TreeNode) -> None:
        pass
Enter fullscreen mode Exit fullscreen mode

Always use this import in every file with type hints. It will become the default behavior in Python 4.0.


The Any vs object Distinction

When you want to accept "anything," you have two options with very different meanings.

object - The Safe Universal Type

def print_anything(value: object) -> None:
    print(value)

print_anything(42)        # OK
print_anything("hello")   # OK
print_anything([1, 2, 3]) # OK
Enter fullscreen mode Exit fullscreen mode

When you type something as object, you're saying:

  • "I accept any type"
  • "But I can only do things that work on ANY object"

What can you do with an object?

def process(value: object) -> None:
    print(value)           # OK - all objects can be printed
    str(value)            # OK - all objects can be converted to string
    value == 5            # OK - all objects support equality

    # These are errors:
    value + 5             # Error! Not all objects support addition
    value.some_method()   # Error! Not all objects have this method
    value[0]              # Error! Not all objects are indexable
Enter fullscreen mode Exit fullscreen mode

MyPy knows that object only supports the universal protocol: __str__, __repr__, __eq__, etc.

Any - The "Turn Off Type Checking" Escape Hatch

from typing import Any

def process(value: Any) -> None:
    print(value)           # OK
    value + 5             # OK - MyPy assumes you know what you're doing
    value.some_method()   # OK - MyPy trusts you
    value[0]              # OK - MyPy allows everything
Enter fullscreen mode Exit fullscreen mode

Any tells MyPy: "Don't check this variable. I know what I'm doing."

It's dangerous because it disables type safety:

def broken(x: Any) -> int:
    return x + 5  # MyPy says this is fine

broken("hello")  # Runtime error: can't add string and int
Enter fullscreen mode Exit fullscreen mode

When to Use Each

Use object when:

  • You truly accept any type
  • You only use universal operations
  • Example: __eq__ methods that compare with anything
def __eq__(self, other: object) -> bool:
    if not isinstance(other, Money):
        return NotImplemented
    return self.amount == other.amount
Enter fullscreen mode Exit fullscreen mode

Use Any when:

  • You're working with truly dynamic data (JSON from API)
  • You're gradually typing legacy code
  • You're interfacing with untyped libraries
  • You've exhausted other options
import json

def load_config(path: str) -> Any:  # JSON can be anything
    with open(path) as f:
        return json.load(f)
Enter fullscreen mode Exit fullscreen mode

The Gradual Typing Strategy

When retrofitting types to legacy code:

# Stage 1: Everything is Any
def process_data(data: Any) -> Any:
    result = transform(data)
    return result

# Stage 2: Narrow the outputs
def process_data(data: Any) -> dict[str, int]:
    result = transform(data)
    return result

# Stage 3: Narrow the inputs
def process_data(data: list[str]) -> dict[str, int]:
    result = transform(data)
    return result

# Stage 4: No more Any!
Enter fullscreen mode Exit fullscreen mode

Start with Any everywhere, then gradually replace it with specific types as you understand the code.


MyPy Configuration

Type hints are useless unless you enforce them. That's where MyPy comes in, a static type checker that analyzes your code without running it.

Installing MyPy

pip install mypy
Enter fullscreen mode Exit fullscreen mode

Basic Usage

# Check a single file
mypy my_script.py

# Check a directory
mypy src/

# Check and show error codes
mypy --show-error-codes .
Enter fullscreen mode Exit fullscreen mode

The Configuration File

Create a pyproject.toml file (or mypy.ini) in your project root:

[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true

# Strict mode - the final boss
strict = true
Enter fullscreen mode Exit fullscreen mode

Let's break down what these do:

disallow_untyped_defs = true - Every function MUST have type hints:

# Error: Function is missing a type annotation
def process(data):
    return data.upper()

# OK
def process(data: str) -> str:
    return data.upper()
Enter fullscreen mode Exit fullscreen mode

warn_return_any = true - Functions can't return Any:

import json

# Error: Returning Any from function declared to return int
def load_number() -> int:
    return json.load(open("config.json"))["number"]  # json.load returns Any

# OK
def load_number() -> int:
    value: Any = json.load(open("config.json"))["number"]
    return int(value)  # Explicit conversion
Enter fullscreen mode Exit fullscreen mode

strict = true - Enables ALL strict checks:

# Error: Need to handle None case
def get_first(items: list[str]) -> str:
    return items[0]  # What if items is empty?

# OK
def get_first(items: list[str]) -> str | None:
    return items[0] if items else None
Enter fullscreen mode Exit fullscreen mode

The NotImplemented Problem

Remember that arithmetic operations should return NotImplemented, not raise exceptions? MyPy doesn't like this:

def __add__(self, other: Money) -> Money:
    if isinstance(other, Money):
        return Money(self.amount + other.amount, self.currency)
    return NotImplemented  # Error: Incompatible return type!
Enter fullscreen mode Exit fullscreen mode

MyPy says: "You declared this returns Money, but you're returning NotImplemented!"

The fix is to be explicit about both return types:

from typing import NotImplementedType

def __add__(self, other: object) -> Money | NotImplementedType:
    if isinstance(other, Money):
        return Money(self.amount + other.amount, self.currency)
    return NotImplemented
Enter fullscreen mode Exit fullscreen mode

Note we also changed other: Money to other: object—we accept any type and check it at runtime.

Handling Third-Party Libraries

Many packages don't have type stubs. MyPy will complain:

error: Skipping analyzing "requests": module is installed, but missing library stubs
Enter fullscreen mode Exit fullscreen mode

Solutions:

[tool.mypy]
# Option 1: Ignore missing imports globally
ignore_missing_imports = true

# Option 2: Configure per-package
[[tool.mypy.overrides]]
module = "requests.*"
ignore_missing_imports = true

# Option 3: Install type stubs
# pip install types-requests
Enter fullscreen mode Exit fullscreen mode

Top comments (0)