Learn Python Series):Python has always been dynamically typed. You can write x = 5 and then later write x = "hello" without Python complaining. The variable doesn't have a fixed type - it's just a name pointing to whatever value you assign.
This flexibility is powerful, but it comes with a cost. When you're reading code, you can't tell what type a variable holds just by looking at it. When you're writing code, your editor can't warn you if you're calling a method that doesn't exist. And when you're debugging, type-related bugs only show up when that specific line of code runs.
Nota bene: This episode is about type hints (also called type annotations), a feature introduced in Python 3.5 that lets you optionally declare types in your code. The key word is "optionally" - Python still doesn't enforce types at runtime. But tools can check them for you before you run the code.
Think of type hints like comments, but smarter. When you write a comment explaining what a function expects, nobody checks if your comment is accurate. It might be wrong, or outdated. Type hints are different: tools like mypy can verify that your hints match how you actually use the code.
Here's the same function without and with type hints:
def greet(name):
return f"Hello, {name}!"
With a type hint, you declare that name should be a string, and that this function returns a string:
def greet(name: str) -> str:
return f"Hello, {name}!"
The syntax is: parameter colon type, arrow return-type. That's it. Python won't stop you from calling greet(42), but your editor will show a warning, and mypy will catch it before runtime.
Nota bene: Type hints don't make Python slower. They're ignored at runtime. They exist purely for tooling - your IDE, your linter, your type checker.
Three reasons matter in practice:
1. Your editor becomes smarter. When it knows what type a variable holds, it can autocomplete methods accurately. Type user. and it shows you the methods that actually exist on a User object, not a generic list of possibilities.
2. Bugs get caught earlier. A type checker finds mistakes like "you're passing a string where an int is expected" before you run the code. No more crashes when a customer hits that rarely-used code path.
3. The code documents itself. When you see process_users(users: list[User]) -> dict[str, int], you immediately know: this function takes a list of User objects and returns a dictionary mapping strings to integers. No need to read the implementation to guess.
The simplest annotations are for Python's built-in types:
name: str = "Scipio"
age: int = 42
price: float = 99.99
active: bool = True
For function returns, use -> None when the function doesn't return anything:
def log_message(message: str) -> None:
print(f"LOG: {message}")
This says "this function returns nothing useful." It might technically return None, but the hint tells readers: don't expect a return value.
When you annotate a list or dictionary, you specify what type of elements it contains. This is where type hints get powerful, because you're not just saying "this is a list" - you're documenting what kind of data the list holds.
Think about it: in Python, a list can contain anything. Integers, strings, dictionaries, other lists, mixed types - anything. But in practice, your lists usually hold one specific type of thing. A list of user IDs (integers), a list of names (strings), a list of config objects. The type system lets you capture that intent:
numbers: list[int] = [1, 2, 3]
This says: numbers is a list, and every element in that list is an int. Now your type checker can warn you if you accidentally add a string to it.
For dictionaries, specify both key and value types:
ages: dict[str, int] = {"Alice": 30, "Bob": 25}
String keys, integer values. If you try to do ages["Carol"] = "thirty", the type checker catches it.
Nota bene: Before Python 3.9, you had to import these from the typing module (from typing import List, Dict). In modern Python, just use the built-in list and dict with square brackets.
One of the most common patterns in Python: a function that might return a value, or might return None.
def find_user(user_id: int) -> dict | None:
users = {1: {"name": "Scipio"}, 2: {"name": "Marcus"}}
return users.get(user_id)
The return type dict | None means: you'll get either a dictionary, or None. The pipe symbol | means "or". This warns anyone calling the function: check if the result is None before using it!
In older Python (before 3.10), you'd write Optional[dict] instead. It means the same thing:
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:
...
The mental model: Optional doesn't mean "this is optional to provide." It means "this value might optionally be None instead of the stated type."
Sometimes a value can legitimately be one of several types. Use Union (or the | syntax in modern Python):
def format_value(value: int | str) -> str:
if isinstance(value, int):
return f"Number: {value}"
return f"Text: {value}"
This says: value can be an int or a string, nothing else. The function handles both cases and always returns a string.
Python 3.7 introduced dataclasses, which work beautifully with type hints. Think of them as a clean way to define data structures without writing boilerplate.
The problem dataclasses solve: you often need simple classes that just hold data. A User with a name, email, and age. A Point with x and y coordinates. A Config with various settings. Writing these by hand means typing out __init__ methods, __repr__ for debugging, __eq__ for comparisons - all repetitive code that's identical across every data class you write.
Before dataclasses, you'd write a class like this:
class User:
def __init__(self, name, email, age):
self.name = name
self.email = email
self.age = age
With a dataclass and type hints, it's much cleaner:
from dataclasses import dataclass
@dataclass
class User:
name: str
email: str
age: int
The @dataclass decorator generates __init__, __repr__, __eq__, and other methods automatically based on your annotations. You get clean data structures with minimal code.
Creating instances looks exactly like before:
user = User("Scipio", "[email protected]", 42)
print(user.name)
But now your type checker knows what fields exist and what types they hold.
You can provide default values for fields:
@dataclass
class Config:
debug: bool = False
timeout: int = 30
Nota bene: For mutable defaults (like lists), use field(default_factory=list) instead of = []. This avoids the classic mutable default argument trap where all instances share the same list object:
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list[str] = field(default_factory=list)
Type hints on their own don't do anything. They're just annotations. To actually check them, use a static type checker like mypy.
Install it with pip install mypy, then run it on your Python files:
mypy yourfile.py
If you write this code:
def add(a: int, b: int) -> int:
return a + b
result = add(5, "10")
mypy will tell you: "Argument 2 has incompatible type 'str'; expected 'int'." Before you even run the code, you know there's a problem.
This is the real power of type hints: catching bugs at check-time instead of runtime.
In this episode, we covered the conceptual foundations of type hints in Python:
Type hints aren't about making Python statically typed. They're about giving you and your tools more information to catch mistakes earlier. In small scripts, you might not need them. In larger codebases, they become invaluable.