Python · Syntax · Advanced
Type hints and dataclasses
Static annotations that document intent and power IDE tooling. Covers function signatures, `Optional`, `Union`, `TypedDict`, `Protocol`, and `dataclasses`.
Quick topic start and explanations before exercises (exercises below):
TypeVar, Generic, Callable, and @overload in depth
#dataclasses, Protocol, runtime checking, quick reference
#Exercises:
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
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
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
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
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
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
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) # []
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
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
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