Skip to content

Validity in the type¤

When data violates an invariant, the library keeps the raw value instead of silently dropping it or failing immediately. The type makes the invalid value visible, so you can decide how to handle it.

Some fields return a whole object or its invalid counterpart. For example, a delivery area can be a DeliveryArea or an InvalidDeliveryArea. Match both cases when you read such a value:

from typing import assert_never

from frequenz.client.common.grid import DeliveryArea, InvalidDeliveryArea


def describe(area: DeliveryArea | InvalidDeliveryArea) -> str:
    match area:
        case DeliveryArea():
            return "valid"
        case InvalidDeliveryArea():
            return "invalid"
        case unexpected:
            assert_never(unexpected)

An invalid object keeps its raw fields. Prefer a safe accessor when the object offers one: it returns the valid value or raises a typed InvalidAttributeError subclass. For example, an InvalidDeliveryAreaError exposes the invalid value on InvalidDeliveryAreaError.delivery_area. See safe accessors.

An otherwise valid object can also carry an invalid value in one field. Location uses InvalidLatitude, InvalidLongitude, and InvalidCountryCode wrappers. Each has the raw value on InvalidLatitude.value, InvalidLongitude.value, or InvalidCountryCode.value.

from frequenz.client.common.types import (
    InvalidCountryCode,
    InvalidLatitude,
    InvalidLongitude,
    Location,
)

location = Location(
    latitude=InvalidLatitude(value=999.0),
    longitude=InvalidLongitude(value=-999.0),
    country_code=InvalidCountryCode(value="Germany"),
)

match location.latitude:
    case InvalidLatitude(value=raw_latitude):
        print(raw_latitude)  # 999.0
    case latitude:
        print(latitude)

Use Location.get_latitude() when you need a valid number. It returns the latitude or raises InvalidLatitudeError, whose InvalidLatitudeError.value is the raw value.

Some wrapper types use a dedicated subclass for a value that is invalid, unspecified, or unrecognized. For a battery, that can be UnspecifiedBattery or UnrecognizedBattery, alongside a known subtype such as LiIonBattery. They are all Battery values. UnrecognizedBattery.type keeps the raw type. Likewise, MismatchedCategoryElectricalComponent is an ElectricalComponent whose MismatchedCategoryElectricalComponent.category records the mismatched value.

from typing import assert_never

from frequenz.client.common.microgrid.electrical_components import (
    BatteryTypes,
    LiIonBattery,
    NaIonBattery,
    UnrecognizedBattery,
    UnspecifiedBattery,
)


def describe_battery(battery: BatteryTypes) -> str:
    match battery:
        case LiIonBattery():
            return "Li-ion"
        case NaIonBattery():
            return "Na-ion"
        case UnspecifiedBattery():
            return "unspecified"
        case UnrecognizedBattery(type=raw_type):
            return f"unrecognized: {raw_type}"
        case unexpected:
            assert_never(unexpected)

Use match with assert_never rather than isinstance() chains so a type checker can keep the cases exhaustive. Invalid data is different from an unrecognized enum integer: Invalid* signals an invariant violation, while an unrecognized integer is unknown but well-formed. See enum-or-int fields for that forward-compatible case.