I'll be honest: when I first saw TypeVar, Protocol, and ParamSpec in a codebase, I thought someone was showing off. Why make Python look like TypeScript? Isn't the whole point of Python that you don't need this stuff?

Then mypy caught a bug that would have taken me hours to debug in production. Now I'm a convert.

This is what I wish someone had explained to me six months ago.

TypedDict: When Your Dictionaries Have Structure

I used to write code like this all the time:

def process_user(user: dict) -> str:
    return f"Hello, {user['name']}!"  # Hope 'name' exists!

The problem? dict tells you nothing about what's actually in the dictionary. You're flying blind.

Basic TypedDict

from typing import TypedDict
 
class User(TypedDict):
    name: str
    age: int
    email: str
 
def process_user(user: User) -> str:
    return f"Hello, {user['name']}!"  # mypy knows 'name' is a str
 
# This works
user: User = {"name": "Alice", "age": 28, "email": "alice@example.com"}
 
# mypy catches this - missing 'email'
bad_user: User = {"name": "Bob", "age": 25}  # Error!

What I learned: TypedDict is basically "struct for dictionaries." It gives you named fields with specific types.

Optional Fields with total=False

Here's where I got confused. What if some fields are optional?

# All fields optional
class PartialUser(TypedDict, total=False):
    name: str
    age: int
    email: str
 
# Valid - no fields required
partial: PartialUser = {}
also_valid: PartialUser = {"name": "Charlie"}

But what if you want some required and some optional? I spent way too long figuring this out.

Mixing Required and NotRequired (Python 3.11+)

from typing import TypedDict, Required, NotRequired
 
class User(TypedDict, total=False):
    # Required even though total=False
    name: Required[str]
    id: Required[int]
    
    # Optional
    email: NotRequired[str]
    age: NotRequired[int]
 
# Must have name and id
user: User = {"name": "Dana", "id": 1}  # OK
user_with_email: User = {"name": "Eve", "id": 2, "email": "eve@example.com"}  # OK
 
# Error - missing required 'id'
bad: User = {"name": "Frank"}  # mypy error!

Inheritance Pattern I Actually Use

class BaseConfig(TypedDict):
    debug: bool
    log_level: str
 
class DatabaseConfig(BaseConfig):
    host: str
    port: int
    database: str
 
class FullConfig(DatabaseConfig, total=False):
    timeout: int  # Optional
    max_connections: int  # Optional
 
config: FullConfig = {
    "debug": True,
    "log_level": "INFO",
    "host": "localhost",
    "port": 5432,
    "database": "myapp",
    # timeout and max_connections are optional
}

What I learned: Use TypedDict for API responses, config files, and anywhere you're dealing with JSON-like data. It's way better than dict[str, Any].

Protocol: Duck Typing With Type Safety

This one took me the longest to understand. I kept thinking "why not just use an ABC?"

The Problem with ABCs

from abc import ABC, abstractmethod
 
class Drawable(ABC):
    @abstractmethod
    def draw(self) -> None: ...
 
# You MUST inherit from Drawable
class Circle(Drawable):
    def draw(self) -> None:
        print("Drawing circle")

But what if you're using a third-party class that has a draw() method but doesn't inherit from your ABC? You're stuck.

Protocol to the Rescue

from typing import Protocol
 
class Drawable(Protocol):
    def draw(self) -> None: ...
 
# No inheritance needed!
class Circle:
    def draw(self) -> None:
        print("Drawing circle")
 
class ThirdPartyWidget:
    """Imagine this comes from a library you can't modify."""
    def draw(self) -> None:
        print("Drawing widget")
 
def render(shape: Drawable) -> None:
    shape.draw()
 
# Both work - they have draw()
render(Circle())          # OK
render(ThirdPartyWidget())  # OK - even without inheriting from Drawable!

The lightbulb moment: Protocol says "I don't care what you are, just that you have these methods." It's structural subtyping - if it walks like a duck and quacks like a duck, it's a duck.

