TypedDict

TypedDict lets you describe the exact shape of a dictionary — which keys exist and what type each value should have — so static checkers can validate dict-based data structures like JSON payloads.

Plain dict[str, Any] type hints tell a checker nothing about which keys are actually present or what each value's type should be. TypedDict (from typing, PEP 589) fixes this by letting you declare a dictionary shape as a set of named, typed keys — commonly used for JSON-like data, configuration dicts, and API payloads where a full class would be overkill but structure still matters.

What Is a TypedDict

A TypedDict declares that a dictionary has a specific, fixed set of string keys, each with its own value type. Unlike a regular class, a TypedDict instance IS a plain dict at runtime — TypedDict exists purely for static type checking; it adds no runtime validation, no methods, and no wrapper object.

from typing import TypedDict

class Movie(TypedDict):
    title: str
    year: int
    rating: float

m: Movie = {"title": "Arrival", "year": 2016, "rating": 8.0}
print(type(m))  # <class 'dict'> — it is a real, ordinary dict

Why TypedDict Matters

Dictionaries are extremely common as ad-hoc records — parsed JSON, configuration blocks, database rows fetched as dicts — but a bare dict[str, Any] hint gives a type checker no way to catch a typo'd key or a wrong-typed value. Defining a small class (or NamedTuple) is often not an option when the data genuinely needs dict semantics (e.g. it came from json.loads() and downstream code indexes it with ["key"]). TypedDict bridges this gap: dict-shaped data with a static contract.

Basic Syntax

TypedDict supports two equivalent syntaxes: the class-based form (preferred for readability) and a functional form (useful when a key name is not a valid Python identifier, e.g. contains a hyphen).

from typing import TypedDict

# class-based form
class Point(TypedDict):
    x: int
    y: int

# functional form — needed for non-identifier keys
Point2 = TypedDict("Point2", {"x": int, "y": int})
Response = TypedDict("Response", {"content-type": str, "status": int})

Required and Optional Keys

By default, every key declared in a TypedDict is required. Mark individual keys optional with NotRequired[...] (Python 3.11+), or flip the class-wide default with total=False and mark the required exceptions with Required[...].

total=True is the implicit default: every key must be present. total=False flips that default for the whole class; Required/NotRequired override the default per key and can be mixed with total.

from typing import TypedDict, NotRequired, Required

class UserProfile(TypedDict):
    username: str            # required
    email: str                # required
    bio: NotRequired[str]     # optional — may be absent

class Config(TypedDict, total=False):
    debug: bool               # optional (total=False makes all optional)
    host: Required[str]       # required exception, overriding total=False

profile: UserProfile = {"username": "ada", "email": "ada@example.com"}  # OK, bio omitted
cfg: Config = {"host": "localhost"}  # OK, debug omitted

Static Checking vs Runtime Behavior

This is the single most important thing to understand about TypedDict: it is a purely static construct. Creating a dict that violates the declared shape does NOT raise any error at runtime — only a static type checker (mypy, pyright) flags it.

TypedDict performs NO runtime validation. If you need runtime enforcement of a dict's shape (e.g. validating untrusted JSON from a network request), reach for a third-party library like pydantic, or write explicit validation code — TypedDict alone will not protect you at runtime.

from typing import TypedDict

class Movie(TypedDict):
    title: str
    year: int

# A type checker flags this as an error (missing "year", wrong type for "title")...
bad: Movie = {"title": 42}
# ...but Python itself runs this line without any exception at all.
print(bad)  # {'title': 42}

Inheritance and Composition

A TypedDict can inherit from other TypedDicts to build up shared shapes, similar to composing interfaces.

from typing import TypedDict

class HasId(TypedDict):
    id: int

class HasTimestamps(TypedDict):
    created_at: str
    updated_at: str

class Article(HasId, HasTimestamps):
    title: str
    body: str

a: Article = {
    "id": 1,
    "created_at": "2026-01-01",
    "updated_at": "2026-01-02",
    "title": "Hello",
    "body": "World",
}

Accessing Values

Because a TypedDict value is a real dict, all normal dict operations work — indexing, .get(), iteration, .items(). Static checkers use the declared key types to narrow the value type for d["key"] accesses, but this narrowing disappears if you use dynamic key lookups.

def show(movie: Movie) -> None:
    print(movie["title"])         # checker knows this is str
    print(movie.get("year", 0))   # checker knows this is int (or the default)

Practical Usage

TypedDict is a natural fit for typing JSON API responses, configuration dictionaries loaded from YAML/JSON, and any function boundary where dict-shaped data crosses in or out — while keeping the lightweight dict runtime representation that JSON tooling expects.

from typing import TypedDict
import json

class ApiResponse(TypedDict):
    status: int
    message: str

def parse_response(raw: str) -> ApiResponse:
    data = json.loads(raw)
    return data  # type checker trusts the declared return shape

resp = parse_response('{"status": 200, "message": "OK"}')
print(resp["status"])

