Skip to main content

How to introspect FHIR resources

· 7 min read
CTO of Beda Software

What is the best way to query data from FHIR resources? Should we use functionality provided by a chosen programming language, or use tailored utilities like FHIRPath? Recently we had an internal conversation about this topic. Here you can find a summary.

Type safety

Discussion about static vs dynamic typing is a hot topic in software development. Dynamic typing is good in the early prototyping stage because it doesn't apply any restrictions. However, in the later stages of the project, you would like to get a static type system that will ensure that any code base changes will not break the functionality.

When working with FHIR, it is highly useful to use static typing and generated type definitions for FHIR data structure. There are many benefits of using it. First of all, for any new developers unfamiliar with FHIR, it is so easy to explore a data structure just by pressing it. and see that attributes exist in the resource.

Also, when you try to access the optional argument type system will force you to check if this attribute exists. For example, in statically typed Python, you have to write code like this:

appointment = await to_resource(fhir_client, r4b.Appointment, task.focus)
appointment_identifier = get_identifier_value(
appointment.identifier or [], internal_appointment_id_system)
assert appointment_identifier

patient_ref = get_appointment_patient(appointment)
assert patient_ref

patient = await to_resource(fhir_client, r4b.Patient, patient_ref)
assert patient.id

patient_ehr_id = get_identifier_value(patient.identifier or [])
assert patient_ehr_id

As you can see it is too verbose and may become annoying. So some programming languages provide a useful tooling to simplify access to optional attributes. For example, since version 3.7 Typescript provides optional chaining. Let's see how it simplifies access to optional attributes of the resource.

Query FHIR resource with optional chaining

With optional chaining, you can easily access nested elements of any FHIR resource. For example, this code returns a display of the location's physical type

location.physicalType?.coding?
.find(({ system }) => system === ptSystem)?
.display;

The result of this expression will be string | undefined. If an attribute that is called via ?. is undefined, the whole expression is resulted to undefined. Otherwise, the values are returned. Since it is all standard library functions, typescript deduce the resulted type correctly.

// this expression type is `Coding | undefined`.
location.physicalType?.coding?.[0]

It is incredibly useful, your code keeps type definitions and you can make sure that you will not get any runtime errors because if missing values.

However, if we have a look at more complex examples, it becomes hard to read:

const l = locations.filter(
(l) => l.physicalType?.coding?
.find(({ system }) => system === ptSystem)?
.code == 'ro'
);

This code searches rooms over a list of locations.

When you want to query something from the resulted list, the code becomes extremely complex:

const l = locations
.filter((l) => l.physicalType?.coding?
.find(({ system }) => system === ptSystem)?
.code == 'ro')
.map((l) => l.type?.find(
(t) => t.coding?.find(({ system }) => system === tSystem))?
.coding?.find(
({ system }) => system === tSystem)?
.display)?.[0];

In this example, we get displays of locations' types filtered by physical type of room.

I would like to point out that all these examples of code leverage a functional programming approach along with optional chaining. If you would like to implement this logic in Python, which doesn't provide optional chaining, or implement it in the imperative programming paradigm the code would be even more complex and less readable.

Query FHIR resources with FHIRPath

If you are familiar with FHIR you may already have this question: 'Why don't you use FHIRPath for these queries?'. It is a very good question that makes a lot of sense. FHIRPath provides a convenient way of querying data. There is a JavaScript implementation of FHIRPath so we can use it easily. If we leverage FHIRPath the previously defined queries will look like this:

const physycalType = evaluate(
location,
`Location.physicalType.coding.where(system='${ptSystem}').display`,
)?.[0];
 const l = evaluate(
locations,
"Location.where(physicalType.coding.code contains 'ro')"
);
const l = evaluate(
locations,
`Location.where(physicalType.coding.code contains 'ro')
.type.coding.where(system='${tSystem}').display`,
)?.[0];

As you can see FHIRPath makes code more compact and readable. However, there are some disadvantages of using FHIRPath.

FHIRPath issues

The biggest issue with FHIRPath is that we are losing the resulted type deducing. Let's see how typescript deduces type for chaining search:

You can see that the resulting type is Location[].

If we leverage FHIRPath for the same query:

You can see that the resulting type is any[]. It happens because the typescript doesn't know any details about the FHIRPath expression, it is just a string. Unfortunately, there is no way to deduce type in this case. As a result, we have to define a type manually:

const l: Location[] = evaluate(
locations,
`Location.where(type.coding.where(system='${tSystem}').code = 'RNEU')`
);

This hard-coded type becomes an issue since there is no other guarantee that the FHIRPath expression is correct and retunt the defined type.

After a long discussion, we ended up with two possible solutions to this missing type issue.

Compiled FHIRPath expressions and unit tests

Since it is not possible to deduce types automatically we have to define a manual process.

  1. Extract logic to a separated getter function
  2. Define wrapper over fhirpath.compile to set types
  3. Write unit tests to ensure that the contract is correct.

The resulting example will look like this:

const filterNeuroradiologies = compileAsArray<Location[], Location>(
`Location.where(type.coding.where(system='${tSystem}').code = 'RNEU')`);

compileAsArray is a generic that forces you to define input and output types. So you can't apply the filterNeuroradiologies function to the incorrect list and you will know the correct result type. As for the guarantee of filterNeuroradiologies implementation, you have to write unit tests to ensure that it works correctly.

Embeded DSL

There is an alternative option that keeps the ability to deduce the resulted type. If we leverage native programming language constructions to describe the FHIRPath we can deduce the resulted type. The ability to implement this embedded DSL depends on the target programming language features. TypeScript has limited functionality in overriding operators, so it will be a challenge to support FHIRPtah operators. However, it could be easily implemented in Python:

dsl.Patient.name.where(use="usual").given.first()
# "Patient.name.where(use='usual').given.first()"

dsl.env.Source.entry[0].resource.expansion.contains.where(code=dsl.env.Coding.code) != dsl.empty
# "%Source.entry[0].resource.expansion.contains.where(code=%Coding.code) != {}"

As you can see this FHIRPath expression is defined with pure python. So mypy can deduce the resulting type of such expression. Unfortunately, not all FHIRPath constructions could be implemented exactly as it is in the targeting language. So we have to use native constructions of the same semantics. For example to define this expression Location.where(physicalType.coding.code contains '817').name we have to replace contains operator with something that has the same semantic in Python. It is << operator. This expresion in Python based dsl will look like this:

dsl.Location.where(dsl.physicalType.coding.code << "817").name

Conclusion

There is no single answer on how to leverage FHIRPath in your applications. All approaches have their pros and cons.

  • For some simple cases, optional chaining is a good choice.
  • You can introduce a wrapper over fhirpath.compile that strictly defines types for a FHIRPath expression.
  • You can implement an embedded DSL for FHIRPath if you would like to keep type-deducing

You need to decide what approach will work better for your particular case.