Expressing semantic content of tuple values in type annotations

Question:

I’m modeling a financial exchange

class Exchange(ABC):
    @abstractproperty
    def balances(self) -> Dict[str, Tuple[float, float]]:
        ...

The semantic content of .balances return type is a dict that is {asset: (quantity, proportion), ...}

e.g.

{"BTC": (0.0015, .30), "ETH": (0.10, .20), "LTC": (5, .50)}

The problem is that this fact is not obvious just by looking at -> Dict[str, Tuple[float, float]].

Developers who are not familiar with the codebase and API responses will not immediately know that the two floats represent the held quantity and proportion of the portfolio respectfully.

I attempt to solve this by creating a custom type alias.

class Balance(NamedTuple):
    quantity: float
    proportion: float

class Exchange(ABC):
    @abstractproperty
    def balances(self) -> Dict[str, Balance]:
        ...

This introduces a new problem: IDE Intellisense literally shows -> Dict[str, Balance] with no elaboration on what Balance looks like.

Screenshot of what vscode shows

I had hoped it would resolve the alias as something like -> Dict[str, tuple(quantity: float, proportion: float)].

This leaves the same problem of sub-optimal expressiveness to unfamiliar developers. When a developer hovers over the function call, they will see unfamiliar custom type aliases in the return, of which they will have to go searching in the file to find it’s definition to understand.

My goal is for developers to be able to jump into the codebase and immediately intuit the shapes and semantic content of function returns, without needing to ask me about API documentation or go searching for type declarations.

Any thoughts? What are the best practices here?

Asked By: Michael Moreno

||

Answers:

This is exactly, what typing.NewType is for.

To make your annotations more concise, you can additionally create a type alias for the balance tuple type. (Note that you did not create a type alias in your example, but an actual type inheriting from NamedTuple.)

from typing import NewType, TypeAlias


Quantity = NewType("Quantity", float)
Proportion = NewType("Proportion", float)
Balance: TypeAlias = tuple[Quantity, Proportion]


class Exchange:
    def balances(self) -> dict[str, Balance]:
        ...

If you are on Python <3.10, you can just omit the typing.TypeAlias annotation.


PS

I just confirmed that apparently VSCode (for some bewildering reason) decides to treat a type alias like Balance as its own type and not display the actual type in its auto-suggestions. It shows def balances(self) -> dict[str, Balance].

In that case, you can either omit the alias and use the tuple directly like

from typing import NewType

Quantity = NewType("Quantity", float)
Proportion = NewType("Proportion", float)

class Exchange:
    def balances(self) -> dict[str, tuple[Quantity, Proportion]]:
        ...

or switch to a more consistent IDE.

I’m joking a bit, but this is worth opening an issue over IMHO. To be clear again, your original Balance inheriting from NamedTuple actually was its own type, so it makes sense that this is what the IDE showed. In the case of a type alias I think this is inconsistent and should be fixed.


PPS

This seems to be turning into a "why does my IDE do X instead of Y?" type conversation.

All the annotations (including the ones you had in the beginning) were entirely fine from a type safety standpoint. You cannot expect to be able to accommodate the idiosyncrasies of every IDE out there.

In the end it comes down to what you think makes sense from a design perspective and then reflect that in your type annotations, not the other way around.

Just for the sake of completeness, another option would be to use a typing.TypedDict instead of a tuple like so:

from typing import TypedDict


class Balance(TypedDict):
    quantity: float
    proportion: float


class Exchange:
    def balances(self) -> dict[str, Balance]:
        return {
            "BTC": {
                "quantity": 0.0015,
                "proportion": .30
            },
            "ETH": {
                "quantity": 0.10,
                "proportion": .20,
            },
            "LTC": {
                "quantity": 5.,
                "proportion": .50,
            },
        }

But this will be the same deal as with the NamedTuple approach.


Your view of other developers is not very flattering, if you think they will not understand or misunderstand either of those options.

You write in your comment regarding the Quantity and Proportion as NewType solution:

Otherwise developers might think that they are plain objects with attributes, when they are just floats.

I thought you assumed people were using a more or less sophisticated IDE? If that is the case, it should tell them unambiguously that those are float subtypes as soon as they interact with them, and provide them with the corresponding auto-suggestions etc.

Answered By: Daniil Fajnberg