Edge Cases and Rules

TypedDict classes cannot be instantiated with isinstance() checks in the normal sense — there is no runtime class distinguishing a Movie dict from any other dict, since both are just dict instances. TypedDict also does not support arbitrary extra keys by default; a checker will flag keys not in the declared shape (this can be relaxed with closed=False/extra_items in newer typing extensions, but treat that as an advanced, evolving feature).

A TypedDict cannot have methods (it inherits from dict, not a normal class body with executable logic) — it is purely a shape declaration.

TypedDict vs dataclass vs NamedTuple

Choose based on what runtime object you actually need.

TypedDict          dataclass          NamedTuple
Runtime type     plain dict         real class          tuple subclass
Mutability       mutable (dict)     mutable by default  immutable
Methods allowed  no                 yes                 yes
Best for         JSON/dict data     rich objects/logic   lightweight records

Key Takeaways

TypedDict declares the shape (keys and value types) of a dictionary for static type checking only.

At runtime, a TypedDict-typed value is an ordinary dict — no validation, no wrapper, no enforcement happens automatically.

All keys are required by default; use NotRequired[...]/Required[...] or class-level total=False to control which keys are optional.

TypedDicts can inherit from other TypedDicts to compose shared key sets.

For runtime validation of untrusted data (e.g. JSON from a network request), use explicit checks or a third-party library like pydantic — TypedDict alone will not catch bad data at runtime.

movie: Movie = {"title": "Arrival"} # missing "year" # Runs fine! No exception, no validation happens.

TypedDict provides zero runtime enforcement; only a static type checker catches shape violations — you must actually run one to get any benefit.

class Config(TypedDict): debug: bool if isinstance(cfg, Config): # TypeError-ish misconception: this always compares against dict ...

if isinstance(cfg, dict) and "debug" in cfg: ...

TypedDict subclasses have no distinct runtime class; isinstance() checks against them don't verify the declared shape (and using a TypedDict directly with isinstance is not meaningful) — check dict-ness and key presence explicitly if you need a runtime check.

class Movie(TypedDict): title: str def summary(self) -> str: # SyntaxError-adjacent misuse: not supported return self["title"] class Movie(TypedDict): title: str def summary(movie: Movie) -> str: return movie["title"]

TypedDict cannot carry methods — it's a pure shape declaration. Use a standalone function or switch to a dataclass if you need behavior attached to the data.

What is a TypedDict's runtime type — is it a distinct class from dict?

No. A TypedDict value is a plain ordinary dict at runtime; TypedDict exists only for static type checking and adds no runtime wrapper or distinct class.

If you assign a dict missing a required key to a TypedDict-annotated variable, what happens when you run the program?

Nothing — the program runs without error. Only a static type checker like mypy or pyright flags the missing key; there is no runtime validation.

How do you mark an individual key as optional in a TypedDict, without changing the totality of the whole class?

Wrap its type in NotRequired[...] (Python 3.11+), e.g. bio: NotRequired[str]. The class-wide total=False flips the default for all keys instead.

When would you use the functional TypedDict syntax instead of the class-based syntax?

When a key name is not a valid Python identifier, such as one containing a hyphen (e.g. 'content-type'), which cannot appear as a class attribute name.

Why is TypedDict not appropriate for validating untrusted JSON from an external API at runtime?

Because TypedDict performs no runtime checks at all — malformed or missing data passes through silently. Use explicit validation code or a library like pydantic for actual runtime enforcement.

Can a TypedDict have methods like a regular class?

No. TypedDict is purely a shape declaration for keys and value types; behavior must live in standalone functions or a different construct like a dataclass.

How does TypedDict inheritance work?

A TypedDict class can inherit from one or more other TypedDict classes, combining their declared keys into a single composed shape.

Define a TypedDict Employee with required name: str and id: int, and an optional manager: str key. Create one valid instance without the optional key.

from typing import TypedDict, NotRequired class Employee(TypedDict): name: str id: int manager: NotRequired[str] e: Employee = {"name": "Priya", "id": 101} print(e)

{'name': 'Priya', 'id': 101} — manager is omitted since it's marked NotRequired, and this is valid both for the checker and at runtime.

What does this print, and does it raise any error at runtime?

It prints {'x': 1, 'y': '2'} <class 'dict'> with no runtime error — a static checker would flag y as wrong-typed (str instead of int), but TypedDict performs no runtime validation, so the program runs normally.

Write two TypedDicts, Base (with id: int) and Extended (inheriting Base, adding name: str), then construct one valid Extended instance.

from typing import TypedDict class Base(TypedDict): id: int class Extended(Base): name: str item: Extended = {"id": 1, "name": "Widget"} print(item)

{'id': 1, 'name': 'Widget'} — Extended combines its own key with the inherited id key from Base, and both are required.

from typing import TypedDict

class Point(TypedDict):
    x: int
    y: int

p: Point = {"x": 1, "y": "2"}
print(p, type(p))