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")) # OKRuntime 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 TypeErrorWarning 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 SizedGeneric 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 strGeneric 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 ProductGeneric 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" argumentsParamSpec 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 argumentDecorators 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 targetMistake 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
Strict Mode (Recommended)
# pyproject.toml
[tool.mypy]
strict = true
warn_return_any = true
warn_unused_ignores = trueGradual 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 = trueUseful 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
-
TypedDict = structured dictionaries. Use for configs, API responses, JSON data.
-
Protocol = duck typing with types. Use when you can't control inheritance.
-
TypeVar = reusable type variables. Essential for generic functions and classes.
-
ParamSpec = decorator typing. Preserves function signatures through wrappers.
-
TypeGuard = custom type narrowing. Tell mypy what you know.
-
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.