Protocols with Properties

from typing import Protocol
 
class Named(Protocol):
    @property
    def name(self) -> str: ...
 
class User:
    def __init__(self, name: str):
        self._name = name
    
    @property
    def name(self) -> str:
        return self._name
 
class Product:
    name: str  # This also satisfies the protocol!
    
    def __init__(self, name: str):
        self.name = name
 
def greet(obj: Named) -> str:
    return f"Hello, {obj.name}!"
 
greet(User("Alice"))     # OK
greet(Product("Widget"))  # OK

Runtime Checking with @runtime_checkable

from typing import Protocol, runtime_checkable
 
@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...
 
class Connection:
    def close(self) -> None:
        print("Closing")
 
conn = Connection()
print(isinstance(conn, Closeable))  # True!
 
# Without @runtime_checkable, isinstance raises TypeError

Warning I wish I'd known: @runtime_checkable only checks method names, not signatures. A class with def close(self, force: bool) would still pass the check.

Practical Pattern: Plugin Systems

from typing import Protocol, runtime_checkable
 
@runtime_checkable
class Plugin(Protocol):
    name: str
    
    def initialize(self) -> None: ...
    def process(self, data: dict) -> dict: ...
 
class LoggingPlugin:
    name = "logging"
    
    def initialize(self) -> None:
        print("Logging plugin initialized")
    
    def process(self, data: dict) -> dict:
        print(f"Processing: {data}")
        return data
 
class ValidationPlugin:
    name = "validation"
    
    def initialize(self) -> None:
        print("Validation plugin initialized")
    
    def process(self, data: dict) -> dict:
        if "id" not in data:
            raise ValueError("Missing id")
        return data
 
def load_plugins(plugins: list[Plugin]) -> None:
    for plugin in plugins:
        plugin.initialize()
        print(f"Loaded: {plugin.name}")
 
# Works with any class that matches the Protocol
load_plugins([LoggingPlugin(), ValidationPlugin()])

Generics: Making Your Code Reusable

I was confused when I first saw TypeVar. Why not just use Any?

from typing import Any
 
def first(items: list[Any]) -> Any:
    return items[0]
 
result = first([1, 2, 3])
# result is Any - we lost the type information!

TypeVar Preserves Types

from typing import TypeVar
 
T = TypeVar('T')
 
def first(items: list[T]) -> T:
    return items[0]
 
result = first([1, 2, 3])  # result is int!
name = first(["alice", "bob"])  # name is str!

The key insight: T is like a variable for types. Whatever type goes in, the same type comes out.

Bounded TypeVars

What if you want to restrict what types are allowed?

from typing import TypeVar
 
# Only int or float allowed
Number = TypeVar('Number', int, float)
 
def double(x: Number) -> Number:
    return x * 2
 
double(5)      # OK, returns int
double(3.14)   # OK, returns float
double("hi")   # Error! str not allowed
 
# Using bound for "at least this type"
from typing import Sized
S = TypeVar('S', bound=Sized)
 
def print_length(item: S) -> S:
    print(len(item))
    return item
 
print_length([1, 2, 3])  # OK - list is Sized
print_length("hello")    # OK - str is Sized
print_length(42)         # Error - int isn't Sized

Generic Classes

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()
    
    def peek(self) -> T:
        return self._items[-1]
 
# Type is locked when you create the instance
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
int_stack.push("oops")  # mypy error!
 
str_stack: Stack[str] = Stack()
str_stack.push("hello")
value: str = str_stack.pop()  # mypy knows it's str

Generic Collections: Old vs New Syntax

This confused me for a while. Why do some codebases use List[int] and others use list[int]?

# Pre-Python 3.9 (requires imports)
from typing import List, Dict, Set, Tuple, Optional
 
def old_style(items: List[int]) -> Dict[str, int]:
    return {str(i): i for i in items}
 
