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
#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
#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
#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
#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
#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
#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
#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
#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
#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
#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