Python course · Module 3: Object-Oriented Programming
Type hints - precise classification
In this lesson10
Welcome back! Darwin here with a lesson about precision and code documentation.
In biology, precise classification is key - "big animal" is not the same as "Panthera leo (African lion), male, adult, 190kg". In Python, type hints (type annotations) allow the same precision in code!
1# Imprecise - what is this?
2def count_animals(animals):
3 return len(animals)
4
5# Precise - everything is clear!
6def count_animals(animals: list[str]) -> int:
7 """Count animals in the list"""
8 return len(animals)What are type hints?
Type hints are type annotations in Python - additional information about the types of variables, function parameters, and return values.
IMPORTANT: Type hints are optional and do NOT affect program execution! Python ignores them at runtime. They serve for:
- Documentation - explain the programmer's intentions
- IDE - better suggestions and autocomplete
- Tools - mypy, pyright can detect type errors
- Readability - easier to understand code
1# Without type hints - we have to guess
2def calculate_density(count, area):
3 return count / area
4
5# With type hints - everything is clear!
6def calculate_density(count: int, area: float) -> float:
7 """Calculate population density (individuals/km²)"""
8 return count / areaBasic type hints
Variables
You write a variable annotation after a colon, between the name and the = sign. For containers you can also give the element type in square brackets:
1# Basic types
2name: str = "Lion"
3age: int = 5
4weight: float = 190.5
5is_dangerous: bool = True
6
7# Container types alone (no element types)
8animals: list = ["Lion", "Elephant"]
9habitat_map: dict = {"Lion": "savanna"}
10
11# Python 3.9+ - even better with element types
12animals: list[str] = ["Lion", "Elephant", "Giraffe"]
13populations: dict[str, int] = {"Lion": 500, "Elephant": 300}
14coordinates: tuple[float, float] = (51.5, -0.1)
15unique_species: set[str] = {"Lion", "Elephant"}Functions - parameters and return type
In a function, you write the type of each parameter after a colon and the return type after the -> arrow, before the final colon:
1def greet(name: str) -> str:
2 """Takes str, returns str"""
3 return f"Hello, {name}!"
4
5def add_numbers(a: int, b: int) -> int:
6 """Takes two ints, returns int"""
7 return a + b
8
9def calculate_average(numbers: list[float]) -> float:
10 """Takes a list of floats, returns float"""
11 return sum(numbers) / len(numbers)
12
13def print_info(message: str) -> None:
14 """None means 'returns nothing' """
15 print(message)
16 # no return or return NoneTypes from the typing module
Optional - can be None
Optional[str] says that the function returns text or None. It is useful for a lookup that may find nothing:
1from typing import Optional
2
3def find_species(name: str) -> Optional[str]:
4 """
5 Returns species or None if not found
6
7 Optional[str] = str | None
8 """
9 database = {"Lion": "Panthera leo", "Elephant": "Loxodonta africana"}
10 return database.get(name) # May return str or None
11
12result = find_species("Lion") # Optional[str]
13if result is not None:
14 print(f"Found: {result}")Union - one of several types
Union lists several allowed types. An animal ID can be a number or text:
1from typing import Union
2
3def process_id(animal_id: Union[int, str]) -> str:
4 """
5 Accepts int OR str
6
7 Python 3.10+: int | str
8 """
9 return f"ID: {animal_id}"
10
11process_id(123) # OK - int
12process_id("LEO-45") # OK - strPython 3.10+: You can use | instead of Union!
1def process_id(animal_id: int | str) -> str:
2 """Newer syntax - Python 3.10+"""
3 return f"ID: {animal_id}"
4
5# Optional can also be written shorter
6def find_species(name: str) -> str | None:
7 """str | None = Optional[str]"""
8 passList, Dict, Tuple, Set - with specific types
Built-in containers take their element types in square brackets. A tuple can have a fixed number of positions or any number, written as ...:
1# Python 3.9+ - use lowercase!
2def process_animals(names: list[str]) -> dict[str, int]:
3 """List of strings → dictionary string:int"""
4 return {name: len(name) for name in names}
5
6# Tuple - fixed number of elements with specified types
7def get_coordinates() -> tuple[float, float]:
8 """Returns (latitude, longitude)"""
9 return (51.5074, -0.1278)
10
11# Tuple - variable number of elements of the same type
12def get_observations() -> tuple[int, ...]:
13 """Tuple with any number of ints"""
14 return (12, 45, 23, 67, 89)
15
16# Set
17def get_unique_habitats() -> set[str]:
18 """Set of unique habitats"""
19 return {"savanna", "jungle", "mountains"}Any - any type
Any means the value can be of any type, so tools stop checking it:
1from typing import Any
2
3def log_data(data: Any) -> None:
4 """Accepts anything - use sparingly!"""
5 print(f"Logging: {data}")
6
7log_data(123) # OK
8log_data("text") # OK
9log_data([1, 2, 3]) # OK
10log_data({"key": "val"}) # OKNote: Use Any only when you truly accept any type! It's like giving up on type hints.
Type hints for classes
In a class you annotate the class attributes, the parameters of __init__ and the methods, and the instance attributes. A class name that Python doesn't know yet goes in quotes, e.g. 'Species':
1class Species:
2 """Species with type hints"""
3
4 # Class attributes
5 kingdom: str = "Animalia"
6 all_species: list['Species'] = [] # Forward reference
7
8 def __init__(
9 self,
10 scientific_name: str,
11 common_name: str,
12 population: int,
13 endangered: bool = False
14 ) -> None:
15 """Constructor with type hints"""
16 self.scientific_name: str = scientific_name
17 self.common_name: str = common_name
18 self.population: int = population
19 self.endangered: bool = endangered
20 self.observations: list[dict[str, Any]] = []
21
22 Species.all_species.append(self)
23
24 def add_observation(
25 self,
26 location: str,
27 count: int,
28 date: str | None = None
29 ) -> None:
30 """Add observation - None return type"""
31 obs: dict[str, str | int] = {
32 "location": location,
33 "count": count
34 }
35 if date:
36 obs["date"] = date
37 self.observations.append(obs)
38
39 def get_population_trend(self) -> float:
40 """Returns float"""
41 if len(self.observations) < 2:
42 return 0.0
43
44 first = self.observations[0]["count"]
45 last = self.observations[-1]["count"]
46 return (last - first) / first * 100
47
48 @classmethod
49 def get_endangered_species(cls) -> list['Species']:
50 """Returns list of Species - forward reference!"""
51 return [s for s in cls.all_species if s.endangered]
52
53 @staticmethod
54 def is_valid_population(pop: int) -> bool:
55 """Returns bool"""
56 return pop >= 0Advanced type hints
Callable - functions as types
When a parameter is a function, Callable[[Species], bool] describes what that function takes and what it returns:
1from typing import Callable
2
3def apply_filter(
4 species_list: list[Species],
5 filter_func: Callable[[Species], bool]
6) -> list[Species]:
7 """
8 Filter list by function
9
10 Callable[[Species], bool] = function taking Species, returning bool
11 """
12 return [s for s in species_list if filter_func(s)]
13
14# Usage
15def is_endangered(species: Species) -> bool:
16 return species.endangered
17
18endangered = apply_filter(Species.all_species, is_endangered)TypeAlias - aliases for complex types
You can name a long type once and use that name in many places. You create such an alias with the TypeAlias annotation:
1from typing import TypeAlias
2
3# Alias for readability
4PopulationData: TypeAlias = dict[str, list[int]]
5Coordinates: TypeAlias = tuple[float, float]
6SpeciesDict: TypeAlias = dict[str, Species]
7
8def analyze_populations(data: PopulationData) -> float:
9 """The PopulationData type is more readable than dict[str, list[int]]"""
10 all_counts: list[int] = []
11 for counts in data.values():
12 all_counts.extend(counts)
13 return sum(all_counts) / len(all_counts)
14
15def get_location(name: str) -> Coordinates:
16 """Coordinates clearly states what we return"""
17 locations: dict[str, Coordinates] = {
18 "Serengeti": (-2.3333, 34.8333),
19 "Masai Mara": (-1.5, 35.1667)
20 }
21 return locations.get(name, (0.0, 0.0))Generic - generic classes
A generic class takes the element type as a parameter, just like list[str]. The same stack can hold strings or numbers, and the IDE still knows what you will take out of it:
1from typing import Generic, TypeVar
2
3T = TypeVar('T') # Generic type
4
5class Stack(Generic[T]):
6 """Generic stack - can store any type"""
7
8 def __init__(self) -> None:
9 self._items: list[T] = []
10
11 def push(self, item: T) -> None:
12 """Add element of type T"""
13 self._items.append(item)
14
15 def pop(self) -> T:
16 """Remove and return element of type T"""
17 return self._items.pop()
18
19 def is_empty(self) -> bool:
20 return len(self._items) == 0
21
22# Usage
23string_stack: Stack[str] = Stack[str]()
24string_stack.push("Lion")
25string_stack.push("Elephant")
26animal: str = string_stack.pop() # IDE knows it's str!
27
28int_stack: Stack[int] = Stack[int]()
29int_stack.push(123)
30int_stack.push(456)
31number: int = int_stack.pop() # IDE knows it's int!Safari example - complete system with type hints
Now everything together: type aliases, enums for the conservation status and habitat, a class with annotations in every method, and helper functions that use it.
1from typing import Optional, TypeAlias
2from datetime import date
3from enum import Enum
4
5# === TYPE ALIASES ===
6
7SpeciesID: TypeAlias = str
8Coordinates: TypeAlias = tuple[float, float]
9ObservationData: TypeAlias = dict[str, str | int | date]
10
11# === ENUMS ===
12
13class ConservationStatus(Enum):
14 """Species conservation status"""
15 EXTINCT = "extinct"
16 EXTINCT_IN_WILD = "extinct_in_wild"
17 CRITICALLY_ENDANGERED = "critically_endangered"
18 ENDANGERED = "endangered"
19 VULNERABLE = "vulnerable"
20 NEAR_THREATENED = "near_threatened"
21 LEAST_CONCERN = "least_concern"
22
23class Habitat(Enum):
24 """Habitat type"""
25 SAVANNA = "savanna"
26 JUNGLE = "jungle"
27 MOUNTAINS = "mountains"
28 DESERT = "desert"
29 WETLANDS = "wetlands"
30
31# === MAIN CLASS ===
32
33class Species:
34 """
35 Species class with full type hints
36
37 Precise classification like in biology!
38 """
39
40 # Class attribute
41 _registry: dict[SpeciesID, 'Species'] = {}
42
43 def __init__(
44 self,
45 scientific_name: str,
46 common_name: str,
47 population: int,
48 habitat: Habitat,
49 status: ConservationStatus,
50 dangerous: bool = False
51 ) -> None:
52 """
53 Initialize species
54
55 Args:
56 scientific_name: Scientific name (e.g., "Panthera leo")
57 common_name: Common name (e.g., "Lion")
58 population: Number of individuals
59 habitat: Habitat type (enum)
60 status: Conservation status (enum)
61 dangerous: Whether dangerous to humans
62 """
63 self.id: SpeciesID = scientific_name
64 self.scientific_name: str = scientific_name
65 self.common_name: str = common_name
66 self.population: int = population
67 self.habitat: Habitat = habitat
68 self.status: ConservationStatus = status
69 self.dangerous: bool = dangerous
70
71 # Observations list
72 self.observations: list[ObservationData] = []
73
74 # Locations
75 self.locations: set[Coordinates] = set()
76
77 # Register
78 Species._registry[self.id] = self
79
80 def add_observation(
81 self,
82 location: str,
83 coordinates: Coordinates,
84 count: int,
85 observation_date: date | None = None
86 ) -> None:
87 """
88 Add a species observation
89
90 Args:
91 location: Location name
92 coordinates: (latitude, longitude)
93 count: Number of observed individuals
94 observation_date: Observation date (optional)
95 """
96 obs: ObservationData = {
97 "location": location,
98 "count": count,
99 "date": observation_date or date.today()
100 }
101 self.observations.append(obs)
102 self.locations.add(coordinates)
103
104 def get_total_observed(self) -> int:
105 """Return total number of observed individuals"""
106 return sum(
107 int(obs["count"])
108 for obs in self.observations
109 )
110
111 def get_observation_locations(self) -> list[str]:
112 """Return list of unique observation locations"""
113 locations: set[str] = {
114 str(obs["location"])
115 for obs in self.observations
116 }
117 return sorted(locations)
118
119 def is_threatened(self) -> bool:
120 """Check if species is threatened"""
121 threatened_statuses: set[ConservationStatus] = {
122 ConservationStatus.CRITICALLY_ENDANGERED,
123 ConservationStatus.ENDANGERED,
124 ConservationStatus.VULNERABLE
125 }
126 return self.status in threatened_statuses
127
128 def get_risk_assessment(self) -> dict[str, str | int | bool]:
129 """
130 Risk assessment for the expedition
131
132 Returns:
133 Dictionary with risk assessment
134 """
135 risk_level: int = 0
136
137 if self.dangerous:
138 risk_level += 5
139
140 if self.population < 100:
141 risk_level += 3 # Rare - hard to find
142
143 if self.habitat == Habitat.JUNGLE:
144 risk_level += 2 # Difficult terrain
145
146 return {
147 "species": self.common_name,
148 "risk_level": min(10, risk_level),
149 "dangerous": self.dangerous,
150 "rare": self.population < 100,
151 "recommendation": "Extreme caution" if risk_level >= 7 else "Normal protocol"
152 }
153
154 @classmethod
155 def get_by_id(cls, species_id: SpeciesID) -> Optional['Species']:
156 """
157 Find species by ID
158
159 Returns:
160 Species or None if not found
161 """
162 return cls._registry.get(species_id)
163
164 @classmethod
165 def get_by_habitat(cls, habitat: Habitat) -> list['Species']:
166 """Return all species from a given habitat"""
167 return [
168 species for species in cls._registry.values()
169 if species.habitat == habitat
170 ]
171
172 @classmethod
173 def get_endangered(cls) -> list['Species']:
174 """Return all threatened species"""
175 return [
176 species for species in cls._registry.values()
177 if species.is_threatened()
178 ]
179
180 @staticmethod
181 def calculate_biodiversity_index(
182 species_list: list['Species']
183 ) -> float:
184 """
185 Calculate Simpson's biodiversity index
186
187 Args:
188 species_list: List of species to analyze
189
190 Returns:
191 Index (0.0 - 1.0, higher = greater diversity)
192 """
193 if not species_list:
194 return 0.0
195
196 total: int = sum(s.population for s in species_list)
197 if total == 0:
198 return 0.0
199
200 sum_squares: float = sum(
201 (s.population / total) ** 2
202 for s in species_list
203 )
204
205 return 1.0 - sum_squares
206
207 def __str__(self) -> str:
208 return f"{self.common_name} ({self.population} individuals)"
209
210 def __repr__(self) -> str:
211 return (f"Species(scientific_name='{self.scientific_name}', "
212 f"population={self.population}, "
213 f"habitat={self.habitat.value})")
214
215# === HELPER FUNCTIONS ===
216
217def generate_report(species: Species) -> str:
218 """
219 Generate a species report
220
221 Args:
222 species: Species to report on
223
224 Returns:
225 Formatted text report
226 """
227 threatened: str = "YES" if species.is_threatened() else "No"
228 risk: dict[str, str | int | bool] = species.get_risk_assessment()
229
230 report: str = f"""
231╔══════════════════════════════════════════════════╗
232 {species.common_name.upper()}
233╠══════════════════════════════════════════════════╣
234 Scientific name: {species.scientific_name}
235 Population: {species.population} individuals
236 Habitat: {species.habitat.value}
237 Status: {species.status.value}
238 Threatened: {threatened}
239 Dangerous: {"YES" if species.dangerous else "No"}
240 Risk level: {risk['risk_level']}/10
241 Observations: {len(species.observations)}
242 Locations: {', '.join(species.get_observation_locations()) or 'None'}
243╚══════════════════════════════════════════════════╝
244 """.strip()
245
246 return report
247
248def filter_by_population(
249 species_list: list[Species],
250 min_pop: int,
251 max_pop: int | None = None
252) -> list[Species]:
253 """
254 Filter species by population
255
256 Args:
257 species_list: List of species
258 min_pop: Minimum population
259 max_pop: Maximum population (optional)
260
261 Returns:
262 Filtered list
263 """
264 filtered: list[Species] = [
265 s for s in species_list
266 if s.population >= min_pop
267 ]
268
269 if max_pop is not None:
270 filtered = [s for s in filtered if s.population <= max_pop]
271
272 return filtered
273
274# === DEMONSTRATION ===
275
276print("=== SPECIES CATALOG WITH TYPE HINTS ===\n")
277
278# Creating species
279lion = Species(
280 scientific_name="Panthera leo",
281 common_name="Lion",
282 population=120,
283 habitat=Habitat.SAVANNA,
284 status=ConservationStatus.VULNERABLE,
285 dangerous=True
286)
287
288rhino = Species(
289 scientific_name="Diceros bicornis",
290 common_name="Black Rhinoceros",
291 population=45,
292 habitat=Habitat.SAVANNA,
293 status=ConservationStatus.CRITICALLY_ENDANGERED,
294 dangerous=True
295)
296
297elephant = Species(
298 scientific_name="Loxodonta africana",
299 common_name="African Elephant",
300 population=450,
301 habitat=Habitat.SAVANNA,
302 status=ConservationStatus.ENDANGERED,
303 dangerous=False
304)
305
306# Add observations
307lion.add_observation("Serengeti", (-2.3333, 34.8333), 12)
308lion.add_observation("Masai Mara", (-1.5, 35.1667), 8)
309rhino.add_observation("Ngorongoro", (-3.1792, 35.5500), 3)
310elephant.add_observation("Amboseli", (-2.6527, 37.2606), 35)
311
312# Reports
313print(generate_report(lion))
314print()
315print(generate_report(rhino))
316
317# Filtering
318print("\n=== THREATENED SPECIES ===")
319endangered: list[Species] = Species.get_endangered()
320for species in endangered:
321 print(f" {species.common_name}: {species.status.value}")
322
323# Filter by population
324print("\n=== SPECIES WITH POPULATION < 100 ===")
325rare: list[Species] = filter_by_population(
326 list(Species._registry.values()),
327 min_pop=0,
328 max_pop=99
329)
330for species in rare:
331 print(f" - {species.common_name}: {species.population} individuals")
332
333# Biodiversity index
334biodiversity: float = Species.calculate_biodiversity_index(
335 list(Species._registry.values())
336)
337print(f"\nBiodiversity index: {biodiversity:.3f}")Tools for type checking
mypy - static type checking
The mypy tool reads the annotations and checks the types before you run the program. You install it with pip and point it at the file to check:
1# Installation
2pip install mypy
3
4# Check a file
5mypy script.pyPython runs this code without an error, but mypy stops it:
1def add(a: int, b: int) -> int:
2 return a + b
3
4result: str = add(5, 10) # mypy: Incompatible types in assignmentThe function returns int, but the variable is declared as str. Python doesn't check annotations while the program runs, so only mypy or your editor will catch this mistake.
Type checking in IDEs
Modern IDEs (VS Code, PyCharm) automatically check type hints:
- Autocomplete - suggestions based on types
- Error detection - detecting type errors
- Refactoring - safer changes
- Documentation - quick signature preview
Type hints best practices
1. Always add type hints to public APIs
Methods that the rest of the program uses are a contract with other programmers. Annotations tell right away what those methods expect and what they return:
1# Good - public API with type hints
2class Species:
3 def add_observation(self, location: str, count: int) -> None:
4 pass
5
6# Bad - public API without types
7class Species:
8 def add_observation(self, location, count):
9 pass2. Use specific types instead of Any
Any tells the reader nothing and checks nothing. If you know what data comes in, write it in the annotation:
1# Bad - too generic
2def process_data(data: Any) -> Any:
3 return data
4
5# Good - specific types
6def process_data(data: dict[str, int]) -> list[int]:
7 return list(data.values())3. Use Optional for None values
If a function can return None, say so in the annotation. Then everyone who calls it knows they have to check the result:
1# Good - clearly specified
2def find_species(name: str) -> Species | None:
3 pass
4
5# Misleading - does it return None?
6def find_species(name: str) -> Species:
7 pass4. Document complex types with TypeAlias
When the same complex type appears in several places, give it a name. The function signature gets shorter and its meaning is clear at once:
1# Readable
2ObservationData: TypeAlias = dict[str, str | int | date]
3
4def add_observation(data: ObservationData) -> None:
5 pass
6
7# Unreadable
8def add_observation(data: dict[str, str | int | date]) -> None:
9 passSummary
In this lesson you learned:
- What type hints are and why to use them
- Basic annotations: int, str, float, bool, list, dict
- Optional and Union (and the
|syntax in Python 3.10+) - Type hints for functions (parameters and return types)
- Type hints for classes and methods
- Advanced techniques: Callable, TypeAlias, Generic
- Enums for safe values
- Tools like mypy
- Type hints best practices
Checkpoint
Before moving on:
- You understand the difference between type hints and runtime behavior
- You can add type hints to functions
- You know Optional and Union (and
|) - You know how to use list[T], dict[K, V]
- You understand TypeAlias and Generic
Safari Analogy: Type hints are like precise biological classification - instead of "big cat" we say "Panthera leo, male, 5 years, 190kg" - everything is clear!
In the next lesson Darwin will teach you decorators - a powerful tool for modifying function and class behaviors!
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Type hints in Python are:
2. How do you annotate a function's return type?
Hands-on tasks in the game
- Vertical ordering
Arrange the typical elements of a class definition:
- Code editor
Create an Animal class with type hints: name: str, age: int, weight: float
- Horizontal ordering
Arrange the header of a speak method that returns text (type hint str):