In an API, I ran into this buy operation:

{
  "operation": "buy",
  "unit-cost": 10.00,
  "quantity": 100
}

It looks like any other JSON, until you try to type that input with TypedDict.

Take a look at the unit-cost key.

The problem Link to heading

A TypedDict is usually declared as a class, and every key becomes an attribute.

class RawOperation(TypedDict):
    operation: Literal["buy", "sell"]
    # and now?
    unit-cost: float
    quantity: int
# SyntaxError: illegal target for annotation
# to Python, unit-cost looks like a subtraction between unit and cost

The code never reaches mypy, it stops before that.

The first instinct is to swap the hyphen for an underscore. But unit_cost is not the key that came from the API.

Making up another name only makes the type lie about the data it represents.

The solution Link to heading

TypedDict also accepts a functional syntax. In it the fields live in a plain dictionary and can have any name the JSON accepts.

from typing import Literal, TypedDict

RawOperation = TypedDict(
    "RawOperation",
    {
        "operation": Literal["buy", "sell"],
        "unit-cost": float,
        "quantity": int,
    },
)

There you go. The editor understands raw["unit-cost"], the type checker knows a float shows up there and nobody has to guess which name the API uses.

That is what the documentation recommends for keys with a hyphen and for names that cannot become attributes.

And inside the application? Link to heading

I keep this type close to where the JSON is read, and right after that I convert it into a domain object.

import json


def parse_json_line(line: str) -> list[Operation]:
    raw_ops_list: list[RawOperation] = json.loads(line)
    # Operation is the domain dataclass, not a TypedDict
    return [
        Operation(
            operation=raw["operation"],
            unit_cost=Money(str(raw["unit-cost"])),
            quantity=raw["quantity"],
        )
        for raw in raw_ops_list
    ]

The hyphen stays in the external contract, where it belongs, and the rest of the code only knows unit_cost.

And what if the API sends "operation": "transfer"?

mypy stays happy, it believes the Literal I wrote. At run time, the match over in the tax calculation falls into assert_never.

AssertionError: Expected code to be unreachable, but got: 'transfer'

It broke far from where the data came in. TypedDict describes what I expect to receive, checking that this is what actually arrived is another job.

Where the example came from Link to heading

The example came from capital-gains, a CLI that calculates taxes on stock market operations. It is the same project from the article “The Money Object”, which is where the Money used here comes from.

That’s it, folks!

See you next time!

{}’s