Python · Syntax · Advanced

Type hints and dataclasses

10 tasks

Static annotations that document intent and power IDE tooling. Covers function signatures, `Optional`, `Union`, `TypedDict`, `Protocol`, and `dataclasses`.

Type hints: basics, Union/Optional, new syntax (3.9+, 3.10+)

#
**What type hints are and why they exist** Python is dynamically typed — you can assign any value to any variable at runtime. Type hints don't change that. They're annotations that tools like `mypy`, `pyright`, and your IDE read to catch errors *before* you run the code. ```python def greet(name: str) -> str: return 'Hello, ' + name greet(42) # mypy: Argument 1 has incompatible type 'int'; expected 'str' # Python still runs this — mypy won't stop you, it just warns ``` Type hints live in `__annotations__` and are erased at runtime by default. They cost nothing to execute. **Evolution of the syntax** Before Python 3.9, the built-in collection types (`list`, `dict`, `tuple`, `set`) couldn't be used directly as generic types in annotations — you had to import capitalized versions from `typing`: ```python # Python 3.8 and older (still valid everywhere) from typing import List, Dict, Tuple, Set, Optional, Union def process(items: List[str]) -> Dict[str, int]: return {item: len(item) for item in items} # Python 3.9+ — use built-in types directly def process(items: list[str]) -> dict[str, int]: return {item: len(item) for item in items} ``` **`Optional` and `Union`** `Optional[X]` means the value is either `X` or `None`. It's shorthand for `Union[X, None]`. Python 3.10 introduced the `|` syntax as a cleaner alternative: ```python from typing import Optional, Union # These four are equivalent: def f1(x: Optional[str]) -> None: ... def f2(x: Union[str, None]) -> None: ... def f3(x: str | None) -> None: ... # 3.10+ def f4(x: str | None) -> None: ... # same # Union with multiple types: def accept(value: int | str | float) -> str: return str(value) ``` **`Any` and `object`** These look similar but behave very differently: ```python from typing import Any def takes_any(x: Any) -> None: x.whatever() # mypy allows this — Any opts out of type checking def takes_object(x: object) -> None: x.whatever() # mypy ERROR — object doesn't have .whatever() ``` `Any` is an escape hatch that silences the type checker completely. `object` is the root of the class hierarchy — every object is an `object`, but the type checker knows `object` has very few guaranteed attributes. **`Final` and `ClassVar`** ```python from typing import Final, ClassVar MAX_SIZE: Final = 100 # cannot be reassigned MAX_SIZE = 200 # mypy error: cannot assign to final name class Config: VERSION: ClassVar[str] = '1.0' # belongs to class, not instances # instance attributes should NOT be ClassVar ```

TypeVar, Generic, Callable, and @overload in depth

#
**`TypeVar` — type variables for generic functions** A `TypeVar` lets you write a function that preserves the relationship between input and output types without pinning them to a concrete type: ```python from typing import TypeVar T = TypeVar('T') def first(items: list[T]) -> T: return items[0] x: int = first([1, 2, 3]) # T is inferred as int y: str = first(['a', 'b']) # T is inferred as str z: int = first(['a', 'b']) # mypy error: str, not int ``` Without `TypeVar`, you'd have to use `Any` and lose the type relationship. **Bounded `TypeVar`** Use `bound=` to say 'T must be this type or a subclass': ```python from typing import TypeVar class Animal: def speak(self) -> str: ... A = TypeVar('A', bound=Animal) def make_speak(animal: A) -> A: animal.speak() return animal # returns the same subtype, not just Animal ``` **`Generic` — typed container classes** ```python from typing import TypeVar, Generic T = TypeVar('T') class Stack(Generic[T]): def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop() s: Stack[int] = Stack() s.push(1) # OK s.push('oops') # mypy error: expected int, got str ``` **`Callable` — typing function arguments** ```python from typing import Callable # Callable[[arg_types], return_type] def apply(func: Callable[[int, int], int], a: int, b: int) -> int: return func(a, b) apply(lambda x, y: x + y, 3, 4) # OK # Callable with any arguments: def run(callback: Callable[..., None]) -> None: callback() ``` **`@overload` — multiple signatures for one function** `@overload` lets you describe multiple distinct call signatures that return different types depending on input: ```python from typing import overload @overload def process(x: int) -> int: ... @overload def process(x: str) -> str: ... def process(x): # actual implementation — no annotation needed if isinstance(x, int): return x * 2 return x.upper() a: int = process(5) # mypy knows this is int b: str = process('hi') # mypy knows this is str ```

dataclasses, Protocol, runtime checking, quick reference

