Mastering Python Typing: Type Hints, Protocols, and Generics in Practice
by Gary Worthington, More Than Monkeys

Type annotations in Python have matured significantly, becoming essential tools for creating maintainable and readable code. In this post, I’ll explore how type hints, protocols, and generics can enhance your Python development workflow, with real-world examples and thoughtful considerations of their benefits and trade-offs.
What is a Type Hint?
A type hint in Python is a formal annotation that explicitly indicates the expected data type of variables, function parameters, and return values. Introduced in Python 3.5, type hints don’t enforce type checking at runtime but instead serve as valuable information for developers, linters, type checkers, and IDEs, helping to improve code clarity and reliability.
Example:
In the below example, the type hints tell the IDE (and you, the coder) that the add_numbers function expects two parameters, which are both of type int, and the return type should also be an int.
def add_numbers(a: int, b: int) -> int:
return a + b
result: int = add_numbers(2, 3)
Why Python Typing Matters
Typing provides clarity and precision, enabling developers to quickly understand how functions and classes interact, reducing bugs, and simplifying code refactoring.
Key Benefits:
- Improved Readability: Clearly communicate intent and usage of your code.
- Early Error Detection: Identify type-related issues before runtime.
- Enhanced Tooling: Enable more powerful IDE features like autocompletion and refactoring.
Using Type Annotations Within Classes
Type annotations are beneficial within class definitions, clearly indicating the expected types of class attributes and method parameters, enhancing clarity and reducing errors.
Example:
class Person:
name: str
age: int
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
def birthday(self) -> None:
self.age += 1
person = Person("Alice", 30)
person.birthday() # person.age is now 31
Tools Leveraging Python Type Hints
Several Python tools leverage type hints to enhance developer productivity and code reliability:
- mypy: A static type checker that helps identify type inconsistencies before runtime.
- Pyright: A fast, feature-rich type checker used widely in editors and IDEs for real-time feedback.
- PyCharm and VS Code: IDEs offering enhanced autocompletion, code navigation, and refactoring powered by type hints.

