Traversing optional FHIR Elements in Python: Making the Complex Simple
FHIR (Fast Healthcare Interoperability Resources) adheres to the 80/20 rule, focusing on providing 80% of the necessary data 20% of the time. While this design philosophy streamlines common use cases, working with FHIR resources becomes challenging due to the optional nature of most elements. Handling optional fields gracefully in Python, especially when nested, can make the code less readable.
At Tiro.health, we leverage Pydantic models to work with FHIR resources. Pydantic simplifies parsing and validating FHIR/JSON resources, but managing optional fields remains cumbersome. For instance, retrieving a patient's name involves multiple nested checks, leading to code like this:
patient = Patient.parse_file("FHIR-Patient-123.json") # Parse a FHIR Patient from a JSON file
if patient.name is not None:
if patient.name[0].given is not None:
if len(patient.name[0].given) > 0:
print(" ".join(patient.name[0].given[0].value))
elif patient.name[0].text is not None:
print(patient.name[0].text)
else:
print("No name")
To address this, we introduce the Maybe monad, a design pattern commonly used in functional programming languages. While Python lacks native optional chaining, the Maybe monad enhances code readability and reduces verbosity.
A good YouTube video on monads: What the Heck Are Monads?!
Introducing the Maybe Monad
The Maybe monad represents optional values, serving as a container that can hold a value or nothing. Unlike existing Python libraries like returns, our implementation caters to our unique use case which is working with FHIR resources. i
class Maybe:
def __init__(self, value):
"""Encapsulate a value."""
self.value = value
def __getattr__(self, name):
"""Access nested fields."""
if self.value is None:
return self
# return a None Maybe if the attribute is not found
return Maybe(getattr(self.value, name, None))
def __getitem__(self, key):
"""Access list items."""
if self.value is None:
return self
try:
return Maybe(self.value[key])
except IndexError:
return Maybe(None)
Important Notes:
- Use
getattr
with a default value ofNone
to avoidAttributeError
exceptions. - In the
__getitem__
method, catchIndexError
exceptions to returnNone
if the index is out of range.
Now, we can simplify nested field access without explicit checks:
print(Maybe(patient).name[0].given[0].value)
Enhancements to the Maybe Monad
To further streamline usage, we introduce additional methods:
The apply
Method
class Maybe:
# ...
def apply(self, func):
if self.value is None:
return self
return Maybe(func(self.value))
This allows concise application of functions to nested values:
print(Maybe(patient).name[0].given[0].apply(" ".join).value)
The __or__
Operator
class Maybe:
# ...
def __or__(self, other):
if self.value is None:
return other
return self
Now we can chain multiple fields and provide a default value if none are found:
patient = Patient.parse_file("FHIR-Patient-123.json") # Parse a FHIR Patient from a JSON file
print((
Maybe(patient).name[0].given[0].apply(" ".join) or
Maybe(patient).name[0].text or
Maybe("No name")
).value)
🎉 Et voilà! We've successfully removed the if/else statements.
There is one additional enhancement we can make to the Maybe monad to make it more useful in other contexts.
The __iter__
Method
class Maybe:
# ...
def __iter__(self):
if self.value is not None:
try:
yield from self.value
except TypeError:
yield self.value
Easily iterate over fields with cardinality 0..*
:
for given in Maybe(patient).name[0].given:
print(given.value)
Parallels with FHIRPath
Drawing parallels with FHIRPath, a powerful expression language for traversing FHIR resources, the Maybe monad shares similarities:
- Both use an empty collection or
None
to represent absence of values. - Facilitate fluent access and application of functions to nested fields without explicit checks.
- Allow iteration over fields with cardinality
0..*
without checking for presence.
While FHIRPath offers more advanced features, combining the Maybe monad with FHIRPath might unlock additional power.
Conclusion
The Maybe monad effectively removes verbosity related to optional fields, making code more readable. However, its implicit behavior requires careful usage to avoid hiding potential bugs. Consider this pattern judiciously, keeping in mind the trade-offs.