#
**`dataclasses` with type hints** `@dataclass` uses your field annotations to auto-generate `__init__`, `__repr__`, and `__eq__`. The types you write there are real hints checked by mypy: ```python from dataclasses import dataclass, field from typing import ClassVar @dataclass class Point: x: float y: float label: str = '' # default value tags: list[str] = field(default_factory=list) # mutable default _count: ClassVar[int] = 0 # not an instance field p = Point(1.0, 2.0, label='origin') # p is automatically: Point(x=1.0, y=2.0, label='origin', tags=[]) ``` `field(default_factory=list)` is essential for mutable defaults — never use `tags: list[str] = []` directly or all instances share the same list. `__post_init__` runs after the generated `__init__` — use it for validation: ```python @dataclass class PositivePoint: x: float y: float def __post_init__(self): if self.x < 0 or self.y < 0: raise ValueError('coordinates must be non-negative') ``` **`Protocol` — structural typing (duck typing + type safety)** A `Protocol` describes a shape — methods and attributes something must have — without requiring explicit inheritance: ```python from typing import Protocol class Drawable(Protocol): def draw(self) -> None: ... class Circle: def draw(self) -> None: # implements Drawable without inheriting it print('drawing circle') def render(shape: Drawable) -> None: shape.draw() render(Circle()) # OK — Circle structurally matches Drawable render(42) # mypy error: int has no .draw() ``` **Runtime type checking with `isinstance` and `get_type_hints`** Annotations are not enforced at runtime by default. To check at runtime: ```python from typing import get_type_hints def enforce(func, *args, **kwargs): hints = get_type_hints(func) params = list(func.__code__.co_varnames) for arg, val in zip(params, args): expected = hints.get(arg) if expected and not isinstance(val, expected): raise TypeError(f'{arg}: expected {expected}, got {type(val)}') return func(*args, **kwargs) ``` **Quick reference** | Annotation | Meaning | |---|---| | `x: int` | x is an int | | `x: int \| None` | x is int or None (3.10+) | | `x: Optional[int]` | same, pre-3.10 | | `x: list[int]` | list of ints (3.9+) | | `x: dict[str, int]` | dict with str keys, int values | | `x: tuple[int, str]` | exactly (int, str) | | `x: tuple[int, ...]` | variable-length tuple of ints | | `x: Any` | no checking | | `x: Final` | cannot reassign | | `x: ClassVar[int]` | class-level attribute |
01

Annotate function signatures

#

Add complete type annotations to the following functions: `greet(name)` returns a str; `add(a, b)` takes two ints and returns an int; `find_first(items, value)` takes a list of ints and an int, returns `int | None` (the index if found, else None).

def greet(name):
    return f'Hello, {name}!'

def add(a, b):
    return a + b

def find_first(items, value):
    for i, v in enumerate(items):
        if v == value:
            return i
    return None


print(greet('Alice'))           # Hello, Alice!
print(add(2, 3))                # 5
print(find_first([1,2,3], 2))   # 1
print(find_first([1,2,3], 9))   # None
Solution
def greet(name: str) -> str:
    return f'Hello, {name}!'

def add(a: int, b: int) -> int:
    return a + b

def find_first(items: list[int], value: int) -> int | None:
    for i, v in enumerate(items):
        if v == value:
            return i
    return None


print(greet('Alice'))           # Hello, Alice!
print(add(2, 3))                # 5
print(find_first([1,2,3], 2))   # 1
print(find_first([1,2,3], 9))   # None
02

TypedDict for API response

#

Define a `TypedDict` called `UserResponse` with keys: `id` (int), `name` (str), `email` (str), `is_active` (bool). Then write a function `parse_user(data: dict) -> UserResponse` that returns the data typed as `UserResponse`.

from typing import TypedDict

# Define UserResponse TypedDict here


def parse_user(data: dict) -> 'UserResponse':
    pass


raw = {'id': 1, 'name': 'Alice', 'email': '[email protected]', 'is_active': True}
user = parse_user(raw)
print(user['name'])      # Alice
print(user['is_active']) # True
Solution
from typing import TypedDict

class UserResponse(TypedDict):
    id: int
    name: str
    email: str
    is_active: bool


def parse_user(data: dict) -> UserResponse:
    return UserResponse(
        id=data['id'],
        name=data['name'],
        email=data['email'],
        is_active=data['is_active'],
    )


raw = {'id': 1, 'name': 'Alice', 'email': '[email protected]', 'is_active': True}
user = parse_user(raw)
print(user['name'])      # Alice
print(user['is_active']) # True
03

Protocol for structural typing

#

Define a `Protocol` called `Drawable` with a method `draw() -> str`. Write a function `render(item: Drawable) -> str` that calls `item.draw()`. Create two classes `Circle` and `Square` (no inheritance needed) that each implement `draw()`. Verify both work with `render()`.

