Type Hints & Static Typing
Reviewed & published by Brayan K
Modern Python development increasingly depends on static typing — not to replace Python's dynamic nature, but to catch bugs earlier, write clearer APIs, and scale large codebases safely. If you understand type hints deeply, your Python code becomes easier to refactor, safer to modify, more readable, better documented, and more compatible with modern IDE autocompletion.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • Why static typing matters and how it prevents bugs before runtime
- • Annotating function parameters, return values, and variables
- • Using Optional, Union, TypeVar, and Generic types
- • Typing collections: list[str], dict[str, int], tuple, and more
- • Running mypy to catch type errors statically before deployment
- • Real-world patterns used in large Python codebases and open-source libraries
🔥 1. Why Use Static Typing in Python?
- Many bugs appear only at runtime
- Refactoring becomes risky
- IDEs can't infer return types
- Large codebases become messy
def add(a: int, b: int) -> int:
return a + b- Better editor suggestions
- Catching mismatched types early
- Clearer intent
- Fewer runtime surprises
Typing is optional but extremely powerful.
⚙️ 2. Basic Type Hints
age: int = 18
name: str = "Alice"
balance: float = 99.5def greet(name: str) -> str:
return f"Hello {name}"def move(x: float, y: float) -> tuple[float, float]:
return x, y🧠 3. Optional & Union Types
Sometimes a variable may hold more than one type.
from typing import Optional
def get_user(id: int) -> Optional[str]:
return "Alice" if id == 1 else Nonefrom typing import Union
def parse(value: Union[str, int]) -> str:
return str(value)
# Python 3.10+ supports a cleaner syntax:
def parse(value: str | int) -> str:
return str(value)🎛 4. Lists, Dicts & Complex Structures
users: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {
"math": 95,
"science": 88
}
matrix: list[list[int]] = [
[1, 2, 3],
[4, 5, 6]
]Typed collections prevent silent mistakes.
📖 Worked Example: Hints in a Program That Actually Runs
Everything above has been fragments with nothing to run. Here is a complete program that uses the hints you have just met — and, more importantly, shows you what they do and do not do.
The last two lines are the ones to stare at. Python stores your hints in a dictionary and then ignores them. Nothing raises when you assign a string to something annotated int. Hints are a message to your editor, to mypy, and to the next human — not a runtime check.
from typing import Optional
# 1. Parameter and return hints. They document the contract you promise callers.
def apply_discount(price: float, percent: float = 10.0) -> float:
"""Return `price` with `percent` taken off, rounded to pennies."""
return round(price * (1 - percent / 100), 2)
# 2. Optional[str] is shorthand for "a str, OR None" — it warns callers to check.
def find_user(user_id: int) -> Optional[str]:
users: dict[int, str] = {1: "Ada", 2: "Grace"} # keys are int, values are str
return users.get(user_id) # .get returns None for a missing key
print(apply_discount(80.0)) # uses the default 10%
print(apply_discount(80.0, 25)) # 25% off instead
for uid in (1, 3):
name = find_user(uid)
if name is None: # exactly the check Optional is asking you to write
print(f"user {uid}: not found")
else:
print(f"user {uid}: {name}")
# 3. Hints are stored, not enforced. Python keeps them in a dict:
print(find_user.__annotations__)
# 4. And it will happily let you break one. Nothing stops this at runtime:
wrong: int = "not a number"
print(wrong, "is really a", type(wrong).__name__)
# ✅ Expected output:
# 72.0
# 60.0
# user 1: Ada
# user 3: not found
# {'user_id': <class 'int'>, 'return': typing.Optional[str]}
# not a number is really a strThat last line is why mypy exists. Run mypy yourfile.py over this exact program and it reports error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] — before the code ever runs. The interpreter will not tell you; the checker will.
🎯 Your Turn: Annotate a Grade Report
The logic is already written and correct. Only the annotations are missing. Fill in the three ___ blanks so each function says exactly what it takes and gives back, then run it — the program prints its own annotations so you can check your answers against the expected output.
# 🎯 YOUR TURN — replace the three ___ blanks
# 1) average takes a list of numbers and gives back a single number.
def average(scores: ___) -> float: # 👉 a list whose items are floats
"""Take a list of numbers, give back one number."""
return round(sum(scores) / len(scores), 1)
# 2) report takes a dict mapping a name to that person's list of scores...
def report(grades: ___) -> ___: # 👉 first blank: dict from str to list of floats
"""Prints a line per student. It returns nothing at all.""" # 👉 second blank: what a
for name, scores in grades.items(): # function returns when
print(f"{name:8}{average(scores)}") # it only prints
grades = {
"Ada": [90.0, 85.0, 95.0],
"Grace": [70.0, 80.0, 78.0],
}
report(grades)
print(average.__annotations__)
print(report.__annotations__)
# ✅ Expected output:
# Ada 90.0
# Grace 76.0
# {'scores': list[float], 'return': <class 'float'>}
# {'grades': dict[str, list[float]], 'return': None}1) list[float] — the square brackets say what is inside the list.
2) dict[str, list[float]] — key type first, value type second, and the value type is itself a typed list.
3) None — a function that only prints returns None, and you write that literally as the return annotation.
Lowercase list and dict work as annotations from Python 3.9 onwards. On older versions you needed List and Dict imported from typing.
🔄 5. Callable Types (Typing Functions)
from typing import Callable
def operate(x: int, y: int, fn: Callable[[int, int], int]) -> int:
return fn(x, y)- strategy patterns
- higher-order functions
🧩 6. Typed Classes & Instance Attributes
class User:
name: str
age: int
def __init__(self, name: str, age: int):
self.name = name
self.age = age
# Typed methods
def birthday(self) -> None:
self.age += 1MyPy ensures you don't assign incorrect types.
📦 7. Dataclasses with Type Hints
from dataclasses import dataclass
@dataclass
class Product:
id: int
price: float
name: str- typed attributes
- automatic init
- readable models
Perfect for APIs & ML pipelines.
🧬 8. Generics — Creating Reusable Typed Patterns
Generics let you write functions/classes that operate on any type safely.
from typing import TypeVar, Generic
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, value: T):
self.value = value
# Use:
b1 = Box(10)
b2 = Box[str]("hello")Powerful for frameworks and libraries.
🎚 9. Protocols (Duck Typing for Static Typing)
A Protocol describes behavior, not inheritance.
from typing import Protocol
class Flyer(Protocol):
def fly(self) -> None: ...
class Bird:
def fly(self): print("bird flies")
class Plane:
def fly(self): print("plane flies")
def lift_off(obj: Flyer):
obj.fly()Any object with .fly() works — no inheritance required.
This is extremely powerful.
🧵 10. Literal Types (Exact Allowed Values)
from typing import Literal
def move(direction: Literal["up", "down"]) -> None:
print(direction)If you pass "left", MyPy errors.
- specific command values
- config flags
🧠 11. Typing None, Never, NoReturn
Function returns no value:
def log(msg: str) -> None:
print(msg)Function never returns (like raise)
from typing import NoReturn
def fail(msg: str) -> NoReturn:
raise RuntimeError(msg)Advanced typing helps verify control flow.
🔍 12. Introducing MyPy (Static Type Checker)
pip install mypymypy your_file.pyMyPy reads your code + type hints and reports mismatches:
error: Incompatible return value type
It's like a spell-checker for your Python types.
🚀 13. MyPy Configuration (Recommended)
[mypy]
strict = True
warn_unused_ignores = True
warn_return_any = True
disallow_untyped_defs = True
disallow_untyped_calls = True"Strict mode" catches bugs EARLY.
✨ 14. MyPy + VSCode = Elite Developer Experience
- instant red underlines
- hover type previews
- autocomplete becomes smarter
- safer refactoring
Typing + MyPy = faster development + fewer mistakes.
🔥 15. Enums — Strict Typed Constants
Instead of using strings everywhere, typed enums give you:
- ✔ autocomplete
- ✔ compile-time validation
- ✔ guaranteed allowed values
from enum import Enum
class Status(Enum):
SUCCESS = "success"
FAIL = "fail"
PENDING = "pending"
def handle(status: Status) -> None:
print("Status:", status.value)If you pass a string, MyPy shows an error.
🧱 16. TypedDict — Type Hints for Dictionaries
Perfect for JSON, API requests, and configuration dictionaries.
from typing import TypedDict
class UserData(TypedDict):
id: int
name: str
verified: bool
user: UserData = {
"id": 1,
"name": "Boopie",
"verified": True
}MyPy catches missing or extra fields.
This is extremely useful for web apps & APIs.
🧪 17. Narrowing Types with isinstance()
MyPy is smart enough to refine types automatically.
from typing import Union
def process(x: Union[int, str]):
if isinstance(x, int):
return x + 10 # MyPy knows x is int here
else:
return x.upper() # MyPy knows x is str hereThis makes your branching logic safer and more readable.
🧠 18. Type Aliases — Naming Complex Types
Coordinates = tuple[float, float]
JsonDict = dict[str, "JSON"]
def move(point: Coordinates) -> None:
...Aliases make large systems more understandable and maintainable.
🧬 19. Typed Exceptions
You can annotate exceptions to improve clarity and debugging.
def load_user() -> dict | None:
raise FileNotFoundError("User not found")MyPy understands that this function raises, affecting control flow analysis.
🧵 20. Typing Coroutines & Async Functions
Async functions also have return types:
async def fetch(url: str) -> dict:
...
from typing import Awaitable
def queue_task(task: Awaitable[int]):
...This is important for:
- asyncio pipelines
- queue workers
📡 21. Typing Generator Functions
Generators require three type parameters:
from typing import Generator
def countdown(n: int) -> Generator[int, None, None]:
while n > 0:
yield n
n -= 1Format: Generator[yield_type, send_type, return_type]
- ✔ streaming pipelines
- ✔ async frameworks
- ✔ ML dataset loaders
⚙️ 22. Type-Safe Context Managers
from typing import ContextManager
def open_file() -> ContextManager[str]:
return open("data.txt")- resource managers
- thread locks
- file systems
🧩 23. Overloading (Different Types, Same Function Name)
Sometimes the same function behaves differently depending on input type.
from typing import overload
@overload
def parse(data: int) -> str: ...
@overload
def parse(data: str) -> int: ...
def parse(data):
if isinstance(data, int):
return str(data)
return int(data)MyPy resolves the correct signature.
This technique is common in libraries like NumPy & Pandas.
📦 24. ReadOnly & Final Types
from typing import Final
API_KEY: Final = "XYZ123"Final classes & methods prevent inheritance or overriding.
- ✔ architecture control
- ✔ framework development
⚡ 25. Typed Properties in Classes
class User:
@property
def name(self) -> str:
return self._nameMyPy verifies property types during assignment.
🔒 26. Private Attributes with Type Checking
Python doesn't enforce private attributes, but typing improves clarity:
class User:
_token: str # internal useTeams use this to coordinate internal vs public API boundaries.
🧬 27. Structural Subtyping — Protocols in Action
A deeper Protocol + practical use case:
class Database(Protocol):
def connect(self) -> None: ...
def query(self, q: str) -> list[str]: ...Any object with these methods works.
- ✔ plug-in architectures
- ✔ dependency injection
- ✔ mockable systems
- ✔ backend abstraction layers
This is how large scalable apps are built.
🧠 28. Using cast() for Type Fixes
Sometimes developers know more than MyPy.
from typing import cast
value = cast(str, some_unknown_value)Use sparingly — only when you are 100% certain.
📊 29. mypy --strict (Real-World, Enterprise Settings)
- ✔ no implicit Optional
- ✔ disallow untyped defs
- ✔ typed bool checks
- ✔ correct narrowing
- ✔ strict Any usage
- ✔ missing return warnings
This avoids massive classes of bugs.
Companies like Meta, Microsoft, Dropbox, and Stripe all use strict typing in their large Python systems.
🔥 30. Full Production Example — Typed API Layer
from typing import TypedDict, Optional
class User(TypedDict):
id: int
name: str
is_active: bool
def get_user(user_id: int) -> Optional[User]:
...- ✔ safe API responses
- ✔ correct JSON schemas
- ✔ strong autocomplete
- ✔ enforced return types
Typing + MyPy is the foundation of fast, safe backend development.
🔥 31. Typed Dependency Injection (FastAPI / Clean Architecture)
The Clean Architecture pattern separates controllers, services, repositories, and models.
You can use Protocols and TypedDicts for clean interfaces.
class UserRepo(Protocol):
def get(self, user_id: int) -> dict: ...
def save(self, user: dict) -> None: ...Any storage implementation becomes valid:
- ✔ PostgreSQL
- ✔ In-memory testing repo
This creates strict boundaries and eliminates class-coupling bugs.
⚙️ 32. Typed Service Layer (Production API Design)
class UserModel(TypedDict):
id: int
email: str
verified: bool
class UserService:
def __init__(self, repo: UserRepo):
self.repo = repo
def verify_user(self, user_id: int) -> UserModel:
user = self.repo.get(user_id)
user["verified"] = True
self.repo.save(user)
return user- ❌ Unknown fields
- ❌ Wrong data shapes
- ❌ Incorrect types
- ✔ Real bugs before runtime
This is how Stripe, Dropbox, and Instagram design services.
🧠 33. Using Typed Exceptions in Architecture
Typing exceptions clarifies control flow:
class NotFoundError(Exception):
pass
def get_user(user_id: int) -> dict:
raise NotFoundError()MyPy can detect unreachable code or missing exception handling.
Used in: payment pipelines, async workers, SQL/ORM layers, microservices.
🕸️ 34. Using Typed Generators in Data Pipelines
For ML or ETL:
from typing import Generator
def batch_loader(data: list[int], size: int) -> Generator[list[int], None, None]:
for i in range(0, len(data), size):
yield data[i:i+size]Large frameworks like TensorFlow, Airflow, Spark rely on typed data pipelines like this.
🧩 35. Typed Async Systems (Real World)
Async APIs & concurrency depend heavily on precise typing.
from typing import AsyncIterator
async def stream_events() -> AsyncIterator[str]:
for i in range(100):
yield f"event-{i}"- ✔ live dashboards
- ✔ websocket feeds
- ✔ streaming ingestion
- ✔ server push notifications
🔐 36. Protocol-Based Plugin Systems (Extremely Powerful)
With Protocols, you can build interfaces without inheritance:
class Plugin(Protocol):
name: str
def run(self, data: bytes) -> bytes: ...Any module implementing the methods becomes a valid plugin.
This technique powers: VSCode extensions, Flask extensions, Django middleware, OBS plugins, game modding systems.
📦 37. Interface Segregation with Protocols
Large apps break systems into narrow interface blocks.
class ReadOnlyRepo(Protocol):
def get(self, id: int) -> dict: ...
class WriteRepo(Protocol):
def save(self, data: dict) -> None: ...This is how enterprise systems prevent complexity explosions.
🏗️ 38. Declarative API Design with TypedDict + Literal Types
from typing import Literal, TypedDict
class Task(TypedDict):
id: int
status: Literal["pending", "running", "done"]- ✔ task queues
- ✔ workflow engines
- ✔ job schedulers
- ✔ distributed workers
The system stays consistent and bug-free.
📊 39. Full Real-World Example — Typed Microservice
class CreateUserRequest(TypedDict):
email: str
password: str
class CreateUserResponse(TypedDict):
id: int
email: str
verified: bool
def create_user(data: CreateUserRequest) -> CreateUserResponse:
return {
"id": 100,
"email": data["email"],
"verified": False,
}- ✔ rejects invalid payloads
- ✔ catches missing fields
- ✔ auto-documents itself
- ✔ integrates with OpenAPI perfectly
🧪 40. Testing with Typed Mocks
Typing plays a huge role in large codebases for tests.
class FakeRepo(UserRepo):
def __init__(self):
self.saved = {}
def get(self, id: int):
return {"id": id, "verified": False}
def save(self, data: dict):
self.saved[data["id"]] = dataMyPy verifies FakeRepo fully matches the protocol.
🧱 41. MyPy in CI/CD Pipelines
- Developer pushes code
- Unit tests run
- MyPy runs in strict mode
- Type failures = ❌ build fails
- Only correct, type-safe code reaches production
This is used by: Google, Meta, Uber, Airbnb, Microsoft.
Typing becomes a security layer against bugs.
🧨 42. MyPy + Pydantic = The Ultimate Typed Backend
For APIs, Pydantic validates FastAPI models using the types.
from pydantic import BaseModel
class User(BaseModel):
id: int
email: str
verified: boolPydantic enforces correctness at runtime.
This is a professional-grade combination.
⚡ 43. When Type Hints Improve PERFORMANCE
- ✔ JIT compilers
- ✔ static analyzers
- ✔ tooling optimizers
- ✔ IDE optimizations
Python 3.13+ will introduce more optimisations because of typing.
Typed code = faster code.
🎯 44. When to Avoid Type Hints
Don't use typing when:
- ✗ writing throwaway scripts
- ✗ prototyping very early
- ✗ working with extremely dynamic structures
- ✗ you don't know the structure yet
Typing is best for:
- ✔ long-term projects
- ✔ large teams
- ✔ performance-sensitive code
- ✔ shared libraries
🎓 Final Summary — Mastering Python Typing
You now understand everything:
- ✔ ints, str, bool
- ✔ Optional, Union
- ✔ collections
- ✔ dataclasses
- ✔ async & generator typing
- ✔ Callable types
- ✔ overloading
- ✔ context manager typing
- ✔ Literal types
- ✔ Final, NoReturn, Never
- ✔ interface segregation
- ✔ ML pipelines
- ✔ microservices
- ✔ async architectures
- ✔ plugin systems
- ✔ ETL pipelines
- ✔ MyPy strict
- ✔ VSCode integration
- ✔ CI/CD workflows
You now write code with:
🔥 fewer bugs 🔥 better structure 🔥 enterprise-level quality 🔥 professional readability 🔥 future-proof maintainability
Typing + MyPy = elite Python engineering.
🏆 Mini-Challenge: A Config Parser That Says What It Means
Outline only — no filled-in logic this time. Config values arrive as strings, and sometimes they are missing entirely. Write one function whose signature tells the whole story: it takes "a string or nothing" and gives back "a number or nothing".
Use the Python 3.10+ union syntax (str | None) rather than Optional — they mean the same thing, and the pipe reads better once you are used to it.
# 🎯 MINI-CHALLENGE: parse a port number out of a config string
#
# 1. Write parse_setting with this signature:
# raw is "a str or None", and it returns "an int or None"
# (use the 3.10+ pipe syntax: str | None and int | None)
# 2. Inside:
# - if raw is None -> return None
# - if raw is not all digits -> return None (hint: raw.isdigit())
# - otherwise -> return int(raw)
# 3. Loop over ["8080", None, "abc", "443"] and print:
# f"{raw!r} -> {parse_setting(raw)}"
# (!r shows the quotes, so you can tell the string "None" from real None)
# 4. Finally print parse_setting.__annotations__ to see what you declared
#
# ✅ Expected output:
# '8080' -> 8080
# None -> None
# 'abc' -> None
# '443' -> 443
# {'raw': str | None, 'return': int | None}
# your code heredef parse_setting(raw: str | None) -> int | None:
"""Turn a config string into a port number, or None if it isn't one."""
if raw is None:
return None
if not raw.isdigit():
return None
return int(raw)
for raw in ["8080", None, "abc", "443"]:
print(f"{raw!r} -> {parse_setting(raw)}")
print(parse_setting.__annotations__)The signature is doing real work here. int | None tells every caller "you must handle the None case", and mypy will flag them if they forget and treat the result as a plain int.
📋 Quick Reference — Type Hints
| Syntax | What it does |
|---|---|
| def fn(x: int) -> str: | Annotate function params and return |
| Optional[str] | Value can be str or None |
| Union[int, str] | Value can be int or str |
| list[str] / dict[str, int] | Generic container types (Python 3.9+) |
| TypeVar('T') | Generic type variable for templates |
🎉 Great work! You've completed this lesson.
You can now annotate functions, variables, and generics with type hints and use mypy to catch type errors before runtime.
Practice quiz
Are Python type hints enforced by the interpreter at runtime?
- Yes — passing the wrong type raises a TypeError automatically
- Yes — but only inside classes
- No — they are checked by tools like mypy, not the interpreter
- Only when running with the -O flag
Answer: No — they are checked by tools like mypy, not the interpreter. Hints are essentially documentation the interpreter ignores at runtime. Static checkers like mypy read them to catch mismatches before you run the code.
How do you annotate a function that takes two ints and returns an int?
- def add(a: int, b: int) -> int:
- def add(int a, int b) -> int:
- def add(a, b): int, int -> int
- int def add(a, b):
Answer: def add(a: int, b: int) -> int:. Parameters are annotated with `name: type` and the return type follows the `->` arrow before the colon.
What does Optional[str] mean?
- The value is optional and can be omitted entirely
- The value can be any type
- The value must be a non-empty string
- The value is a str or None
Answer: The value is a str or None. Optional[str] is shorthand for Union[str, None] — a value that is either a str or None.
Which annotation says a value may be an int or a str?
- Optional[int, str]
- Union[int, str]
- Either[int, str]
- int and str
Answer: Union[int, str]. Union[int, str] allows either type. In Python 3.10+ you can also write the cleaner `int | str`.
How do you type a list of strings using built-in generics (Python 3.9+)?
- list[str]
- List(str)
- list<str>
- [str]
Answer: list[str]. Built-in containers became generic, so you can write list[str], dict[str, int], etc. directly without importing List from typing.
What does Callable[[int, int], int] describe?
- A list of two ints plus an int
- A class with two int attributes
- A function taking two ints and returning an int
- An int that can be called like a function
Answer: A function taking two ints and returning an int. Callable[[arg types], return type] describes a function's signature — here two int parameters and an int result.
What return type annotation should a function that returns nothing use?
- -> void
- -> None
- -> null
- -> empty
Answer: -> None. A function with no meaningful return value is annotated -> None, like `def log(msg: str) -> None:`.
What are TypeVar and Generic used for?
- Forcing every value to be the same type
- Disabling type checking for a block
- Converting between types at runtime
- Writing classes/functions that work with any type while preserving type info
Answer: Writing classes/functions that work with any type while preserving type info. A TypeVar is a placeholder type; subclassing Generic[T] lets a class like Box[T] work for any element type while keeping type information.
What is a Protocol used for in typing?
- Defining a network communication format
- Describing required behavior (methods) without inheritance — structural typing
- Locking a class so it cannot be subclassed
- Marking a function as async
Answer: Describing required behavior (methods) without inheritance — structural typing. A Protocol describes a shape: any object with the right methods fits, no inheritance required. It's duck typing made statically checkable.
What does a TypedDict let you describe?
- A dictionary that can only hold one type for all values
- A frozen, immutable dictionary
- The exact keys a dictionary has and the type of each value
- A dictionary stored on disk
Answer: The exact keys a dictionary has and the type of each value. A TypedDict declares the precise keys and per-value types of a dict (great for JSON/API shapes). At runtime it is just a normal dict.
Continue this course
- Previous: Memory Management & Garbage Collection Internals
- Next: Data Classes & Advanced Class Patterns — Reduce boilerplate with @dataclass and slots
- Quick reference: Python cheat sheet › Functions