Skip to content

Validity in the type¤

Invalid protobuf data is still data the client received. Show whether it is valid in the wrapper type. This lets a conversion function keep the received data, ordinary construction enforce its rules, and later code ask for a valid value when it needs one. For guidance on using these types, see the User Guide's validity-in-the-type section and safe-accessor section.

Model valid and invalid object states¤

For an object with rules that cover the whole object, define a guarded Base* class and two concrete subclasses: the valid type and its Invalid* counterpart. The base holds shared fields. Its __new__ guard prevents callers from constructing the base directly, so each instance has a meaningful state. For example, DeliveryArea and InvalidDeliveryArea share a base while making their validity visible in annotations.

Normal constructors enforce the valid subclass's rules. A conversion function that sees invalid protobuf data creates the matching invalid subclass and returns X | InvalidX. The invalid subclass keeps the raw fields for diagnosis, recovery, and later interpretation. It does not invent a valid value.

Annotate results and fields with the union X | InvalidX, never with the guarded base class. The union tells callers that invalid data is possible, forces them to separate the two cases — or lets them require only the valid type where nothing else makes sense — and keeps a match over the result exhaustive. A Base* annotation would hide which states exist and accept any future subclass, so a type checker could not check either decision. For example, a delivery-area conversion function returns DeliveryArea|InvalidDeliveryArea, and MetricSample.bounds_set is annotated BoundsSet|InvalidBoundsSet, while BaseDeliveryArea and BaseBounds appear in no public signature.

Wrap invalid fields precisely¤

An otherwise valid object can have one invalid field. In that case, put a field-specific Invalid* wrapper in that field's union. Do not mark unrelated data invalid. Location does this with InvalidLatitude, InvalidLongitude, and InvalidCountryCode. Each wrapper keeps the raw value while leaving the other fields usable.

Represent protobuf recovery as a subtype¤

When the class identifies a protobuf category or type, use dedicated subclasses for recovery cases. An UnspecifiedBattery represents a missing type, an UnrecognizedBattery retains an unknown raw type, and MismatchedCategoryElectricalComponent records conflicting category information. These subclasses make the recovery case clear without pretending it is a known component.

Use the complete public type alias when working with guarded bases and recovery subclasses. This handler deliberately does not construct an object. Type-check it instead of running it:

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)

Design semantic accessors and errors together¤

Keep the low-level field unchanged, then add a get_*() accessor for callers that need a valid value. Use an exhaustive match for valid, invalid, missing, and unknown cases. End it with assert_never. For example, Location.get_latitude() returns a validated number and raises a typed error for an invalid latitude wrapper.

Use this error hierarchy: ClientCommonErrorInvalidAttributeError (also a ValueError) → UnspecifiedEnumValueError, UnrecognizedEnumValueError, MissingFieldError, and domain-specific invalid-value errors such as InvalidBoundsSetError. Keep a specific error's raw value or invalid object on the error instance so the caller can inspect it.

Use the <invalid:…> marker only for a failed rule, such as a malformed required field or the unspecified raw value 0. It makes real failures clear in logs and diagnostics. For data that is merely unknown to this version, show the raw value as :field=value. Unknown data does not itself break a rule. Use these compact forms consistently in each wrapper's __str__ method.