from typing import Protocol

class Drawable(Protocol):
    pass  # add draw() method signature

def render(item: Drawable) -> str:
    pass


class Circle:
    def draw(self) -> str:
        return 'Drawing Circle'

class Square:
    def draw(self) -> str:
        return 'Drawing Square'

print(render(Circle()))  # Drawing Circle
print(render(Square()))  # Drawing Square
Solution
from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str: ...

def render(item: Drawable) -> str:
    return item.draw()


class Circle:
    def draw(self) -> str:
        return 'Drawing Circle'

class Square:
    def draw(self) -> str:
        return 'Drawing Square'

print(render(Circle()))  # Drawing Circle
print(render(Square()))  # Drawing Square
04

Frozen dataclass: Point

#

Create a `frozen=True` dataclass `Point` with fields `x: float` and `y: float`. Add a method `distance_to(other: 'Point') -> float` that computes Euclidean distance. Verify that trying to assign `p.x = 5` raises a `FrozenInstanceError`.

from dataclasses import dataclass
import math

# Define Point as a frozen dataclass


p1 = Point(0, 0)
p2 = Point(3, 4)
print(p1.distance_to(p2))  # 5.0
print(p1 == Point(0, 0))   # True
try:
    p1.x = 10              # FrozenInstanceError
except Exception as e:
    print(type(e).__name__) # FrozenInstanceError
Solution
from dataclasses import dataclass
import math

@dataclass(frozen=True)
class Point:
    x: float
    y: float

    def distance_to(self, other: 'Point') -> float:
        return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)


p1 = Point(0, 0)
p2 = Point(3, 4)
print(p1.distance_to(p2))  # 5.0
print(p1 == Point(0, 0))   # True
try:
    p1.x = 10
except Exception as e:
    print(type(e).__name__) # FrozenInstanceError
05

Dataclass with __post_init__ validation

#

Create a dataclass `BankAccount` with fields `owner: str` and `balance: float`. Use `__post_init__` to raise `ValueError` if `balance` is negative. Add a method `deposit(amount: float)` that increases balance, and `withdraw(amount: float)` that raises `ValueError` if the amount exceeds balance.

from dataclasses import dataclass

@dataclass
class BankAccount:
    owner: str
    balance: float

    def __post_init__(self):
        pass

    def deposit(self, amount: float) -> None:
        pass

    def withdraw(self, amount: float) -> None:
        pass


acc = BankAccount('Alice', 100.0)
acc.deposit(50)
print(acc.balance)  # 150.0
acc.withdraw(30)
print(acc.balance)  # 120.0
try:
    BankAccount('Bob', -50)  # ValueError
except ValueError as e:
    print(e)
Solution
from dataclasses import dataclass

@dataclass
class BankAccount:
    owner: str
    balance: float

    def __post_init__(self):
        if self.balance < 0:
            raise ValueError(f'Balance cannot be negative: {self.balance}')

    def deposit(self, amount: float) -> None:
        self.balance += amount

    def withdraw(self, amount: float) -> None:
        if amount > self.balance:
            raise ValueError(f'Insufficient funds: {self.balance} < {amount}')
        self.balance -= amount


acc = BankAccount('Alice', 100.0)
acc.deposit(50)
print(acc.balance)  # 150.0
acc.withdraw(30)
print(acc.balance)  # 120.0
try:
    BankAccount('Bob', -50)
except ValueError as e:
    print(e)  # Balance cannot be negative: -50
06

Callable type hints

#

Write a function `apply_twice(func: ..., value: int) -> int` that applies `func` to `value` twice. Annotate `func` with the correct `Callable` type (a function that takes an int and returns an int). Also write `compose(f, g)` that returns a new function `h(x)` = `f(g(x))`, with correct Callable annotations.

from typing import Callable

def apply_twice(func, value: int) -> int:
    pass

def compose(f, g):
    pass


double = lambda x: x * 2
add_one = lambda x: x + 1

print(apply_twice(double, 3))            # 12
print(apply_twice(add_one, 5))           # 7
double_then_add = compose(add_one, double)
print(double_then_add(4))                # 9  (4*2 + 1)
Solution
from typing import Callable

def apply_twice(func: Callable[[int], int], value: int) -> int:
    return func(func(value))

def compose(
    f: Callable[[int], int],
    g: Callable[[int], int],
) -> Callable[[int], int]:
    return lambda x: f(g(x))


double = lambda x: x * 2
add_one = lambda x: x + 1

print(apply_twice(double, 3))            # 12
print(apply_twice(add_one, 5))           # 7
double_then_add = compose(add_one, double)
print(double_then_add(4))                # 9
07

