Python course · Module 3: Object-Oriented Programming

Type hints - precise classification

14 min read
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 / area

Basic 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 None

Types 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 - str

Python 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    pass

List, 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"})  # OK

Note: 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 >= 0

Advanced 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.py

Python 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 assignment

The 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        pass

2. 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    pass

4. 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    pass

Summary

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. 1. Type hints in Python are:

  2. 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):

Useful articles