Pick a style
- Dataclass (the default Style): standard library only, with no validation or conversion.
- Pydantic:
BaseModelsubclasses for Pydantic v2 that validate values and build nested models. - TypedDict: a type for the plain
dictthatjson.loadsreturns, read by mypy or Pyright; nothing changes at runtime.
Charge JSON to Python classes
With Charge as the Root name, the sample becomes these dataclasses:
{
"id": "ch_3QX9",
"object": "charge",
"amount": 4200,
"currency": "usd",
"paid": true,
"refunded": false,
"receipt_email": null,
"billing_details": {
"name": "Jenny Rosen",
"address": { "city": "Berlin", "country": "DE", "postal_code": "10115" }
},
"metadata": { "order_id": "6735" },
"created": 1762300800
}
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
@dataclass
class Address:
city: str
country: str
postal_code: str
@dataclass
class BillingDetails:
name: str
address: Address
@dataclass
class Metadata:
order_id: str
@dataclass
class Charge:
id: str
object: str
amount: int
currency: str
paid: bool
refunded: bool
receipt_email: Optional[Any]
billing_details: BillingDetails
metadata: Metadata
created: int
Classes come in dependency order, so Address exists before BillingDetails uses it. receipt_email held only null, hence Optional[Any]; narrow it to Optional[str]. metadata became a class because the sample had one key in it, but payment metadata is usually free-form, so dict[str, str] may fit better.
In a dataclass, a field without a default can’t follow one with a default, so optional fields move to the end with = None. Those are keys missing from some array items, found by merging all items as described on JSON to TypeScript.
Loading data into the classes
Pydantic does the whole job:
from pathlib import Path
body = Path("charge.json").read_text()
charge = Charge.model_validate_json(body)
print(charge.billing_details.address.city) # Berlin
It builds the nested models, turns "4200" into 4200, and raises ValidationError for wrong types or missing keys. receipt_email must be present but may be null: Pydantic v2 treats an Optional field without a default as required.
A dataclass converts nothing:
import json
charge = Charge(**json.loads(body))
print(type(charge.billing_details)) # <class 'dict'>
Nested objects stay dicts, so build them yourself or switch Style to Pydantic. With TypedDict you only annotate: charge: Charge = json.loads(body). If json.loads fails first, see JSONDecodeError: Expecting value; to inspect a payload, pretty print the JSON in Python.
Keys that aren’t valid Python names
Keywords get a trailing underscore and other keys become snake_case, with the original kept as a Pydantic alias. With Period as the root name:
{ "from": "2026-11-01", "to": "2026-11-30", "time-zone": "Europe/Berlin" }
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class Period(BaseModel):
model_config = ConfigDict(populate_by_name=True)
from_: str = Field(alias="from")
to: str
time_zone: str = Field(alias="time-zone")
populate_by_name=True lets you build the model with either name, as in Period(from_="2026-11-01", to="2026-11-30", time_zone="Europe/Berlin"), and model_dump(by_alias=True) writes from and time-zone back out. Dataclasses have no aliases, so that style leaves a # JSON key: comment and the mapping to you. TypedDict switches to its functional form, TypedDict("Period", {"from": str, ...}), which keeps the exact keys.