Dataclass with field() and default_factory

#

Create a dataclass `Playlist` with fields: `name: str` (required), `songs: list[str]` defaulting to an empty list, and `max_length: int` defaulting to 100. Use `field(default_factory=list)` for `songs` — not a bare `[]` default. Add an `add_song(song: str)` method.

from dataclasses import dataclass, field

@dataclass
class Playlist:
    name: str
    # songs should default to empty list — use field(default_factory=list)
    # max_length should default to 100

    def add_song(self, song: str) -> None:
        pass


p1 = Playlist('Rock')
p2 = Playlist('Pop')
p1.add_song('Bohemian Rhapsody')
print(p1.songs)  # ['Bohemian Rhapsody']
print(p2.songs)  # []  -- p2 has its own list
Solution
from dataclasses import dataclass, field

@dataclass
class Playlist:
    name: str
    songs: list[str] = field(default_factory=list)
    max_length: int = 100

    def add_song(self, song: str) -> None:
        self.songs.append(song)


p1 = Playlist('Rock')
p2 = Playlist('Pop')
p1.add_song('Bohemian Rhapsody')
print(p1.songs)  # ['Bohemian Rhapsody']
print(p2.songs)  # []
08

Generic Stack with TypeVar

#

Use `TypeVar` to create a generic `Stack[T]` class that works with any type. Implement `push(item: T)`, `pop() -> T`, `peek() -> T`, and `is_empty() -> bool`. `pop` and `peek` should raise `IndexError` on an empty stack.

from typing import TypeVar, Generic

T = TypeVar('T')

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        pass

    def pop(self) -> T:
        pass

    def peek(self) -> T:
        pass

    def is_empty(self) -> bool:
        pass


s: Stack[int] = Stack()
s.push(1)
s.push(2)
print(s.peek())    # 2
print(s.pop())     # 2
print(s.pop())     # 1
print(s.is_empty()) # True
Solution
from typing import TypeVar, Generic

T = TypeVar('T')

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        if not self._items:
            raise IndexError('pop from empty stack')
        return self._items.pop()

    def peek(self) -> T:
        if not self._items:
            raise IndexError('peek at empty stack')
        return self._items[-1]

    def is_empty(self) -> bool:
        return len(self._items) == 0


s: Stack[int] = Stack()
s.push(1)
s.push(2)
print(s.peek())     # 2
print(s.pop())      # 2
print(s.pop())      # 1
print(s.is_empty()) # True
09

Literal type for restricted values

#

Write a function `set_log_level(level: ...)` that accepts only one of `'DEBUG'`, `'INFO'`, `'WARNING'`, `'ERROR'` using `Literal`. It should print `'Log level set to: <level>'`. Also annotate a variable `direction` as `Literal['N', 'S', 'E', 'W']`.

from typing import Literal

LogLevel = Literal['DEBUG', 'INFO', 'WARNING', 'ERROR']

def set_log_level(level: LogLevel) -> None:
    pass


set_log_level('DEBUG')    # Log level set to: DEBUG
set_log_level('ERROR')    # Log level set to: ERROR
# set_log_level('TRACE')  # type error (not in Literal)

direction: Literal['N', 'S', 'E', 'W'] = 'N'
print(direction)
Solution
from typing import Literal

LogLevel = Literal['DEBUG', 'INFO', 'WARNING', 'ERROR']

def set_log_level(level: LogLevel) -> None:
    print(f'Log level set to: {level}')


set_log_level('DEBUG')
set_log_level('ERROR')

direction: Literal['N', 'S', 'E', 'W'] = 'N'
print(direction)  # N
10

Convert namedtuple to dataclass

#

Convert the following `namedtuple` to a `@dataclass` with the same fields. Then add a `full_name` property that returns `f'{first} {last}'` and an `is_adult` property that returns `True` if `age >= 18`.

from collections import namedtuple

# Original namedtuple (convert this to a dataclass):
# Person = namedtuple('Person', ['first', 'last', 'age'])

from dataclasses import dataclass

# Your dataclass here:


p = Person('Alice', 'Smith', 30)
print(p.full_name)  # Alice Smith
print(p.is_adult)   # True
print(p.age)        # 30
Solution
from dataclasses import dataclass

@dataclass
class Person:
    first: str
    last: str
    age: int

    @property
    def full_name(self) -> str:
        return f'{self.first} {self.last}'

    @property
    def is_adult(self) -> bool:
        return self.age >= 18


p = Person('Alice', 'Smith', 30)
print(p.full_name)  # Alice Smith
print(p.is_adult)   # True
print(p.age)        # 30