Python TypedDict: Typed Dictionaries

Python dictionaries are everywhere—API responses, configuration files, database records, JSON data. But standard dictionaries are black boxes to type checkers. Access `user['name']` and your type...

Key Insights

  • TypedDict provides type hints for dictionaries with fixed string keys, enabling static type checkers to catch errors without runtime overhead or changing your dictionary-based code
  • Unlike dataclasses or Pydantic models, TypedDict works with plain dictionaries—perfect for APIs, JSON data, and codebases that must maintain dict compatibility
  • Use total=False, Required[], and NotRequired[] to precisely control which keys are mandatory, making TypedDict flexible enough for real-world data with optional fields

Introduction to TypedDict

Python dictionaries are everywhere—API responses, configuration files, database records, JSON data. But standard dictionaries are black boxes to type checkers. Access user["name"] and your type checker has no idea if that key exists or what type the value should be.

TypedDict solves this by letting you define the exact structure of dictionaries with known keys. It’s pure type annotation—no runtime changes, no new classes, just better type safety for your existing dict-based code.

# Without TypedDict: type checker knows nothing
def get_user_display(user: dict) -> str:
    return f"{user['name']} ({user['email']})"  # No type checking

# With TypedDict: full type safety
from typing import TypedDict

class User(TypedDict):
    name: str
    email: str
    age: int

def get_user_display(user: User) -> str:
    return f"{user['name']} ({user['email']})"  # Type checked!

When you run mypy or pyright on the TypedDict version, it verifies that all required keys exist and values match the declared types. But at runtime, User instances are just regular dictionaries—no performance cost, no compatibility issues.

Basic TypedDict Syntax

TypedDict offers two definition styles: class-based and functional. The class-based syntax is cleaner and more common.

from typing import TypedDict

# Class-based syntax (preferred)
class ServerConfig(TypedDict):
    host: str
    port: int
    debug: bool
    max_connections: int

# Functional syntax (useful for dynamic creation)
ServerConfig = TypedDict('ServerConfig', {
    'host': str,
    'port': int,
    'debug': bool,
    'max_connections': int
})

# Usage is identical—just plain dicts
config: ServerConfig = {
    'host': 'localhost',
    'port': 8000,
    'debug': True,
    'max_connections': 100
}

print(config['host'])  # Type checker knows this is a str

The functional syntax is necessary when key names aren’t valid Python identifiers or when you’re generating TypedDicts programmatically. For 99% of use cases, stick with the class-based syntax.

Here’s a practical example for a user profile:

class UserProfile(TypedDict):
    user_id: int
    username: str
    email: str
    created_at: str  # ISO format timestamp
    is_active: bool

def create_user(username: str, email: str) -> UserProfile:
    return {
        'user_id': generate_id(),
        'username': username,
        'email': email,
        'created_at': datetime.now().isoformat(),
        'is_active': True
    }

Required vs Optional Keys

By default, all TypedDict keys are required (total=True). But real-world data often has optional fields. TypedDict provides several ways to handle this.

from typing import TypedDict, NotRequired, Required

# All keys optional
class PartialUser(TypedDict, total=False):
    name: str
    email: str
    phone: str

# Mix required and optional (Python 3.11+)
class APIResponse(TypedDict):
    status: str  # Required
    data: dict  # Required
    error: NotRequired[str]  # Optional
    request_id: NotRequired[str]  # Optional

# Make specific keys required when total=False
class OptionalConfig(TypedDict, total=False):
    host: Required[str]  # Must be present
    port: Required[int]  # Must be present
    timeout: int  # Optional
    retries: int  # Optional

This is invaluable for API responses where some fields only appear on errors:

class UserAPIResponse(TypedDict):
    success: bool
    user_id: NotRequired[int]
    username: NotRequired[str]
    error_message: NotRequired[str]
    error_code: NotRequired[str]

def handle_response(response: UserAPIResponse) -> None:
    if response['success']:
        # Type checker knows these might not exist
        user_id = response.get('user_id')
        if user_id:
            print(f"Created user {user_id}")
    else:
        error = response.get('error_message', 'Unknown error')
        print(f"Error: {error}")

Inheritance and Composition

TypedDicts support inheritance, letting you build complex types from simpler ones. This is perfect for extending base models or creating variations.

class BaseUser(TypedDict):
    user_id: int
    username: str
    email: str

