As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
When I first started writing Python code, I prided myself on being able to keep the whole program in my head. It was a small script, a personal tool. Then, the project grew. Files multiplied. Other people needed to read and change my code. That’s when I realized my memory wasn't a reliable documentation system. A variable called data could be a list, a dictionary, or a string from one function to the next. Tracking it became a puzzle.
This is where type hints come in. They are not for the computer; Python will run just fine without them. They are for you, the future you, and your teammates. They are notes you leave in the code that say, "This is what I expect here." Over time, I've learned these notes can be simple or incredibly precise, turning guesswork into clear contracts. Let me show you some of the most effective ways I use them.
A function is a promise. It says, "Give me X and Y, and I'll give you Z back." Type hints make that promise explicit. Before, you had to read the function's code or hope the docstring was up-to-date to understand that promise. Now, you can see it at a glance.
from typing import List, Dict, Optional, Union
def calculate_stats(
readings: List[float],
adjustments: Dict[str, Union[int, float]],
smoothing_factor: Optional[float] = None
) -> Dict[str, float]:
"""
Calculates statistics from sensor readings.
"""
results = {"average": sum(readings) / len(readings)}
# ... more calculations using adjustments and smoothing_factor
return results
# I know exactly what to give it
stats = calculate_stats([22.5, 23.1, 21.8], {"offset": 2})
print(stats)
The moment I started adding these, my editor began helping me. It would draw a wavy line under my code if I tried to pass a list of strings where floats were expected. It caught mistakes before I even ran the program. The Optional part, marked by = None, tells me that smoothing_factor is something I can skip. Union lets me say "this can be one of these types," which is common when dealing with configuration.
What if you write a function or a class that should work with many different types, but in a consistent way? You don't want to duplicate code. This is where generics are useful. They let you write code once but keep the type safety for each use.
Think of a box. You can put a book in it, or a cup. The box's job is the same—to hold something—but what it holds changes. A generic class lets you define that box.
from typing import TypeVar, Generic
# T is a placeholder for a type. It's like saying "some type T".
T = TypeVar('T')
class StorageBox(Generic[T]):
def __init__(self, item: T):
self.stored_item = item
def get_item(self) -> T:
return self.stored_item
def label(self) -> str:
# We can't assume everything has a .name, so we use the type.
return f"Box containing a {type(self.stored_item).__name__}"
# Now I can make boxes for specific types.
book_box: StorageBox[str] = StorageBox("Python Guide")
tool_box: StorageBox[int] = StorageBox(42)
# The type checker knows get_item() returns a string here.
book_title: str = book_box.get_item()
print(book_title) # "Python Guide"
print(tool_box.label()) # "Box containing a int"
I use this pattern for data containers, caches, or pipeline stages. The TypeVar creates a temporary type name. When you declare StorageBox[str], you tell the type checker to replace every T in the class with str. Now it knows get_item returns a string. It prevents me from accidentally treating a number like a string later on.
Sometimes, you care more about what an object can do than what it is. In Python, we often call this "duck typing": if it walks like a duck and quacks like a duck, it's a duck. Protocols let you bring this powerful, flexible idea into the world of static type checking.
Imagine you have a function that needs objects it can render. It doesn't care if it's a chart, a button, or a text block. It just needs a .render() method that returns a string.
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str:
... # The ellipsis means "any implementation is fine"
class Chart:
def __init__(self, data):
self.data = data
# It has the right method. That's all that matters for the protocol.
def render(self) -> str:
return f"Chart: {self.data}"
class Button:
def __init__(self, text):
self.text = text
def render(self) -> str:
return f"[{self.text}]"
def build_page(components: list[Renderable]) -> str:
return "\n".join(comp.render() for comp in components)
# Both Chart and Button satisfy the Renderable protocol.
# I haven't changed their code at all.
page = build_page([Chart([1,2,3]), Button("Click Me")])
print(page)
This changed how I design systems. I no longer force everything into a big inheritance tree just for type checking. I define a protocol for the capability I need—Saveable, Serializable, Readable—and any class that implements those methods will work. It keeps code loose and adaptable.
My code is full of conditions. "If this variable is not None, then do X." "If this is a list, process each item." Type narrowing is the type checker's ability to follow my logic and understand what type a variable must be inside a block of code.
This is a huge help for avoiding errors.
from typing import Optional
def format_username(user_id: Optional[str]) -> str:
if user_id is None:
# The checker knows here that user_id is None.
return "Guest"
else:
# The checker knows here that user_id is a *string*, not an optional.
# So it's safe to call .upper() on it.
return user_id.upper()
def process_response(data: dict) -> None:
# The .get() method returns an Optional type.
message = data.get("message")
if not message:
# This could mean message is None or an empty string.
print("No message found.")
return
# For a simple checker, 'not message' doesn't prove it's a string.
# Let's be more explicit.
if isinstance(message, str):
# Now the checker is sure. I can use string methods.
words = message.split()
print(f"Message has {len(words)} words.")
The key is using clear checks like is None, is not None, isinstance(), or issubclass(). They act as signposts for the type checker. It makes the analysis much smarter and catches bugs where you might try to access an attribute on a None value.
Often, a function argument can only be a few specific values. Think of HTTP methods like "GET" or "POST", or log levels like "DEBUG" or "ERROR". Using a plain string for this is error-prone. You might type "GETT" by mistake. Literal types let you lock down the allowed values.
from typing import Literal
# This defines a type that can *only* be these four strings.
HttpMethod = Literal["GET", "POST", "PUT", "DELETE"]
def api_call(url: str, method: HttpMethod = "GET"):
print(f"Making a {method} request to {url}")
# ... implementation
# These are valid and clear.
api_call("https://api.site.com/users", "POST")
api_call("https://api.site.com/users") # Uses default "GET"
# This would cause a type checking error, catching the typo early.
# api_call("https://api.site.com", "PAT")
# It also works well for configuration dictionaries.
ThemeName = Literal["light", "dark", "high-contrast"]
def apply_theme(theme: ThemeName) -> None:
print(f"Applying the {theme} theme.")
apply_theme("dark")
I use this for status codes, mode switches, and any predefined set of options. It's much lighter than creating an Enum class, but it gives you similar safety. Your editor can even autocomplete the allowed values for you.
Have you ever used a class where you call many methods in a chain, like query.filter().sort().limit()? This is called a fluent interface. The trick is that each method needs to return the instance itself so you can call the next method. But if you have inheritance, the type can get lost. The Self type fixes this.
from typing import Self
class QueryBuilder:
def __init__(self):
self._conditions = []
def where(self, condition: str) -> Self:
self._conditions.append(condition)
return self # Returns the same instance.
def limit(self, count: int) -> Self:
self._limit = count
return self
def build(self) -> str:
query = "SELECT * FROM data"
if self._conditions:
query += " WHERE " + " AND ".join(self._conditions)
if hasattr(self, '_limit'):
query += f" LIMIT {self._limit}"
return query
class EnhancedQueryBuilder(QueryBuilder):
def select(self, columns: list[str]) -> Self:
self._columns = columns
return self
# The type checker knows the full chain.
query = (
EnhancedQueryBuilder()
.select(["name", "age"])
.where("age > 21")
.limit(10)
.build()
)
print(query)
Using Self (or TypeVar bound to the class in older Python versions) tells the checker that where() returns an instance of the exact same class it was called on, not just the parent QueryBuilder. This means it knows that after calling .where(), I can still call the child class's .select() method. It makes building complex, step-by-step objects much safer.
This is a more advanced concept, but it solves a specific headache. What if you have a function that takes several arguments and returns them in a tuple, and you want the type of the tuple to match the types of the inputs? Or a decorator that should work on any function? Variadic generics handle "variable numbers of types."
In Python 3.11 and above, this is done with TypeVarTuple. It's like a TypeVar, but for a group of types.
# This requires Python 3.11+
from typing import TypeVarTuple, Callable
import functools
Ts = TypeVarTuple('Ts')
def pair_up(*args: *Ts) -> tuple[*Ts, *Ts]:
"""Takes arguments and returns them twice in a tuple."""
return (*args, *args)
# The magic: The type checker knows the exact output.
result = pair_up("hello", 42)
# The type of 'result' is tuple[str, int, str, int]
first_str, first_int, second_str, second_int = result
print(f"{first_str}, {second_int}")
# A more practical use: preserving types in a decorator.
def log_calls(func: Callable[[*Ts], int]) -> Callable[[*Ts], int]:
@functools.wraps(func)
def wrapper(*args: *Ts) -> int:
print(f"Calling {func.__name__} with {args}")
return func(*args)
return wrapper
@log_calls
def add(a: int, b: int) -> int:
return a + b
# The decorator doesn't break the type signature of 'add'.
sum = add(5, 3) # Type checker knows this returns an int.
While this syntax is new, it represents the frontier of type hint precision. It's useful for authors of libraries and frameworks where flexibility and type safety are both critical.
This last technique is about making distinctions that matter to humans but not to the computer. An int could be a user ID, a product ID, or a count of items. Mixing them up could cause subtle bugs. NewType lets you create distinct types that are based on another type but are treated as incompatible by the type checker.
from typing import NewType
# Defining new, distinct types.
UserId = NewType('UserId', int)
ProductId = NewType('ProductId', int)
EmailAddress = NewType('EmailAddress', str)
def get_user_profile(id: UserId) -> str:
return f"Fetching profile for user #{id}"
def get_product_info(id: ProductId) -> str:
return f"Fetching info for product #{id}"
# I have to explicitly create these types.
alice_id = UserId(1205)
mug_id = ProductId(4452)
# This works.
print(get_user_profile(alice_id))
# This causes a type error, preventing a serious mix-up.
# print(get_user_profile(mug_id)) # Error: ProductId given, UserId expected.
# At runtime, they are still just ints. No performance cost.
print(type(alice_id)) # <class 'int'>
I use this for units of measurement, database IDs, validated versus raw strings, and any place where a simple type is too broad. It adds a layer of logical safety that has saved me from more than one confusing bug. The runtime cost is zero, but the clarity gain is immense.
These eight techniques form a toolkit. You don't need to use them all at once. Start with basic function annotations. When you find yourself writing a flexible container, try a generic. When you have a set of allowed strings, use a literal.
The goal is not to write perfectly typed code on the first try. The goal is to add clarity and let the tools help you. Every time you add a hint, you are writing a note to your future self, explaining what you intended. Over time, these notes turn a confusing codebase into a well-documented conversation. The errors you catch early are just a welcome bonus.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)