Optional fields

An email address may be unknown. Express this with str | None:

from bytespec import ProtoModel, field

class User(ProtoModel):
    name: str
    email: str | None = field(flag=0)

user = User(name="Anna")
decoded = User.decode(user.encode())
print(decoded.email)  # None

str | None allows a string or an absent value. You can now omit the email argument; without a default, it becomes None. Optional[str] from typing means the same thing.

flag=0 assigns the field a presence bit in the message. The library sets it when a value is present and clears it for None. You do not need to calculate or pass flags manually when reading.

See field presence in bytes

In this short example, the header contains only Flags(1) and PayloadLength(1): one flag byte and one field-length byte. This makes the difference between a name and None easier to see. Configuring such a header is covered in Headers and framing.

from bytespec import Flags, PayloadLength, ProtoModel, field

class Contact(ProtoModel):
    __header__ = (Flags(1), PayloadLength(1))
    name: str | None = field(flag=0, prefix_length=1)
present = Contact(name="Anna").encode()
assert Contact.decode(present).name == "Anna"
print(present.hex(" "))
print(f"Flags: {present[0]:08b}")
01 05 04 41 6e 6e 61
Flags: 00000001

The rightmost bit is 1: the name is present.

① Flags · 01

flag=0 is the rightmost bit. bytespec set it because a name was provided.

② PayloadLength · 05

Five field bytes: one for the string length and four for the name.

③ name · 04 41 6e 6e 61

Four text bytes for Anna, preceded by a one-byte length.

absent = Contact(name=None).encode()
assert Contact.decode(absent).name is None
print(absent.hex(" "))
print(f"Flags: {absent[0]:08b}")
00 00
Flags: 00000000

The rightmost bit is 0: there is no name. Only the two header bytes remain.

① Flags · 00

All bits are clear. The name field is absent.

② PayloadLength · 00

There are no field bytes after the header. Reading produces name=None.

What is a bit, and why flag=0?

A byte contains eight bits, each either 0 or 1. They are numbered from right to left, starting at zero. A field with flag=0 corresponds to 00000001, a field with flag=1 to 00000010, and both fields present to 00000011. This is a bitmask. You choose the bit numbers; bytespec computes the number.

Supply a value

user = User(name="Anna", email="anna@example.com")
decoded = User.decode(user.encode())
print(decoded.email)  # anna@example.com

When the value is None, no field bytes are written. An empty string, however, is still a present value:

user = User(name="Anna", email="")
decoded = User.decode(user.encode())
print(repr(decoded.email))  # ''

The same distinction applies between None and numeric zero or False. Only None means absence.

Add another optional field

Each field needs its own bit number:

class User(ProtoModel):
    name: str
    email: str | None = field(flag=0)
    nickname: str | None = field(flag=1)

user = User(name="Anna", nickname="ann")
decoded = User.decode(user.encode())
print(decoded.email, decoded.nickname)  # None ann

flag is a bit number, not a bitmask. By default, numbers 0 through 63 are available; duplicates are not allowed. An optional field without flag, or a regular field with flag, raises SchemaError when the class is declared. The bitmap size is set by the Flags element in Headers and framing. If you change the header, keep Flags for optional fields: omitting it raises SchemaError at class declaration. This check also includes inherited fields.

An optional field with a default value

default still applies only when the argument is omitted:

class Message(ProtoModel):
    note: str | None = field(flag=0, default="draft")

print(Message().note)           # draft
print(Message(note=None).note)  # None

encoded = Message(note=None).encode()
print(Message.decode(encoded).note)  # None

An explicit None is not replaced with "draft". When decoding, an absent field also becomes None, regardless of its default. Defaults help create instances; they do not restore missing message bytes.

Next: Lists and nested models, where we will add a list to the user and nest it inside another model.