TypedDict, NamedTuple, Protocol
Three core tools for handling structured data in a type-safe way in Python. Each specializes in a different use case: dictionary structure (TypedDict), immutable records (NamedTuple), and duck-typed interfaces (Protocol).
TypedDict — Typed Dictionary Definitions
from typing import TypedDict, Required, NotRequired
# Basic TypedDict
class Point(TypedDict):
x: float
y: float
class UserProfile(TypedDict):
id: int
name: str
email: str
age: int
# Usage
p: Point = {"x": 1.0, "y": 2.0}
user: UserProfile = {"id": 1, "name": "Alice", "email": "alice@example.com", "age": 30}
print(p["x"])
print(user["name"])
# Python 3.11+: Required / NotRequired key specification
class Config(TypedDict):
host: Required[str] # Must be present
port: Required[int]
debug: NotRequired[bool] # Optional (no default)
timeout: NotRequired[float] # Optional (no default)
config: Config = {"host": "localhost", "port": 8080} # debug, timeout can be omitted
config_full: Config = {"host": "localhost", "port": 8080, "debug": True, "timeout": 30.0}
# total=False: all keys are optional
class PartialUser(TypedDict, total=False):
name: str
email: str
age: int
partial: PartialUser = {"name": "Bob"} # rest can be omitted
TypedDict Inheritance and Nesting
from typing import TypedDict
class BaseEntity(TypedDict):
id: int
created_at: str
updated_at: str
class Address(TypedDict):
street: str
city: str
country: str
postal_code: str
class Person(BaseEntity): # Inherits from BaseEntity
name: str
email: str
address: Address # Nested TypedDict
# Type-safe JSON serialization function
import json
from typing import Any
def to_json(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)
person: Person = {
"id": 1,
"created_at": "2024-01-01",
"updated_at": "2024-01-15",
"name": "Alice",
"email": "alice@example.com",
"address": {
"street": "123 Main St",
"city": "New York",
"country": "USA",
"postal_code": "10001",
},
}
print(to_json(person))
NamedTuple — Immutable Records
from typing import NamedTuple
class Point2D(NamedTuple):
x: float
y: float
class Point3D(NamedTuple):
x: float
y: float
z: float = 0.0 # Default value supported
class Employee(NamedTuple):
name: str
department: str
salary: float
is_remote: bool = False
# Usage
p = Point2D(1.0, 2.0)
print(p.x, p.y) # Attribute access
print(p[0], p[1]) # Index access (it's a tuple)
print(p._asdict()) # {'x': 1.0, 'y': 2.0}
# Immutability
try:
p.x = 3.0 # AttributeError!
except AttributeError as e:
print(f"Error: {e}")
# Create a new instance with _replace()
p2 = p._replace(x=10.0)
print(p2) # Point2D(x=10.0, y=2.0)
# With structural pattern matching
def classify_employee(emp: Employee) -> str:
match emp:
case Employee(department="Engineering", salary=s) if s > 10000:
return "High-earner engineer"
case Employee(is_remote=True):
return "Remote worker"
case _:
return "Regular employee"
alice = Employee("Alice", "Engineering", 12000.0)
bob = Employee("Bob", "Marketing", 8000.0, is_remote=True)
print(classify_employee(alice)) # High-earner engineer
print(classify_employee(bob)) # Remote worker
Protocol — Structural Subtyping
from typing import Protocol, runtime_checkable
# Protocol definition: only method signatures
class Drawable(Protocol):
def draw(self) -> None: ...
def get_color(self) -> str: ...
class Resizable(Protocol):
def resize(self, factor: float) -> None: ...
# Classes that satisfy the protocol (no explicit inheritance needed)
class Circle:
def __init__(self, radius: float, color: str = "red"):
self.radius = radius
self.color = color
def draw(self) -> None:
print(f"Drawing circle: radius={self.radius}, color={self.color}")
def get_color(self) -> str:
return self.color
def resize(self, factor: float) -> None:
self.radius *= factor
class Square:
def __init__(self, side: float, color: str = "blue"):
self.side = side
self.color = color
def draw(self) -> None:
print(f"Drawing square: side={self.side}, color={self.color}")
def get_color(self) -> str:
return self.color
def render(shapes: list[Drawable]) -> None:
for shape in shapes:
shape.draw()
render([Circle(5.0), Square(3.0)]) # Both satisfy Drawable protocol
# @runtime_checkable: enables isinstance() checks
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict: ...
def to_json(self) -> str: ...
class Product:
def __init__(self, name: str, price: float):
self.name = name
self.price = price
def to_dict(self) -> dict:
return {"name": self.name, "price": self.price}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict())
p = Product("Python Book", 35.0)
print(isinstance(p, Serializable)) # True
Protocol Inheritance and Composite Protocols
from typing import Protocol, runtime_checkable
class Reader(Protocol):
def read(self, n: int = -1) -> bytes: ...
class Writer(Protocol):
def write(self, data: bytes) -> int: ...
class ReadWriter(Reader, Writer, Protocol):
"""Reader + Writer composite protocol"""
pass
class Closeable(Protocol):
def close(self) -> None: ...
class Stream(ReadWriter, Closeable, Protocol):
"""Full stream protocol"""
pass
# Real-world: function that handles file-like objects
def copy_data(src: Reader, dst: Writer, chunk_size: int = 4096) -> int:
total = 0
while True:
data = src.read(chunk_size)
if not data:
break
written = dst.write(data)
total += written
return total
# In-memory buffer implementation (satisfies protocol without explicit inheritance)
import io
src_buf = io.BytesIO(b"Hello, Protocol World!")
dst_buf = io.BytesIO()
bytes_copied = copy_data(src_buf, dst_buf)
print(f"Bytes copied: {bytes_copied}")
print(dst_buf.getvalue()) # b'Hello, Protocol World!'
Pro Tips
1. TypedDict vs dataclass vs NamedTuple — When to Use What
from dataclasses import dataclass
from typing import TypedDict, NamedTuple
# TypedDict: JSON/dict interfaces, only type checking needed
class APIResponse(TypedDict):
status: int
data: dict
message: str
# NamedTuple: immutable records, index access needed, memory efficient
class Coordinate(NamedTuple):
lat: float
lon: float
alt: float = 0.0
# dataclass: mutable objects, adding methods, defaults, inheritance needed
@dataclass
class Rectangle:
width: float
height: float
@property
def area(self) -> float:
return self.width * self.height
# Comparison
coord = Coordinate(37.5665, 126.9780)
print(coord.lat, coord.lon)
print(coord[0], coord[1]) # Index access supported
rect = Rectangle(3.0, 4.0)
rect.width = 5.0 # Mutable
print(rect.area) # 20.0
2. Dependency Inversion with Protocol
from typing import Protocol
# Bad: depends on a concrete class
class MySQLDatabase:
def execute(self, sql: str) -> list:
return [] # Actual implementation
class UserService:
def __init__(self, db: MySQLDatabase): # Tightly coupled to MySQL
self.db = db
# Good: depends on a Protocol (dependency inversion)
class DatabaseProtocol(Protocol):
def execute(self, sql: str) -> list: ...
def commit(self) -> None: ...
def rollback(self) -> None: ...
class UserServiceV2:
def __init__(self, db: DatabaseProtocol): # Any DB works
self.db = db
def get_user(self, user_id: int) -> dict | None:
rows = self.db.execute(f"SELECT * FROM users WHERE id = {user_id}")
return rows[0] if rows else None
# Testing: inject a fake DB
class FakeDatabase:
def execute(self, sql: str) -> list:
return [{"id": 1, "name": "Alice"}]
def commit(self) -> None:
pass
def rollback(self) -> None:
pass
service = UserServiceV2(FakeDatabase())
print(service.get_user(1))
Summary
| Tool | Characteristics | Primary Use Cases |
|---|---|---|
TypedDict | Typed dictionary structure | JSON APIs, config, external data |
NamedTuple | Immutable tuple + named access | Coordinates, records, lightweight value objects |
Protocol | Structural subtyping | Interface definitions, dependency inversion |
All three tools achieve type safety in a Pythonic way — without explicit inheritance.