Generics and TypeVar: Functions That Work Safely on Any Type
Master TypeVar and generic functions to write reusable code that works on any type while preserving type safety. Learn how generics are the contract templates of Python.
I just opened Amir's utils.py to look for that batch processing function he mentioned, and I see TypeVar at the top. Then TypeVar again three lines later. And again on line 47. I have no idea what I am looking at.
That is exactly where I expected you to be. Last week you fixed the pipeline — you saw concurrency in action. This week you are stepping back to read the type system that makes concurrency safe. TypeVar is fundamental. Once you understand it, you will see why Amir uses it everywhere.
I thought I knew what types were. str, int, list[str]. What does TypeVar add to that?
Imagine I handed you a blank contract template. "The Buyer (underscore underscore underscore) agrees to purchase from the Seller for $______. The Buyer (underscore underscore underscore) will pay within 30 days." The underscores are blanks. Everywhere it says "the Buyer" it uses the same blank. When you fill in the blanks — "Priya" everywhere — you have a specific contract. Fill in "Acme Corp" — different contract, same shape.
TypeVar is that blank. A placeholder type that stays consistent throughout a function. When you call the function with str, the blank is str everywhere. When you call it with Order, the blank is Order everywhere. Same function shape. Different concrete types.
But Python is dynamically typed. I can already write a function that works on any type. Why do I need a special TypeVar for that?
You can write a function that runs on any type. But you cannot tell a type checker — Mypy, Pyright — that it is intentional. Without TypeVar, the type checker looks at your function and thinks: "You took an argument, you didn't constrain its type, so I have to assume it could be anything. I have no idea what methods it has. If you call .upper() on it, I cannot verify that is safe."
With TypeVar, you are saying: "This function works on any single type, but that type is consistent throughout the call. If you pass a str, the return type is a string. If you pass a list, the return type is a list. The type checker can prove both are safe."
So it is a contract. Mypy can verify the contract is being followed.
Exactly. Here is the simplest possible example:
from typing import TypeVar
T = TypeVar('T')
def identity(x: T) -> T:
return x
T is a type variable. When you call identity(42), the type checker infers T is int, so the return type is int. When you call identity("hello"), T is str, so the return type is str. The same function handles both, but the type contract is explicit.
And Mypy can verify I am not doing something silly. Like if I wrote identity(x) and then tried to call x.upper(), Mypy would say: "I have no idea if x has an .upper() method. It could be an int." Unless the contract said T has to be a string.
Now you are thinking like a type designer. That is a bound. You can restrict which types T can be:
from typing import TypeVar
T = TypeVar('T', bound=str) # T can only be str or subclasses of str
def shout(x: T) -> T:
return x.upper() # Safe — T is guaranteed to have .upper()
If you call shout(42), Mypy rejects it: int is not a subclass of str. The function is generic, but within strict bounds.
Okay, so TypeVar by itself is generic — any type. And bound= restricts it. What if I want to say: "This can be either int or str, but nothing else?"
Constraints. Pass multiple types as positional arguments:
T = TypeVar('T', int, str) # T can be int OR str, not anything else
def double(x: T) -> T:
return x * 2
Call double(5) and you get 10 (int). Call double("hi") and you get "hihi" (str). Call double([1,2]) and Mypy rejects it: list is not in the constraints.
So TypeVar is like a multiple choice — unconstrained if you don't specify, constrained to a list, or bounded to a superclass. That is genuinely elegant.
It is. And now the practical part. Generic functions are most useful when you are working with collections. Here is the real-world pattern:
from typing import TypeVar, Callable
T = TypeVar('T')
def batch_filter(items: list[T], predicate: Callable[[T], bool]) -> list[T]:
"""Filter a list of any type using a predicate function.
Args:
items: A list of items of type T
predicate: A function that takes an item and returns True if it should be kept
Returns:
A list of the same type T, filtered by the predicate
"""
return [item for item in items if predicate(item)]
One function. Works on lists of anything. The type checker verifies the predicate function accepts the right type and returns a boolean. The return list has the same type as the input list.
So if I call batch_filter(orders, lambda o: o['status'] == 'paid'), the type checker knows:
itemsis alist[dict](or whatever typeordersis)- The predicate takes a dict and returns a bool
- The result is a
list[dict](same type as input)
Exactly. And if you call it with customers:
customers = [
{'id': 'C-001', 'name': 'Priya', 'premium': True},
{'id': 'C-002', 'name': 'Amir', 'premium': False},
]
filtered = batch_filter(customers, lambda c: c['premium'])
The type checker sees filtered is a list[dict], same type. The function is "generic" — it works on any type. But the type safety is preserved. Type checkers call this "parametric polymorphism."
So this is different from regular polymorphism where you have a base class and subclasses. This is a completely different concept.
Completely different. Regular polymorphism (inheritance) is about sharing behavior across different types. Generics (parametric polymorphism) is about writing one function that works on a family of types, but in a type-safe way. They solve different problems.
Amir's utils.py has batch functions for orders, customers, products, probably. They all use TypeVar to avoid code duplication and stay type-safe.
That is exactly why. One batch_filter. One batch_sort. One batch_map. They all use TypeVar. No code duplication. Type checkers verify every call. It is the right abstraction for data processing pipelines.
Here is a more realistic example with an order type:
from typing import TypeVar, Callable
from dataclasses import dataclass
T = TypeVar('T')
@dataclass
class Order:
id: str
customer: str
total: float
status: str
def batch_filter(items: list[T], predicate: Callable[[T], bool]) -> list[T]:
return [item for item in items if predicate(item)]
# Usage
orders = [
Order('ORD-001', 'Priya', 89.99, 'paid'),
Order('ORD-002', 'Amir', 124.50, 'pending'),
Order('ORD-003', 'Jen', 45.00, 'paid'),
]
paid_orders = batch_filter(orders, lambda o: o.status == 'paid')
# Type checker knows paid_orders is list[Order]
for order in paid_orders:
print(f'{order.id}: {order.total}') # Safe — order is Order, has these fields
So type checkers use TypeVar to track the flow of types through the function. Input is list[Order], so T becomes Order. The predicate takes Order, returns bool. The output is list[Order]. All verified.
All verified. And if you make a mistake:
result = batch_filter(orders, lambda o: "done") # Returns string, not bool
Mypy catches it: the lambda returns str, not bool. The contract is broken. You wrote the generic function correctly, but the caller violated the contract.
This is what Amir meant when he said the type system catches bugs before tests run. The contract is enforced automatically.
You are going to read his PRs very differently now. When you see TypeVar, you will see intent. The function is generic by design. It scales to any type, safely.
So what is the difference between TypeVar and Protocol? I keep seeing Protocol used too, and I don't understand when to use which.
That is the bridge to next lesson. TypeVar works on any type that fits the blank — any T. Protocol works on any type that acts like a certain interface — any type that "quacks like a duck." It is structural vs. generic.
Today: TypeVar is your blank contract template. Next lesson: Protocol is your shape-matching system. Together, they are the vocabulary of the type system. You will read Amir's code and finally understand what you are approving.
Practice your skills
Sign up to write and run code in this lesson.
Generics and TypeVar: Functions That Work Safely on Any Type
Master TypeVar and generic functions to write reusable code that works on any type while preserving type safety. Learn how generics are the contract templates of Python.
I just opened Amir's utils.py to look for that batch processing function he mentioned, and I see TypeVar at the top. Then TypeVar again three lines later. And again on line 47. I have no idea what I am looking at.
That is exactly where I expected you to be. Last week you fixed the pipeline — you saw concurrency in action. This week you are stepping back to read the type system that makes concurrency safe. TypeVar is fundamental. Once you understand it, you will see why Amir uses it everywhere.
I thought I knew what types were. str, int, list[str]. What does TypeVar add to that?
Imagine I handed you a blank contract template. "The Buyer (underscore underscore underscore) agrees to purchase from the Seller for $______. The Buyer (underscore underscore underscore) will pay within 30 days." The underscores are blanks. Everywhere it says "the Buyer" it uses the same blank. When you fill in the blanks — "Priya" everywhere — you have a specific contract. Fill in "Acme Corp" — different contract, same shape.
TypeVar is that blank. A placeholder type that stays consistent throughout a function. When you call the function with str, the blank is str everywhere. When you call it with Order, the blank is Order everywhere. Same function shape. Different concrete types.
But Python is dynamically typed. I can already write a function that works on any type. Why do I need a special TypeVar for that?
You can write a function that runs on any type. But you cannot tell a type checker — Mypy, Pyright — that it is intentional. Without TypeVar, the type checker looks at your function and thinks: "You took an argument, you didn't constrain its type, so I have to assume it could be anything. I have no idea what methods it has. If you call .upper() on it, I cannot verify that is safe."
With TypeVar, you are saying: "This function works on any single type, but that type is consistent throughout the call. If you pass a str, the return type is a string. If you pass a list, the return type is a list. The type checker can prove both are safe."
So it is a contract. Mypy can verify the contract is being followed.
Exactly. Here is the simplest possible example:
from typing import TypeVar
T = TypeVar('T')
def identity(x: T) -> T:
return x
T is a type variable. When you call identity(42), the type checker infers T is int, so the return type is int. When you call identity("hello"), T is str, so the return type is str. The same function handles both, but the type contract is explicit.
And Mypy can verify I am not doing something silly. Like if I wrote identity(x) and then tried to call x.upper(), Mypy would say: "I have no idea if x has an .upper() method. It could be an int." Unless the contract said T has to be a string.
Now you are thinking like a type designer. That is a bound. You can restrict which types T can be:
from typing import TypeVar
T = TypeVar('T', bound=str) # T can only be str or subclasses of str
def shout(x: T) -> T:
return x.upper() # Safe — T is guaranteed to have .upper()
If you call shout(42), Mypy rejects it: int is not a subclass of str. The function is generic, but within strict bounds.
Okay, so TypeVar by itself is generic — any type. And bound= restricts it. What if I want to say: "This can be either int or str, but nothing else?"
Constraints. Pass multiple types as positional arguments:
T = TypeVar('T', int, str) # T can be int OR str, not anything else
def double(x: T) -> T:
return x * 2
Call double(5) and you get 10 (int). Call double("hi") and you get "hihi" (str). Call double([1,2]) and Mypy rejects it: list is not in the constraints.
So TypeVar is like a multiple choice — unconstrained if you don't specify, constrained to a list, or bounded to a superclass. That is genuinely elegant.
It is. And now the practical part. Generic functions are most useful when you are working with collections. Here is the real-world pattern:
from typing import TypeVar, Callable
T = TypeVar('T')
def batch_filter(items: list[T], predicate: Callable[[T], bool]) -> list[T]:
"""Filter a list of any type using a predicate function.
Args:
items: A list of items of type T
predicate: A function that takes an item and returns True if it should be kept
Returns:
A list of the same type T, filtered by the predicate
"""
return [item for item in items if predicate(item)]
One function. Works on lists of anything. The type checker verifies the predicate function accepts the right type and returns a boolean. The return list has the same type as the input list.
So if I call batch_filter(orders, lambda o: o['status'] == 'paid'), the type checker knows:
itemsis alist[dict](or whatever typeordersis)- The predicate takes a dict and returns a bool
- The result is a
list[dict](same type as input)
Exactly. And if you call it with customers:
customers = [
{'id': 'C-001', 'name': 'Priya', 'premium': True},
{'id': 'C-002', 'name': 'Amir', 'premium': False},
]
filtered = batch_filter(customers, lambda c: c['premium'])
The type checker sees filtered is a list[dict], same type. The function is "generic" — it works on any type. But the type safety is preserved. Type checkers call this "parametric polymorphism."
So this is different from regular polymorphism where you have a base class and subclasses. This is a completely different concept.
Completely different. Regular polymorphism (inheritance) is about sharing behavior across different types. Generics (parametric polymorphism) is about writing one function that works on a family of types, but in a type-safe way. They solve different problems.
Amir's utils.py has batch functions for orders, customers, products, probably. They all use TypeVar to avoid code duplication and stay type-safe.
That is exactly why. One batch_filter. One batch_sort. One batch_map. They all use TypeVar. No code duplication. Type checkers verify every call. It is the right abstraction for data processing pipelines.
Here is a more realistic example with an order type:
from typing import TypeVar, Callable
from dataclasses import dataclass
T = TypeVar('T')
@dataclass
class Order:
id: str
customer: str
total: float
status: str
def batch_filter(items: list[T], predicate: Callable[[T], bool]) -> list[T]:
return [item for item in items if predicate(item)]
# Usage
orders = [
Order('ORD-001', 'Priya', 89.99, 'paid'),
Order('ORD-002', 'Amir', 124.50, 'pending'),
Order('ORD-003', 'Jen', 45.00, 'paid'),
]
paid_orders = batch_filter(orders, lambda o: o.status == 'paid')
# Type checker knows paid_orders is list[Order]
for order in paid_orders:
print(f'{order.id}: {order.total}') # Safe — order is Order, has these fields
So type checkers use TypeVar to track the flow of types through the function. Input is list[Order], so T becomes Order. The predicate takes Order, returns bool. The output is list[Order]. All verified.
All verified. And if you make a mistake:
result = batch_filter(orders, lambda o: "done") # Returns string, not bool
Mypy catches it: the lambda returns str, not bool. The contract is broken. You wrote the generic function correctly, but the caller violated the contract.
This is what Amir meant when he said the type system catches bugs before tests run. The contract is enforced automatically.
You are going to read his PRs very differently now. When you see TypeVar, you will see intent. The function is generic by design. It scales to any type, safely.
So what is the difference between TypeVar and Protocol? I keep seeing Protocol used too, and I don't understand when to use which.
That is the bridge to next lesson. TypeVar works on any type that fits the blank — any T. Protocol works on any type that acts like a certain interface — any type that "quacks like a duck." It is structural vs. generic.
Today: TypeVar is your blank contract template. Next lesson: Protocol is your shape-matching system. Together, they are the vocabulary of the type system. You will read Amir's code and finally understand what you are approving.