DEV Community

Kai Thorne
Kai Thorne

Posted on

5 Python Type Hint Patterns That Finally Made My Code Click

I've been writing Python for about 5 years now. And for the first 3 of those, my type hints looked like this:

def process_user(user: dict) -> list:
    return user["items"]
Enter fullscreen mode Exit fullscreen mode

Technically correct. Uselessly vague. I knew typing existed but every time I tried to dive in I'd hit a wall of confusing generics and give up.

Then last year I inherited a Django codebase with 50,000 lines of untyped Python and spent two weeks debugging a KeyError that static analysis would have caught instantly. That was the turning point.

Here are the 5 patterns that finally made type hints click for me — not the academic version, but the stuff you actually use day-to-day.


1. TypedDict — Stop Passing Around Anonymous Dicts

This is the one I wish I'd learned first. So much Python code passes dictionaries around with no indication of what keys they contain.

Before:

def create_user(name: str, age: int) -> dict:
    return {"name": name, "age": age, "active": True}

# Somewhere else, 200 lines later:
user = create_user("Alice", 30)
print(user["nam"])  # Runtime KeyError. Oops.
Enter fullscreen mode Exit fullscreen mode

After:

from typing import TypedDict

class UserDict(TypedDict):
    name: str
    age: int
    active: bool

def create_user(name: str, age: int) -> UserDict:
    return {"name": name, "age": age, "active": True}

user = create_user("Alice", 30)
print(user["nam"])  # MyPy/Pyright catches this instantly.
Enter fullscreen mode Exit fullscreen mode

The beauty of TypedDict is that it costs nothing at runtime — it's pure type metadata. Your code runs exactly the same. But your editor now knows exactly what keys exist and what types they hold.

I now use these for any function that returns a dictionary with a fixed shape, especially JSON responses from APIs.


2. Literal — When a String Can Only Be One of a Few Things

How many times have you written something like this?

def set_status(user_id: int, status: str):
    ...
Enter fullscreen mode Exit fullscreen mode

And then spent 10 minutes figuring out why nothing works, only to find someone passed "activ" instead of "active"?

Literal fixes this:

from typing import Literal

Status = Literal["active", "inactive", "suspended"]

def set_status(user_id: int, status: Status) -> None:
    ...
Enter fullscreen mode Exit fullscreen mode

Now your editor autocompletes the valid values, and passing "activ" is a type error. I use Literal everywhere I have a set of string constants — config modes, API response statuses, sort orders.

Pro tip: Combine it with TypeVar and you get some really expressive APIs:

from typing import Literal, TypeVar

T = TypeVar("T")

def first_or_default(items: list[T], mode: Literal["strict", "lenient"] = "strict") -> T | None:
    if not items and mode == "strict":
        raise ValueError("Empty list not allowed")
    return items[0] if items else None
Enter fullscreen mode Exit fullscreen mode

3. Union with | Syntax (Python 3.10+) — Readability Matters

If you're still writing Optional[str] and Union[int, float], your code probably looks noisier than it needs to be. Since Python 3.10, you can use |:

Old way:

from typing import Optional, Union

def parse_config(path: str) -> Optional[dict]:
    ...

def calculate_total(prices: list[Union[int, float]]) -> Union[int, float]:
    ...
Enter fullscreen mode Exit fullscreen mode

New way (Python 3.10+):

def parse_config(path: str) -> dict | None:
    ...

def calculate_total(prices: list[int | float]) -> int | float:
    ...
Enter fullscreen mode Exit fullscreen mode

It's a small change, but I found it made type annotations much less intimidating for junior devs on my team. When type hints look less like angle-bracket soup, people actually read them.

One gotcha though: None | SomeType and Optional[SomeType] are equivalent, but Optional[SomeType] implies "this could be None" more explicitly. I still use Optional when None is the "something went wrong" case and | None when it's just a valid possible value. Pick what makes intent clearer.


4. Protocol — Duck Typing for Type Hints

This one blew my mind when I understood it. Before Protocol, if you wanted to define an interface in Python you had to either use ABC (abstract base classes) with all the inheritance ceremony, or just… not type-check at all.

Protocol lets you define what an object can do rather than what it is:

from typing import Protocol

class Logger(Protocol):
    def log(self, message: str) -> None: ...

class FileLogger:
    def log(self, message: str) -> None:
        with open("app.log", "a") as f:
            f.write(message + "\n")

class PrintLogger:
    def log(self, message: str) -> None:
        print(f"[LOG] {message}")

def process_data(data: list[str], logger: Logger) -> None:
    for item in data:
        logger.log(f"Processing: {item}")

# Both work — no inheritance needed!
process_data(["a", "b"], FileLogger())
process_data(["c", "d"], PrintLogger())
Enter fullscreen mode Exit fullscreen mode

This is the Pythonic way. No LoggerBase, no ABC.register(), no metaclass hacks. If it walks like a duck and has a .log() method that takes a string, it is a logger.

I switched most of my internal APIs from ABCs to Protocols last year and the code is way easier to test and extend. You don't need to import base classes everywhere — you just need to match the shape.


5. TypeVar with Constraints — Generics You'll Actually Use

Generics in Python look scary. TypeVar, Generic, covariance contravariance — it feels like a Haskell lecture. But the most useful pattern is dead simple:

from typing import TypeVar, Sequence

T = TypeVar("T")

def first(items: Sequence[T]) -> T | None:
    return items[0] if items else None

# Now this is type-safe:
result = first([1, 2, 3])       # inferred type: int | None
result = first(["a", "b"])      # inferred type: str | None
result = first([])              # inferred type: T | None (unknown)
Enter fullscreen mode Exit fullscreen mode

Without TypeVar, returning the first element of a generic sequence would force you to use Any and lose all type information downstream. With it, your function preserves the element type.

The constrained version is even more practical:

from typing import TypeVar

Numeric = TypeVar("Numeric", int, float, complex)

def double(value: Numeric) -> Numeric:
    return value * 2
Enter fullscreen mode Exit fullscreen mode

Now double works with int, float, and complex, but rejects strings or lists at type-check time. I use this pattern for math-heavy code where I want to avoid accidental string concatenation.


The One Thing I'd Tell My Younger Self

You don't need to type-hint every single line. I started by adding types only to function signatures (parameters and return values) and leaving internal variables alone. That alone caught about 80% of the bugs static typing can find.

Once the signatures were typed, I gradually started adding types to data structures — and that's when TypedDict and Protocol became essential.

The return on investment for type hints is backend: a few seconds of annotation saves you hours of debugging. I learned this the hard way by breaking production with a typo in a dictionary key. Don't be me — try TypedDict this week. You'll thank yourself next time you refactor.


I write about Python, backend architecture, and the mistakes I make so you don't have to. If this was useful, I have a few more posts in the pipeline — follow me to catch them when they drop.

Top comments (0)