Skip to main content
Advertisement

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

ToolCharacteristicsPrimary Use Cases
TypedDictTyped dictionary structureJSON APIs, config, external data
NamedTupleImmutable tuple + named accessCoordinates, records, lightweight value objects
ProtocolStructural subtypingInterface definitions, dependency inversion

All three tools achieve type safety in a Pythonic way — without explicit inheritance.

Advertisement