F vs Fs โ "give me one" vs "give me all."
F.x โ a single value (errors on 0 or >1)Fs.x โ a set of all values ({} if none)entity.x_ is the same as Fs.x
Remember: in Zef's graph model, every field is physically many-valued โ a potentially-empty set of outgoing edges. Whether you treat a field as "exactly one" or "possibly many" is a logical decision you make at read time, not a schema decision.
When you access a field:
You decide: "do I want to assert there's one?" or "do I want to handle zero-or-many gracefully?"
Traditional Python:
if person.email is not None:
send_notification(person.email)
Zef:
person.email_ | map(send_notification) | to_array | run
If email_ is empty, nothing happens. If it has 1 value, one
notification. If it has 5 values, 5 notifications. Same code, no branches.
This is the zero-one-infinity rule showing up in your code. Because the storage is already "many," and your access syntax can treat it as "many," your code handles all three cases (0, 1, N) with no conditionals.
This also means: the day your "single email" becomes "multiple emails," you
don't rewrite anything. Just start using Fs where you were using F.
| situation | F.email | Fs.email |
|---|---|---|
| field has 1 value | 'a@x' | {'a@x'} |
| field has 2 values | โ ERROR | {'a@x', 'a@y'} |
| field is absent | โ ERROR | {} |
You don't have to use F / Fs โ Python attribute access works the same way:
person.name # F.name โ single
person.email_ # Fs.email โ set
# exactly equivalent to:
person | F.name
person | Fs.email
Use the dot form when reading directly. Use F/Fs when building pipelines to pass around.
data = {'name': 'Alice', 'tags': ['python', 'rust']}
data.name_ # {'Alice'}
data.tags_ # {['python', 'rust']}
data.missing_ # {} โ no KeyError
Here's where it really shines. Say you have a list of people with optional emails:
people = [
ET.Person(name='Alice', email_={'alice@x', 'alice@y'}),
ET.Person(name='Bob'), # no email
ET.Person(name='Carol', email_={'carol@x'}),
]
# every email across every person, flattened
all_emails = people | map(Fs.email) | flatten | collect
# {'alice@x', 'alice@y', 'carol@x'}
# people with at least one email
emailable = people | filter(Fs.email | length > 0) | collect
On the construction side, the underscore works the same way:
alice = ET.Person(
name='Alice', # single value
email_={'a@x', 'a@y'}, # set of emails
visited_=[ET.City(name='Berlin'),
ET.City(name='Paris')], # ordered list
)
If you construct with one rule and read with another, Zef will complain:
alice = ET.Person(email_={'a@x', 'a@y'})
alice.email # โ error โ 2 values
alice.email_ # โ
{'a@x', 'a@y'}
Use the _ on both sides when the field is plural.
The four things to remember:
F.x / x asserts exactly one.Fs.x / x_ gives you a set.x_ when the field can be plural.What's wrong with this code?
person = ET.Person(name='Alice', tags=['py', 'rust'])
person.tags | map(to_upper_case) | collect
Two bugs actually. The construction uses tags=[...] (singular), so Zef thinks tags is a single value (the list itself). Should be tags_=['py', 'rust']. And the read person.tags should be person.tags_ for multi-access.
person = ET.Person(name='Alice', tags_={'py', 'rust'})
person.tags_ | map(to_upper_case) | collect
Next up: the graph data model โ why Zef writes nested but stores flat. โ