# Python 3.9+ (built-in generics)
def new_style(items: list[int]) -> dict[str, int]:
    return {str(i): i for i in items}
 
# Python 3.10+ (union syntax)
def newest_style(value: int | None) -> str | int:
    return value if value else "default"

My rule: Use the built-in syntax (list[int]) unless you need to support Python 3.8 or earlier.

Practical Pattern: Factory Functions

from typing import TypeVar, Type
 
T = TypeVar('T')
 
def create_instance(cls: Type[T], **kwargs) -> T:
    """Factory that preserves the type of what it creates."""
    return cls(**kwargs)
 
class User:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age
 
class Product:
    def __init__(self, name: str, price: float):
        self.name = name
        self.price = price
 
# Type is preserved!
user = create_instance(User, name="Alice", age=30)  # user is User
product = create_instance(Product, name="Widget", price=9.99)  # product is Product

Generic Protocol (Mind-Blowing)

from typing import Protocol, TypeVar
 
T = TypeVar('T')
 
class Repository(Protocol[T]):
    def get(self, id: int) -> T | None: ...
    def save(self, entity: T) -> T: ...
    def delete(self, id: int) -> bool: ...
 
class UserRepository:
    def __init__(self):
        self._users: dict[int, User] = {}
    
    def get(self, id: int) -> User | None:
        return self._users.get(id)
    
    def save(self, entity: User) -> User:
        self._users[entity.id] = entity
        return entity
    
    def delete(self, id: int) -> bool:
        if id in self._users:
            del self._users[id]
            return True
        return False
 
def get_or_create(repo: Repository[T], id: int, default: T) -> T:
    existing = repo.get(id)
    return existing if existing else repo.save(default)

ParamSpec: The Decorator Typing Problem

This one is advanced, but once you need it, you really need it.

The Problem

from typing import Callable, TypeVar
 
R = TypeVar('R')
 