class AdminUser(BaseUser):
    permissions: list[str]
    admin_level: int
    can_delete_users: bool

class GuestUser(BaseUser):
    session_id: str
    expires_at: str

# You can also combine multiple TypedDicts
class Timestamped(TypedDict):
    created_at: str
    updated_at: str

class UserWithTimestamps(BaseUser, Timestamped):
    pass  # Inherits all fields from both parents

# Usage
admin: AdminUser = {
    'user_id': 1,
    'username': 'admin',
    'email': 'admin@example.com',
    'permissions': ['read', 'write', 'delete'],
    'admin_level': 3,
    'can_delete_users': True
}

This composition model works well for database models where you have common fields across tables:

class DBRecord(TypedDict):
    id: int
    created_at: str
    updated_at: str

class Product(DBRecord):
    name: str
    price: float
    stock: int

class Order(DBRecord):
    user_id: int
    product_ids: list[int]
    total: float
    status: str

TypedDict with Type Checkers

The real power of TypedDict emerges when you run static type checkers. Here’s what mypy catches:

class Article(TypedDict):
    title: str
    author: str
    views: int

# Valid usage
article: Article = {
    'title': 'Python TypedDict Guide',
    'author': 'Jane Doe',
    'views': 1500
}

# Error: missing required key 'views'
incomplete: Article = {
    'title': 'Incomplete Article',
    'author': 'John Smith'
}

# Error: wrong value type for 'views'
wrong_type: Article = {
    'title': 'Bad Article',
    'author': 'Bob',
    'views': '1500'  # Should be int, not str
}

# Error: accessing non-existent key
def print_article(article: Article) -> None:
    print(article['published_date'])  # Key doesn't exist in TypedDict

Type checkers also validate function returns:

def fetch_article(article_id: int) -> Article:
    # Error: return type doesn't match Article
    return {
        'title': 'Some Article',
        'author': 'Author Name'
        # Missing 'views' key
    }

Real-World Use Cases

TypedDict shines when working with external data formats. Here’s a FastAPI example:

from fastapi import FastAPI
from typing import TypedDict

app = FastAPI()

class CreateUserRequest(TypedDict):
    username: str
    email: str
    password: str

class UserResponse(TypedDict):
    user_id: int
    username: str
    email: str
    created_at: str

@app.post("/users")
def create_user(request: CreateUserRequest) -> UserResponse:
    # Type checker ensures we return correct structure
    user_id = save_user_to_db(request)
    return {
        'user_id': user_id,
        'username': request['username'],
        'email': request['email'],
        'created_at': datetime.now().isoformat()
    }

For configuration files, TypedDict provides structure without overhead:

class DatabaseConfig(TypedDict):
    host: str
    port: int
    database: str
    username: str
    password: str
    pool_size: NotRequired[int]
    timeout: NotRequired[int]

class AppConfig(TypedDict):
    database: DatabaseConfig
    debug: bool
    secret_key: str

def load_config() -> AppConfig:
    with open('config.json') as f:
        config = json.load(f)
    return config  # Type checker validates structure

Limitations and Alternatives

TypedDict isn’t always the right choice. Here’s when to use alternatives:

Use dataclasses when:

  • You need methods on your data structures
  • You want default values or post-init processing
  • You don’t need dict compatibility
from dataclasses import dataclass

@dataclass
class User:
    username: str
    email: str
    is_active: bool = True
    
    def deactivate(self):
        self.is_active = False

Use Pydantic when:

  • You need runtime validation
  • You’re parsing external data (APIs, JSON)
  • You want automatic data conversion
from pydantic import BaseModel, EmailStr

class User(BaseModel):
    username: str
    email: EmailStr  # Validates email format at runtime
    age: int
    
    class Config:
        validate_assignment = True

Use NamedTuple when:

  • You need immutable data
  • You want tuple unpacking
  • Memory efficiency matters
from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float
    
p = Point(1.0, 2.0)
x, y = p  # Tuple unpacking works

Stick with TypedDict when:

  • Working with existing dict-based code
  • Interfacing with libraries expecting dicts
  • You need zero runtime overhead
  • Your data naturally maps to JSON/dict structures

TypedDict is the minimalist choice—pure type safety without changing how your code runs. For APIs, configuration files, and JSON data where dicts are the natural representation, TypedDict provides exactly what you need and nothing you don’t.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.