In the above example, vscode isclearly showing me that the second parameter of my Bobbins constructor does not accept a str.
Harnessing the Power of Protocols
Protocols define structural sub-typing in Python, allowing you to specify methods and properties that classes must implement without forcing explicit inheritance. Essentially, protocols describe interfaces that classes can fulfill implicitly, promoting flexible and interchangeable components.
Using protocols can often be cleaner and more flexible than regular class-based inheritance because they:
- Reduce Coupling: Classes do not need explicit inheritance, allowing greater flexibility.
- Simplify Codebase: Reduce the complexity associated with deeply nested inheritance hierarchies.
- Enhance Interchangeability: Enable easier swapping and substitution of components in your system.
But there is more:
- Duck Typing with Structure: Protocols promote duck typing in a type-safe way. If an object implements the required methods and properties, it qualifies; no explicit inheritance required.
- Testing and Mocking: Protocols are particularly useful in unit tests where you want to define the expected shape of an object without introducing concrete dependencies.
- Plug-in Architectures: They are ideal in plugin systems or extensible codebases, where different implementations must adhere to a contract without being tightly coupled.
Practical Example:
from typing import Protocol
class Serializable(Protocol):
def serialize(self) -> str:
# do some serializing here
class User:
def __init__(self, name: str):
self.name = name
def serialize(self) -> str:
return f"User: {self.name}"
class Product(Serializable):
def __init__(self, product_name: str):
self.product_name = product_name
def serialize(self) -> str:
return f"Product: {self.product_name}"
def save_to_db(obj: Serializable):
serialized = obj.serialize()
# Save to database logic here
user = User("Bob")
product = Product("Widget")
save_to_db(user) # User implicitly satisfies Serializable protocol
save_to_db(product) # Product explicitly inherits Serializable protocol
And what happens when the class doesn’t implement the required method from the Protocol I hear you say?
class Incomplete:
def __init__(self, value: int):
self.value = value
# This will raise a type-checking error with mypy or Pyright
# because 'serialize' is not defined on Incomplete
save_to_db(Incomplete(42))
Static type checkers will flag this with an error like:
Argument 1 to "save_to_db" has incompatible type "Incomplete"; expected "Serializable"
While static type checkers like mypy or Pyright will warn you when a class doesn’t meet a protocol’s requirements, Python won’t enforce this at runtime unless you explicitly check it. This means if a method like serialize() is missing and still called, you'll get a standard AttributeError:
save_to_db(Incomplete(42)) # This will raise an AttributeError at runtime
Output:
AttributeError: 'Incomplete' object has no attribute 'serialize'
So while Protocols guide developers and tools during development, they don’t prevent runtime issues on their own. If you’re relying heavily on Protocols and want runtime enforcement, consider pairing them with explicit checks using hasattr or runtime type enforcement strategies.
Mastering Generics for Flexibility
Generics allow you to create functions and classes that can operate on various data types while preserving type relationships and enabling meaningful type inference across your codebase. Unlike using Any, which removes type safety, generics let you describe how multiple inputs and outputs relate to each other.
At their core, generics are about parameterising types using TypeVar. You define one or more placeholder types, and then reuse them in function signatures or class definitions to maintain consistency.
For example, if a function takes in a list of T and returns a single T, you’re declaring a contract that ties the input type to the output.
They are especially useful when:
- Writing utility functions that can work across types without sacrificing type safety.
- Designing container-like classes (e.g. cache, repositories, queues).
- Enabling automatic type inference and better IDE feedback.
- Creating transformation pipelines or adapters where input and output types vary.
Deeper Benefits:
- Reusable APIs: Generics allow you to write reusable, type-safe APIs. They prevent duplication of logic across types and help maintain consistency.
- Stronger Contracts: By explicitly declaring a type variable, you’re enforcing a contract on how that type is used within your function or class, reducing misuse.
- Interoperability: Generics improve the interaction with data structures like lists, sets, and dictionaries by retaining type information throughout the application lifecycle.
Real-world Example:
Here’s an example showing how generics can be used to build a type-safe data transformation pipeline:
from typing import TypeVar, Generic, Callable, List
T = TypeVar('T')
U = TypeVar('U')
class Transformer(Generic[T, U]):
def __init__(self, func: Callable[[T], U]) -> None:
self.func = func
def transform_all(self, items: List[T]) -> List[U]:
return [self.func(item) for item in items]
# Example 1: Transform integers to their string representations
int_to_str = Transformer[int, str](lambda x: f"Item-{x}")
result = int_to_str.transform_all([1, 2, 3])
print(result) # Output: ['Item-1', 'Item-2', 'Item-3']
# Example 2: Transform strings to their lengths
str_to_len = Transformer[str, int](lambda s: len(s))
lengths = str_to_len.transform_all(["apple", "banana", "kiwi"])
print(lengths) # Output: [5, 6, 4]
More Advanced Uses of Generics
Generics go beyond collections and transformation pipelines; they’re powerful tools when combined with other typing features like TypedDict, NewType, and even runtime behaviour.
1. Nested Generics and Composition
You can compose generics with other generic types, creating deeply structured types while retaining accuracy:
from typing import List, Dict, TypeVar
T = TypeVar('T')
def group_by_first_letter(items: List[T], key_func) -> Dict[str, List[T]]:
result: Dict[str, List[T]] = {}
for item in items:
key = key_func(item)[0].upper()
result.setdefault(key, []).append(item)
return result
words = ["apple", "banana", "cherry", "avocado"]
print(group_by_first_letter(words, lambda x: x))
This uses a Dict[str, List[T]], preserving the relationship between key and grouped values.
2. Generics in Custom Collections or Services
Generic services and managers are common in frameworks. For example, a cache service:
from typing import Dict, TypeVar, Generic
T = TypeVar('T')
class Cache(Generic[T]):
def __init__(self):
self._store: Dict[str, T] = {}
def set(self, key: str, value: T) -> None:
self._store[key] = value
def get(self, key: str) -> T:
return self._store[key]
cache = Cache[int]()
cache.set("user_count", 42)
print(cache.get("user_count")) # 42
Type-safe caches like this let you confidently reuse the same pattern for multiple types.
3. Generic Factories and Inference
Generics can support dynamic creation patterns while maintaining inference:
from typing import Type, TypeVar
T = TypeVar('T')
def create_instance(cls: Type[T]) -> T:
return cls()
class Foo:
def __init__(self):
self.value = 123
foo = create_instance(Foo)
print(foo.value) # 123
This pattern is useful in frameworks, factories, and dependency injection setups.
Conclusion
Mastering Python typing through effective use of type hints, protocols, and generics significantly improves code quality, readability, and maintainability. By thoughtfully applying these practices and leveraging typing tools, you’ll foster a cleaner, more robust Python codebase.
A message from our Founder
Hey, Sunil here. I wanted to take a moment to thank you for reading until the end and for being a part of this community.
Did you know that our team run these publications as a volunteer effort to over 200k supporters? We do not get paid by Medium!
If you want to show some love, please take a moment to follow me on LinkedIn, TikTok and Instagram. And before you go, don’t forget to clap and follow the writer️!
Mastering Python Typing: Type Hints, Protocols, and Generics in Practice was originally published in Python in Plain English on Medium, where people are continuing the conversation by highlighting and responding to this story.