Type Hints Changed How I Code Python
I resisted type hints for years. 'Python is a dynamic language!' I said. 'Types are for Java developers!' I was wrong, and I'm not ashamed to admit it.
Type hints have made me more productive. They catch bugs before I even run the code. They make refactoring safer. They make the code more readable. Here's why I'm a convert.
What changed my mind
I was debugging a function that was receiving the wrong type. A function expected a list of user IDs (integers), but somewhere upstream it was getting passed a single user ID. I spent an hour tracing through the codebase to figure out where the bad data was coming from.
If I had type hints, pyright would have told me immediately. The function would be typed as def process_users(user_ids: list[int]) -> None, and passing a single int would be a type error. Red squiggly line in the editor. No debugging session needed.
That hour of debugging could have been 30 seconds of 'oh, I passed the wrong type, let me wrap it in a list.'
The basics are easy
Type hints are just annotations. They don't change how your code runs:
def greet(name: str) -> str:
return f'Hello, {name}'
def add(a: int, b: int) -> int:
return a + b
def is_valid(data: dict) -> bool:
return 'id' in data
That's it. Put the type after a colon. Put the return type after an arrow. The syntax is intuitive once you see a few examples.
Common types you'll use
Here are the types I use most often:
# Basic types
name: str
count: int
price: float
is_active: bool
# Collections
names: list[str]
user_ids: set[int]
config: dict[str, str]
# Optional (can be None)
result: str | None
# Union (multiple types)
id: int | str
Modern Python makes it nicer
In Python 3.10+, you can use the pipe | for unions:
# Python 3.10+ style
def find_user(id: int) -> User | None:
return users.get(id)
# Older style (still works)
from typing import Optional
def find_user(id: int) -> Optional[User]:
return users.get(id)
The pipe means 'or'. User | None is clearer than Optional[User], which is why I prefer the modern syntax.
In Python 3.9+, you can use list[str] instead of List[str] from typing. Built-in types work directly as generics:
# Python 3.9+ style
def process(items: list[str]) -> dict[str, int]:
...
# Older style
from typing import List, Dict
def process(items: List[str]) -> Dict[str, int]:
...
You don't need to type everything
Start with function signatures. That's where types help the most - the boundaries between functions.
def process_data(input_file: Path, output_file: Path) -> int:
# Local variables are inferred
data = read_json(input_file) # Type checker knows this is dict
count = 0 # Type checker knows this is int
for item in data['items']: # inferred
count += 1
write_json(output_file, data)
return count
The type checker can infer local variable types from context. You only need to annotate the function signature.
Typing complex structures
For more complex types, you can use TypedDict:
from typing import TypedDict
class User(TypedDict):
id: int
name: str
email: str
is_active: bool
def create_user(data: User) -> None:
# Now the type checker knows exactly what fields 'data' has
print(data['name']) # OK
print(data['age']) # Error! 'age' doesn't exist
Or use dataclasses/Pydantic models, which I prefer:
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
is_active: bool = True
def greet_user(user: User) -> str:
return f'Hello, {user.name}!'
Callable types
For functions as arguments:
from collections.abc import Callable
def apply_twice(func: Callable[[int], int], value: int) -> int:
return func(func(value))
# Usage
result = apply_twice(lambda x: x * 2, 5) # Returns 20
The Callable[[int], int] means 'a function that takes an int and returns an int'.
Run pyright
Type hints by themselves don't do anything at runtime. You need a type checker to get the benefits:
uv add --dev pyright
uv run pyright .
Pyright is fast and thorough. It'll tell you about type errors before you even run your code. Your editor (VS Code, PyCharm) probably has built-in support too.
Common gotchas and how to handle them
Mutable default arguments
This classic Python gotcha becomes more visible with type hints:
# Bad - mutable default
def add_item(item: str, items: list[str] = []) -> list[str]:
items.append(item)
return items
# Good - use None as default
def add_item(item: str, items: list[str] | None = None) -> list[str]:
if items is None:
items = []
items.append(item)
return items
Any vs Unknown
When you don't know the type, use Any sparingly:
from typing import Any
# Too permissive - type checker gives up
def process(data: Any) -> Any:
return data['result'] # No type checking at all
# Better - be specific about what you don't know
def process(data: dict[str, Any]) -> Any:
return data['result'] # At least 'data' is known to be a dict
Dealing with None
This is the most common type error I see:
def find_user(id: int) -> User | None:
return db.get_user(id)
# Type error! user might be None
user = find_user(123)
print(user.name) # Pyright warns you
# Correct - handle the None case
user = find_user(123)
if user is not None:
print(user.name) # OK now
Pyright catches these 'NoneType object has no attribute' errors before runtime.
Generic types and reusability
Sometimes you want a function that works with multiple types. Use TypeVar:
from typing import TypeVar
T = TypeVar('T')
def first(items: list[T]) -> T | None:
return items[0] if items else None
# The return type adapts to the input
names: list[str] = ['Alice', 'Bob']
first_name = first(names) # Type is str | None
ids: list[int] = [1, 2, 3]
first_id = first(ids) # Type is int | None
This is how Python's built-in generic types work. Your function preserves type information through the call.
Bounded type variables
You can constrain what types are allowed:
from typing import TypeVar
# Only numeric types
Numeric = TypeVar('Numeric', int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b
add(1, 2) # OK - returns int
add(1.5, 2.5) # OK - returns float
add('a', 'b') # Error! str not allowed
Protocol types for duck typing
Python's duck typing ('if it quacks like a duck...') can be type-safe with Protocol:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print('Drawing circle')
class Square:
def draw(self) -> None:
print('Drawing square')
# This function accepts anything with a draw() method
def render(shape: Drawable) -> None:
shape.draw()
# Both work, even though they don't inherit from a common base
render(Circle()) # OK
render(Square()) # OK
No inheritance needed. The type checker just verifies the shape has the required methods. This is structural typing, and it's perfect for Python.
Real-world patterns I use constantly
Factory functions
from typing import Protocol
class Database(Protocol):
def query(self, sql: str) -> list[dict]: ...
def create_database(url: str) -> Database:
if url.startswith('postgres://'):
return PostgresDB(url)
elif url.startswith('sqlite://'):
return SQLiteDB(url)
raise ValueError(f'Unknown database type: {url}')
Builder pattern with types
from dataclasses import dataclass, field
@dataclass
class QueryBuilder:
table: str
filters: list[str] = field(default_factory=list)
order_by: str | None = None
def where(self, condition: str) -> 'QueryBuilder':
self.filters.append(condition)
return self
def sort(self, column: str) -> 'QueryBuilder':
self.order_by = column
return self
def build(self) -> str:
query = f'SELECT * FROM {self.table}'
if self.filters:
query += ' WHERE ' + ' AND '.join(self.filters)
if self.order_by:
query += f' ORDER BY {self.order_by}'
return query
# Type-safe method chaining
query = (QueryBuilder('users')
.where('age > 18')
.where('is_active = true')
.sort('created_at')
.build())
Literal types for constants
from typing import Literal
Environment = Literal['development', 'staging', 'production']
def get_database_url(env: Environment) -> str:
if env == 'development':
return 'sqlite:///dev.db'
elif env == 'staging':
return 'postgres://staging.example.com/db'
else: # env == 'production'
return 'postgres://prod.example.com/db'
# Type checker enforces valid values
get_database_url('production') # OK
get_database_url('prod') # Error! Not a valid Environment
This prevents typos and invalid values at compile time.
Integration with popular frameworks
FastAPI
FastAPI is built on type hints. They're not optional - they're how the framework works:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class CreateUserRequest(BaseModel):
name: str
email: str
age: int
@app.post('/users')
def create_user(request: CreateUserRequest) -> dict[str, str]:
# FastAPI validates the request body automatically
# You get a type-safe request object
return {'id': '123', 'name': request.name}
The type hints generate API documentation, validate requests, and provide autocomplete.
SQLAlchemy 2.0
Modern SQLAlchemy uses type hints for type-safe queries:
from sqlalchemy.orm import Mapped, mapped_column
class User(Base):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
email: Mapped[str]
age: Mapped[int | None]
# Type-safe queries
users = session.query(User).filter(User.age > 18).all()
# 'users' is inferred as list[User]
The real benefits
1. Catch bugs earlier. Type errors are caught at development time, not runtime. 'NoneType has no attribute x' becomes a compile-time error.
2. Better autocomplete. Your editor knows what type a variable is, so it can show you relevant methods and properties.
3. Safer refactoring. Rename a method? The type checker will find every place it's used and tell you if you broke something.
4. Self-documenting code. def process(data) tells you nothing. def process(data: UserRequest) -> UserResponse tells you exactly what goes in and comes out.
5. Better code design. When you have to type out 'this function takes a list of dicts with these specific keys...', you start to realize maybe you should make a proper class.
6. Team collaboration. When someone reads your code six months later (including future you), type hints are documentation that never goes out of date.
Gradual adoption
You don't need to type everything at once. Here's my recommended migration path:
Week 1: Start with new code
Add type hints to all new functions you write. This builds the habit without the burden of retrofitting existing code:
# Every new function gets types
def create_invoice(user_id: int, amount: float) -> Invoice:
...
Week 2: Type your public API
Add types to functions that other modules call. These are the highest-value targets because they're the boundaries where bugs often occur:
# Public functions in your modules
def get_user_by_email(email: str) -> User | None:
...
def calculate_total(items: list[LineItem]) -> Decimal:
...
Week 3: Type the bug-prone areas
You know which parts of your codebase cause the most issues. Add types there:
# That function that always receives the wrong type
def process_payment(amount: Decimal, currency: str) -> PaymentResult:
# No more passing integers as amounts!
...
Configure pyright gradually
Start lenient, get stricter over time:
# pyproject.toml
[tool.pyright]
typeCheckingMode = 'basic' # Start here
reportMissingTypeStubs = false
reportUnknownMemberType = false
# After a few weeks, enable stricter checks
# typeCheckingMode = 'standard'
# Eventually
# typeCheckingMode = 'strict'
Use type: ignore sparingly
Sometimes you need to suppress a type error temporarily:
# For third-party libraries without type stubs
result = some_untyped_library.call() # type: ignore
# Better - add a comment explaining why
result = some_untyped_library.call() # type: ignore # TODO: Add types when library updates
But treat type: ignore as code smell. If you're using it a lot, something's wrong.
Performance considerations
Type hints have zero runtime overhead. They're stored as annotations and ignored by the Python interpreter unless you explicitly access them.
def greet(name: str) -> str:
return f'Hello, {name}'
# This runs at the same speed as the untyped version
# The type hints are metadata, not runtime checks
If you want runtime type checking, use Pydantic:
from pydantic import BaseModel, ValidationError
class User(BaseModel):
name: str
age: int
# Validates at runtime
try:
user = User(name='Alice', age='not a number') # Raises ValidationError
except ValidationError as e:
print(e)
But for most code, static type checking with pyright is enough.
Troubleshooting common errors
"Cannot assign to a method"
# Error
class MyClass:
def __init__(self):
self.value: int = 0
self.process = self._process # Type error!
def _process(self) -> None: ...
# Fix - assign the type to the method reference
from collections.abc import Callable
class MyClass:
def __init__(self):
self.value: int = 0
self.process: Callable[[], None] = self._process # OK
"Incompatible type in assignment"
Usually means you're assigning the wrong type:
# Error
name: str = 123 # Incompatible type!
# Fix - use the right type
name: str = '123'
# Or if it can be multiple types
name: str | int = 123 # OK
"Argument type is incompatible"
You're passing the wrong type to a function:
def greet(name: str) -> str:
return f'Hello, {name}'
user_id: int = 123
greet(user_id) # Error! Expected str, got int
# Fix - convert the type
greet(str(user_id)) # OK
My type hints checklist
Here's what I check before committing code:
- All public functions have type hints - At minimum, function signatures are typed
- No Any unless necessary - If I'm using Any, there's a comment explaining why
- pyright passes - Run
uv run pyright .before committing - Optional types are explicit - If something can be None, the type says so:
str | None - Collections are specific - Not just
list, butlist[str]
The bottom line
Type hints changed how I code Python. They catch bugs before they reach production. They make refactoring fearless. They make codebases maintainable.
The cost is minimal - a few extra characters per function signature. The benefits are massive - fewer bugs, better tooling, more confidence.
If you're writing Python without type hints in 2025, you're missing out. Modern Python is typed Python. Start small - just add types to your next function. Then the next. Before you know it, you'll wonder how you ever coded without them.
I resisted for years. I was wrong. Don't make the same mistake I did.