def log_call(fn: Callable[..., R]) -> Callable[..., R]:
    def wrapper(*args, **kwargs) -> R:
        print(f"Calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper
 
@log_call
def greet(name: str, excited: bool = False) -> str:
    return f"Hello, {name}{'!' if excited else '.'}"
 
# Problem: mypy doesn't know greet's signature anymore
greet("Alice", excited=True)  # mypy: "Any" arguments

ParamSpec to the Rescue

from typing import ParamSpec, TypeVar, Callable
 
P = ParamSpec('P')
R = TypeVar('R')
 
def log_call(fn: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper
 
@log_call
def greet(name: str, excited: bool = False) -> str:
    return f"Hello, {name}{'!' if excited else '.'}"
 
# Now mypy knows the exact signature!
greet("Alice", excited=True)  # OK
greet(123)  # Error: expected str
greet("Alice", wrong_param=True)  # Error: unexpected keyword argument

Decorators That Add Arguments

from typing import ParamSpec, TypeVar, Callable
from functools import wraps
 
P = ParamSpec('P')
R = TypeVar('R')
 
def retry(times: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(fn: Callable[P, R]) -> Callable[P, R]:
        @wraps(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last_error = None
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_error = e
            raise last_error  # type: ignore
        return wrapper
    return decorator
 
@retry(times=3)
def fetch_data(url: str, timeout: int = 30) -> dict:
    # Implementation
    return {}
 
# Type-safe!
fetch_data("https://api.example.com", timeout=10)

Type Narrowing: Teaching mypy What You Know

Sometimes you know more than mypy does. Here's how to tell it.

isinstance Narrowing

def process(value: str | int | list[str]) -> str:
    if isinstance(value, str):
        # mypy knows value is str here
        return value.upper()
    elif isinstance(value, int):
        # mypy knows value is int here
        return str(value * 2)
    else:
        # mypy knows value is list[str] here
        return ", ".join(value)

TypeGuard for Custom Checks

from typing import TypeGuard
 
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    """Returns True if all elements are strings."""
    return all(isinstance(x, str) for x in val)
 
def process_items(items: list[object]) -> str:
    if is_string_list(items):
        # mypy now knows items is list[str]
        return ", ".join(items)  # .join() works!
    return "Not all strings"
 
# Without TypeGuard, mypy wouldn't allow .join()

Practical TypeGuard Example

from typing import TypeGuard, TypedDict
 
class ValidUser(TypedDict):
    name: str
    email: str
    age: int
 
def is_valid_user(data: dict) -> TypeGuard[ValidUser]:
    return (
        isinstance(data.get("name"), str) and
        isinstance(data.get("email"), str) and
        isinstance(data.get("age"), int)
    )
 
def process_user_data(data: dict) -> str:
    if is_valid_user(data):
        # data is now ValidUser
        return f"User: {data['name']} ({data['email']})"
    raise ValueError("Invalid user data")

Common Mistakes (That I Made)

Mistake 1: Mutable Default Arguments in Generics

from typing import TypeVar
 
T = TypeVar('T')
 
# WRONG - shared mutable default
def append_to(item: T, target: list[T] = []) -> list[T]:
    target.append(item)
    return target
 
# RIGHT - use None and create new list
def append_to(item: T, target: list[T] | None = None) -> list[T]:
    if target is None:
        target = []
    target.append(item)
    return target

Mistake 2: Forgetting Covariance/Contravariance

from typing import TypeVar, Generic
 
# Invariant by default
T = TypeVar('T')
 
class Box(Generic[T]):
    def __init__(self, value: T):
        self.value = value
 
# This fails - Box[int] is not Box[object]
def print_box(box: Box[object]) -> None:
    print(box.value)
 
int_box: Box[int] = Box(42)
print_box(int_box)  # Type error!
 
# Fix with covariant TypeVar for read-only containers
T_co = TypeVar('T_co', covariant=True)
 
class ReadOnlyBox(Generic[T_co]):
    def __init__(self, value: T_co):
        self._value = value
    
    @property
    def value(self) -> T_co:
        return self._value
 
# Now this works
def print_readonly_box(box: ReadOnlyBox[object]) -> None:
    print(box.value)
 
readonly_int_box: ReadOnlyBox[int] = ReadOnlyBox(42)
print_readonly_box(readonly_int_box)  # OK!

Mistake 3: Using Type Instead of type

from typing import Type
 
# For class objects as values
def create(cls: Type[T]) -> T:  # Correct
    return cls()
 
# Python 3.9+: type works too
def create_new(cls: type[T]) -> T:  # Also correct
    return cls()

Mypy Integration Tips

# pyproject.toml
[tool.mypy]
strict = true
warn_return_any = true
warn_unused_ignores = true

Gradual Typing

If you're adding types to an existing codebase:

# pyproject.toml
[tool.mypy]
# Start lenient
disallow_untyped_defs = false
disallow_incomplete_defs = false
 
# Increase strictness over time
[[tool.mypy.overrides]]
module = "myapp.new_code.*"
disallow_untyped_defs = true

Useful Type Comments

# For variables mypy can't infer
data = json.loads(response)  # type: dict[str, Any]
 
# For ignoring specific errors
result = sketchy_function()  # type: ignore[no-untyped-call]

What I Learned

  1. TypedDict = structured dictionaries. Use for configs, API responses, JSON data.

  2. Protocol = duck typing with types. Use when you can't control inheritance.

  3. TypeVar = reusable type variables. Essential for generic functions and classes.

  4. ParamSpec = decorator typing. Preserves function signatures through wrappers.

  5. TypeGuard = custom type narrowing. Tell mypy what you know.

  6. Start gradually - you don't need to type everything at once.

The biggest lesson: types aren't about making Python feel like Java. They're documentation that tools can verify. When mypy catches a bug before it hits production, all the typing work pays for itself.


What typing patterns do you use? I'm still learning and would love to hear what works for others.

React to this post: