Repository: aehrc/pathling (language: python)

Issue:
Terminology function: property
```
collection<Coding|CodeableConcept> -> property(code: string) : collection<String|Integer|DateTime|Decimal|Coding>
```

This function takes a collection of [Coding](https://pathling.csiro.au/docs/fhirpath/data-types.html#coding) or [CodeableConcept](https://hl7.org/fhir/R4/datatypes.html#CodeableConcept) elements as input, and returns a collection containing the values of the specified property.

Example:

```
Patient.reverseResolve(Condition.subject).code.property('effectiveTime')
```


Discussion / hints:
We could potentially pre-adopt this R5 expand feature to enable a more efficient implementation: https://ontoserver.csiro.au/docs/6/ext-r5-preadopt-exp.html
Following the implementation of #757, I think [lookup](https://hl7.org/fhir/codesystem-operation-lookup.html) will be the best way to implement this.
To simplify the implementation we are going to add the optional second parameter that specifies the type of the property being requested, one of: code | Coding | string | integer | boolean | dateTime | decimal


```
collection<Coding|CodeableConcept> -> property(code: string, [type:string]='string') : collection<String|Integer|DateTime|Decimal|Coding>
```

@johngrimes : does it make sense to have this function on CodeableConcept. I mean we can definitely combine the properties for all codings but is that useful in any way? 








Fix patch:
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/NamedFunction.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/NamedFunction.java
index 5792363b4b..6571df7603 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/NamedFunction.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/NamedFunction.java
@@ -25,6 +25,7 @@
 
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.function.terminology.MemberOfFunction;
+import au.csiro.pathling.fhirpath.function.terminology.PropertyFunction;
 import au.csiro.pathling.fhirpath.function.terminology.SubsumesFunction;
 import au.csiro.pathling.fhirpath.function.terminology.TranslateFunction;
 import com.google.common.collect.ImmutableMap;
@@ -40,7 +41,7 @@
 public interface NamedFunction {
 
   /**
-   * Mapping of function names to instances of those functions.
+   * Mapping of function names to the instances of those functions.
    */
   Map<String, NamedFunction> NAME_TO_INSTANCE = new ImmutableMap.Builder<String, NamedFunction>()
       .put("count", new CountFunction())
@@ -65,6 +66,7 @@ public interface NamedFunction {
       .put("until", new UntilFunction())
       .put("exists", new ExistsFunction())
       .put("display", new DisplayFunction())
+      .put("property", new PropertyFunction())
       .build();
 
   /**
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/Arguments.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/Arguments.java
new file mode 100644
index 0000000000..719c6a8765
--- /dev/null
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/Arguments.java
@@ -0,0 +1,84 @@
+package au.csiro.pathling.fhirpath.function.terminology;
+
+import au.csiro.pathling.fhirpath.FhirPath;
+import au.csiro.pathling.fhirpath.function.NamedFunctionInput;
+import au.csiro.pathling.fhirpath.literal.LiteralPath;
+import org.hl7.fhir.r4.model.Type;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Helper class for dealing with optional arguments.
+ */
+public class Arguments {
+
+  @Nonnull
+  private final List<FhirPath> arguments;
+
+  private Arguments(@Nonnull final List<FhirPath> arguments) {
+    this.arguments = arguments;
+  }
+
+  /**
+   * Gets the value of an optional literal argument or the default value it the argument is
+   * missing.
+   *
+   * @param index the 0-based index of the argument.
+   * @param defaultValue the default value.
+   * @param <T> the Java type of the argument value.
+   * @return the java value of the requested argument.
+   */
+  @SuppressWarnings("unchecked")
+  @Nonnull
+  public <T extends Type> T getValueOr(final int index, @Nonnull final T defaultValue) {
+    return (index < arguments.size())
+           ? getValue(index, (Class<T>) defaultValue.getClass())
+           : defaultValue;
+  }
+
+  /**
+   * Gets the value of an optional literal argument that does not have a default value.
+   *
+   * @param index the 0-based index of the argument.
+   * @param <T> the Java type of the argument value.
+   * @return the java value of the requested argument.
+   */
+  @Nullable
+  public <T extends Type> T getNullableValue(final int index,
+      @Nonnull final Class<T> valueClass) {
+    final LiteralPath<?> literalPath;
+    try {
+      literalPath = (LiteralPath<?>) arguments.get(index);
+    } catch (final IndexOutOfBoundsException e) {
+      return null;
+    }
+    return valueClass.cast(literalPath.getValue());
+  }
+
+  /**
+   * Gets the value of the required literal argument.
+   *
+   * @param index the 0-based index of the argument
+   * @param valueClass the expected Java  class of the argument value
+   * @param <T> the HAPI type of the argument value
+   * @return the java value of the requested argument
+   */
+  @Nonnull
+  public <T extends Type> T getValue(final int index, @Nonnull final Class<T> valueClass) {
+    return Objects.requireNonNull(getNullableValue(index, valueClass));
+  }
+
+  /**
+   * Construct Arguments for given {@link NamedFunctionInput}
+   *
+   * @param input the function input.
+   * @return the Arguments for the input.
+   */
+  @Nonnull
+  public static Arguments of(@Nonnull final NamedFunctionInput input) {
+    return new Arguments(input.getArguments());
+  }
+}
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunction.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunction.java
new file mode 100644
index 0000000000..0b536796d4
--- /dev/null
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunction.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.fhirpath.function.terminology;
+
+import au.csiro.pathling.fhirpath.FhirPath;
+import au.csiro.pathling.fhirpath.element.ElementPath;
+import au.csiro.pathling.fhirpath.function.NamedFunction;
+import au.csiro.pathling.fhirpath.function.NamedFunctionInput;
+import au.csiro.pathling.fhirpath.literal.BooleanLiteralPath;
+import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
+import au.csiro.pathling.fhirpath.parser.ParserContext;
+import au.csiro.pathling.sql.udf.PropertyUdf;
+import org.apache.commons.lang3.tuple.MutablePair;
+import org.apache.spark.sql.Column;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
+import org.hl7.fhir.r4.model.StringType;
+import javax.annotation.Nonnull;
+
+import java.util.List;
+import java.util.Optional;
+
+import static au.csiro.pathling.fhirpath.function.NamedFunction.checkNoArguments;
+import static au.csiro.pathling.fhirpath.function.NamedFunction.expressionFromInput;
+import static au.csiro.pathling.sql.Terminology.display;
+import static au.csiro.pathling.sql.Terminology.property_of;
+import static au.csiro.pathling.utilities.Preconditions.checkUserInput;
+import static au.csiro.pathling.utilities.Preconditions.wrapInUserInputError;
+
+/**
+ * This function returns the value of a property for a Coding.
+ *
+ * @author Piotr Szul
+ * @see <a href="https://pathling.csiro.au/docs/fhirpath/functions.html#property">property</a>
+ */
+public class PropertyFunction implements NamedFunction {
+
+  private static final String NAME = "property";
+
+  @Nonnull
+  @Override
+  public FhirPath invoke(@Nonnull final NamedFunctionInput input) {
+
+    validateInput(input);
+    final ElementPath inputPath = (ElementPath) input.getInput();
+    final String expression = expressionFromInput(input, NAME);
+
+    final Arguments arguments = Arguments.of(input);
+    final String propertyCode = arguments.getValue(0, StringType.class).asStringValue();
+    final String propertyTypeAsString = arguments.getValueOr(1,
+        new StringType(PropertyUdf.DEFAULT_PROPERTY_TYPE.toCode())).asStringValue();
+
+    final FHIRDefinedType propertyType = wrapInUserInputError(FHIRDefinedType::fromCode).apply(
+        propertyTypeAsString);
+
+    final Dataset<Row> dataset = inputPath.getDataset();
+    final Column propertyValues = property_of(inputPath.getValueColumn(), propertyCode,
+        propertyType);
+
+    // // The result is an array of property values per each input element, which we now
+    // // need to explode in the same way as for path traversal, creating unique element ids.
+    final MutablePair<Column, Column> valueAndEidColumns = new MutablePair<>();
+    final Dataset<Row> resultDataset = inputPath
+        .explodeArray(dataset, propertyValues, valueAndEidColumns);
+
+    if (FHIRDefinedType.CODING.equals(propertyType)) {
+      // Special case for CODING properties: we use the Coding definition form 
+      // the input path so that the results can be further traversed.
+      return inputPath.copy(expression, resultDataset, inputPath.getIdColumn(),
+          Optional.of(valueAndEidColumns.getRight()),
+          valueAndEidColumns.getLeft(), inputPath.isSingular(), inputPath.getThisColumn());
+    } else {
+      return ElementPath.build(expression, resultDataset, inputPath.getIdColumn(),
+          Optional.of(valueAndEidColumns.getRight()),
+          valueAndEidColumns.getLeft(), inputPath.isSingular(), inputPath.getCurrentResource(),
+          inputPath.getThisColumn(), propertyType);
+    }
+  }
+
+  private void validateInput(@Nonnull final NamedFunctionInput input) {
+    final ParserContext context = input.getContext();
+    checkUserInput(context.getTerminologyServiceFactory()
+        .isPresent(), "Attempt to call terminology function " + NAME
+        + " when terminology service has not been configured");
+
+    final FhirPath inputPath = input.getInput();
+    checkUserInput(inputPath instanceof ElementPath
+            && (((ElementPath) inputPath).getFhirType().equals(FHIRDefinedType.CODING)),
+        "Input to property function must be Coding but is: " + inputPath.getExpression());
+
+    final List<FhirPath> arguments = input.getArguments();
+    checkUserInput(arguments.size() >= 1 && arguments.size() <= 2,
+        NAME + " function accepts one required and one optional arguments");
+    checkUserInput(arguments.get(0) instanceof StringLiteralPath,
+        String.format("Function `%s` expects `%s` as argument %s", NAME, "String literal", 1));
+    checkUserInput(arguments.size() <= 1 || arguments.get(1) instanceof StringLiteralPath,
+        String.format("Function `%s` expects `%s` as argument %s", NAME, "String literal", 2));
+  }
+}
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/TranslateFunction.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/TranslateFunction.java
index d29abbbddf..fa240e4b90 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/TranslateFunction.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/TranslateFunction.java
@@ -31,17 +31,14 @@
 import au.csiro.pathling.fhirpath.function.NamedFunction;
 import au.csiro.pathling.fhirpath.function.NamedFunctionInput;
 import au.csiro.pathling.fhirpath.literal.BooleanLiteralPath;
-import au.csiro.pathling.fhirpath.literal.LiteralPath;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.stream.Collectors;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import au.csiro.pathling.utilities.Strings;
-import lombok.Builder;
 import org.apache.commons.lang3.tuple.MutablePair;
 import org.apache.spark.sql.Column;
 import org.apache.spark.sql.Dataset;
@@ -49,7 +46,6 @@
 import org.hl7.fhir.r4.model.BooleanType;
 import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence;
 import org.hl7.fhir.r4.model.StringType;
-import org.hl7.fhir.r4.model.Type;
 
 /**
  * A function that takes a set of Codings or CodeableConcepts as inputs and returns a set Codings
@@ -76,79 +72,6 @@ public class TranslateFunction implements NamedFunction {
   private static final String DEFAULT_EQUIVALENCE = "equivalent";
 
 
-  /**
-   * Helper class for dealing with optional arguments.
-   */
-  private static class Arguments {
-
-    @Nonnull
-    private final List<FhirPath> arguments;
-
-    private Arguments(@Nonnull final List<FhirPath> arguments) {
-      this.arguments = arguments;
-    }
-
-    /**
-     * Gets the value of an optional literal argument or the default value it the argument is
-     * missing.
-     *
-     * @param index the 0-based index of the argument.
-     * @param defaultValue the default value.
-     * @param <T> the Java type of the argument value.
-     * @return the java value of the requested argument.
-     */
-    @SuppressWarnings("unchecked")
-    @Nonnull
-    private <T extends Type> T getValueOr(final int index, @Nonnull final T defaultValue) {
-      return (index < arguments.size())
-             ? getValue(index, (Class<T>) defaultValue.getClass())
-             : defaultValue;
-    }
-
-    /**
-     * Gets the value of an optional literal argument that does not have a default value.
-     *
-     * @param index the 0-based index of the argument.
-     * @param <T> the Java type of the argument value.
-     * @return the java value of the requested argument.
-     */
-    @Nullable
-    private <T extends Type> T getNullableValue(final int index,
-        @Nonnull final Class<T> valueClass) {
-      final LiteralPath<?> literalPath;
-      try {
-        literalPath = (LiteralPath<?>) arguments.get(index);
-      } catch (final IndexOutOfBoundsException e) {
-        return null;
-      }
-      return valueClass.cast(literalPath.getValue());
-    }
-
-    /**
-     * Gets the value of the required literal argument.
-     *
-     * @param index the 0-based index of the argument
-     * @param valueClass the expected Java  class of the argument value
-     * @param <T> the HAPI type of the argument value
-     * @return the java value of the requested argument
-     */
-    @Nonnull
-    public <T extends Type> T getValue(final int index, @Nonnull final Class<T> valueClass) {
-      return Objects.requireNonNull(getNullableValue(index, valueClass));
-    }
-
-    /**
-     * Construct Arguments for given {@link NamedFunctionInput}
-     *
-     * @param input the function input.
-     * @return the Arguments for the input.
-     */
-    @Nonnull
-    public static Arguments of(@Nonnull final NamedFunctionInput input) {
-      return new Arguments(input.getArguments());
-    }
-  }
-
   @Nonnull
   @Override
   public FhirPath invoke(@Nonnull final NamedFunctionInput input) {
diff --git a/lib/python/examples/property_of.py b/lib/python/examples/property_of.py
new file mode 100755
index 0000000000..a47bff68d8
--- /dev/null
+++ b/lib/python/examples/property_of.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python
+
+#  Copyright 2022 Commonwealth Scientific and Industrial Research
+#  Organisation (CSIRO) ABN 41 687 119 230.
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import os
+
+from pathling import PathlingContext
+from pathling.functions import to_snomed_coding
+from pathling.udfs import property_of, display, PropertyType
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+
+pc = PathlingContext.create()
+
+csv = pc.spark.read.options(header=True).csv(
+    f'file://{os.path.join(HERE, "data/csv/conditions.csv")}'
+)
+
+# Get the parent codes for each code in the dataset.
+parents = csv.withColumn(
+    "PARENTS",
+    property_of(to_snomed_coding(csv.CODE), "parent", PropertyType.CODE),
+)
+# Split each parent code into a separate row.
+exploded_parents = parents.selectExpr(
+    "CODE", "DESCRIPTION", "explode_outer(PARENTS) AS PARENT"
+)
+# Retrieve the preferred term for each parent code.
+with_displays = exploded_parents.withColumn(
+    "PARENT_DISPLAY", display(to_snomed_coding(exploded_parents.PARENT))
+)
+with_displays.show()
diff --git a/lib/python/pathling/udfs.py b/lib/python/pathling/udfs.py
index 966b725cd6..2cbaee7731 100644
--- a/lib/python/pathling/udfs.py
+++ b/lib/python/pathling/udfs.py
@@ -50,6 +50,19 @@ def _invoke_function(name: str, *args: Any) -> Column:
     return Column(jf(*args))
 
 
+class PropertyType:
+    """
+    Allowed property types.
+    """
+    STRING = "string"
+    INTEGER = "integer"
+    BOOLEAN = "boolean"
+    DECIMAL = "decimal"
+    DATETIME = "dateTime"
+    CODE = "code"
+    CODING = "Coding"
+
+
 def member_of(coding: CodingArg, value_set_uri: str) -> Column:
     """
     Takes a Coding or array of Codings column as its input. Returns the column which contains a 
@@ -123,3 +136,22 @@ def display(coding: CodingArg) -> Column:
     :return: a Column containing the result of the operation (String).
     """
     return _invoke_function("display", _coding_to_java_column(coding))
+
+
+def property_of(coding: CodingArg, property_code: str,
+                property_type: str = PropertyType.STRING) -> Column:
+    """
+    Takes a Coding column as its input. Returns the Column, which contains the values of properties
+    for this coding with specified names and types. The type of the result column depends on the
+    types of the properties. Primitive FHIR types are mapped to their corresponding SQL primitives.
+    Complex types are mapped to their corresponding structs. The allowed property types are: code |
+    Coding | string | integer | boolean | dateTime | decimal. 
+    See also :class:`PropertyType`.
+    
+    :param coding: a Column containing a struct representation of a Coding
+    :param property_code: the code of the property to retrieve.
+    :param property_type: the type of the property to retrieve.
+    :return: the Column containing the result of the operation (array of property values)
+    """
+    return _invoke_function("property_of", _coding_to_java_column(coding), property_code,
+                            property_type)
diff --git a/site/docs/fhirpath/functions.md b/site/docs/fhirpath/functions.md
index 559bb37fbe..e08d89a866 100644
--- a/site/docs/fhirpath/functions.md
+++ b/site/docs/fhirpath/functions.md
@@ -94,6 +94,33 @@ Patient.name.given.count()
 
 See also: [count](https://hl7.org/fhirpath/#count-integer)
 
+## display
+
+```
+collection<Coding> -> display() : collection<String>
+```
+
+When invoked on a [Coding](./data-types#coding), returns the preferred display 
+term, according to the terminology server.
+
+Example:
+
+```
+Condition.code.display()
+```
+
+:::note
+The `display` function is a terminology function, which means that it requires
+a configured
+[terminology service](https://hl7.org/fhir/R4/terminology-service.html). See
+[Configuration](/docs/server/configuration#terminology-service) for details.
+:::
+
+:::note
+The `display` function is not within the FHIRPath specification, and is
+currently unique to the Pathling implementation.
+:::
+
 ## empty
 
 ```
@@ -254,6 +281,50 @@ function, for the purpose of disambiguating polymorphic resource references.
 
 See also: [ofType](https://hl7.org/fhirpath/#oftypetype-identifier-collection)
 
+## property
+
+```
+collection<Coding> -> property(code: string, type = 'string') : collection<String|Integer|DateTime|Decimal|Coding>
+```
+
+When invoked on a [Coding](./data-types#coding), returns any matching property
+values, using the specified `name` and `type` parameters.
+
+The `type` parameter has these possible values:
+
+- `string` (default)
+- `code`
+- `Coding`
+- `integer`
+- `boolean`
+- `DateTime`
+
+Both the `code` and the `type` of the property must be present within a 
+[lookup](https://www.hl7.org/fhir/codesystem-operation-lookup.html) response in 
+order for it to be returned by this function. If there are no matches, the 
+function will return an empty collection.
+
+See [Properties](https://www.hl7.org/fhir/codesystem.html#properties)
+in the FHIR specification for more information.
+
+Example:
+
+```
+Condition.code.coding.property('parent', 'code')
+```
+
+:::note
+The `property` function is a terminology function, which means that it requires
+a configured
+[terminology service](https://hl7.org/fhir/R4/terminology-service.html). See
+[Configuration](/docs/server/configuration#terminology-service) for details.
+:::
+
+:::note
+The `property` function is not within the FHIRPath specification, and is
+currently unique to the Pathling implementation.
+:::
+
 ## resolve
 
 ```
@@ -394,7 +465,7 @@ unique to the Pathling implementation.
 collection<Coding|CodeableConcept> -> translate(conceptMapUrl: string, reverse = false, equivalence = 'equivalent', target?: string) : collection<Coding>
 ```
 
-When invoked on a [Coding](./data-types#coding)-valued element, returns any
+When invoked on a [Coding](./data-types#coding), returns any
 matching concepts using the ConceptMap specified using `conceptMapUrl`.
 
 The `reverse` parameter controls the direction to traverse the map - `false`
diff --git a/site/docs/libraries/terminology.md b/site/docs/libraries/terminology.md
index ad37913229..b511dcd086 100644
--- a/site/docs/libraries/terminology.md
+++ b/site/docs/libraries/terminology.md
@@ -134,7 +134,7 @@ Results in:
 
 The `translate` function can be used to translate codes from one code system to
 another using maps that are known to the terminology server. In this example, we
-translate our SNOMED CT diagnosis codes into Read CTV3. 
+translate our SNOMED CT diagnosis codes into [Read CTV3](https://digital.nhs.uk/services/terminology-and-classifications/read-codes). 
 
 Please note that the
 type of the output column is the array of coding structs, as the translation may
@@ -362,6 +362,130 @@ Results in:
 | 16114001  | Fracture of ankle | false  |
 | 444814009 | Viral sinusitis   | true   |
 
+### Retrieving properties
+
+Some terminologies contain additional properties that are associated with codes. 
+You can query these properties using the `property_of` function.
+
+There is also a `display` function that can be used to retrieve the preferred 
+display term for each code.
+
+<!--suppress CheckEmptyScriptTag -->
+<Tabs>
+<TabItem value="python" label="Python">
+
+<PythonInstallation/>
+
+```python
+from pathling import PathlingContext
+from pathling.functions import to_snomed_coding
+from pathling.udfs import property_of, display, PropertyType
+
+pc = PathlingContext.create()
+csv = pc.spark.read.csv("conditions.csv")
+
+# Get the parent codes for each code in the dataset.
+parents = csv.withColumn(
+    "PARENTS",
+    property_of(to_snomed_coding(csv.CODE), "parent", PropertyType.CODE),
+)
+# Split each parent code into a separate row.
+exploded_parents = parents.selectExpr(
+    "CODE", "DESCRIPTION", "explode_outer(PARENTS) AS PARENT"
+)
+# Retrieve the preferred term for each parent code.
+with_displays = exploded_parents.withColumn(
+    "PARENT_DISPLAY", display(to_snomed_coding(exploded_parents.PARENT))
+)
+with_displays.show()
+```
+
+</TabItem>
+<TabItem value="scala" label="Scala">
+
+<ScalaInstallation/>
+
+```scala
+import au.csiro.pathling.library.PathlingContext
+import au.csiro.pathling.sql.Terminology
+import au.csiro.pathling.sql.Terminology._
+import au.csiro.pathling.library.TerminologyHelpers._
+import au.csiro.pathling.fhirpath.encoding.CodingEncoding
+
+val pc = PathlingContext.create()
+val csv = spark.read.csv("conditions.csv")
+
+// Get the parent codes for each code in the dataset.
+val parents = csv.withColumn(
+    "PARENTS",
+    property_of(toSnomedCoding(csv.col("CODE")), "parent", "code")
+)
+// Split each parent code into a separate row.
+val exploded_parents = parents.selectExpr(
+    "CODE", "DESCRIPTION", "explode_outer(PARENTS) AS PARENT"
+)
+// Retrieve the preferred term for each parent code.
+val with_displays = exploded_parents.withColumn(
+    "PARENT_DISPLAY", Terminology.display(toSnomedCoding(exploded_parents.col("PARENT")))
+)
+with_displays.show()
+```
+
+</TabItem>
+<TabItem value="java" label="Java">
+
+<JavaInstallation/>
+
+```java
+import static au.csiro.pathling.sql.Terminology.*;
+import static au.csiro.pathling.library.TerminologyHelpers.*;
+
+import au.csiro.pathling.library.PathlingContext;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+
+class MyApp {
+    public static void main(String[] args) {
+        PathlingContext pc = PathlingContext.create();
+        Dataset<Row> csv = pc.getSpark().read().csv("conditions.csv");
+
+        // Get the parent codes for each code in the dataset.
+        Dataset<Row> parents = csv.withColumn(
+                "PARENTS",
+                property_of(toSnomedCoding(csv.col("CODE")), "parent", "code")
+        );
+        // Split each parent code into a separate row.
+        Dataset<Row> exploded_parents = parents.selectExpr(
+                "CODE", "DESCRIPTION", "explode_outer(PARENTS) AS PARENT"
+        );
+        // Retrieve the preferred term for each parent code.
+        Dataset<Row> with_displays = exploded_parents.withColumn(
+                "PARENT_DISPLAY", Terminology.display(toSnomedCoding(exploded_parents.col("PARENT")))
+        );
+        with_displays.show();
+    }
+}
+
+```
+
+</TabItem>
+</Tabs>
+
+Results in:
+
+| CODE      | DESCRIPTION               | PARENT    | PARENT_DISPLAY                             |
+|-----------|---------------------------|-----------|--------------------------------------------|
+| 65363002  | Otitis media              | 43275000  | Otitis                                     |
+| 65363002  | Otitis media              | 68996008  | Disorder of middle ear                     |
+| 16114001  | Fracture of ankle         | 125603006 | Injury of ankle                            |
+| 16114001  | Fracture of ankle         | 46866001  | Fracture of lower limb                     |
+| 444814009 | Viral sinusitis           | 36971009  | Sinusitis                                  |
+| 444814009 | Viral sinusitis           | 281794004 | Viral upper respiratory tract infection    |
+| 444814009 | Viral sinusitis           | 363166002 | Infective disorder of head                 |
+| 444814009 | Viral sinusitis           | 36971009  | Sinusitis                                  |
+| 444814009 | Viral sinusitis           | 281794004 | Viral upper respiratory tract infection    |
+| 444814009 | Viral sinusitis           | 363166002 | Infective disorder of head                 |
+
 ### Authentication
 
 Pathling can be configured to connect to a protected terminology server by
diff --git a/site/src/components/installation.jsx b/site/src/components/installation.jsx
index 84b830ff2e..59dc3d761c 100644
--- a/site/src/components/installation.jsx
+++ b/site/src/components/installation.jsx
@@ -1,19 +1,24 @@
 import React from "react";
 
-export const PythonInstallation = () => <p>
-To use the Pathling encoders from Python, install
-the <a href="https://pypi.org/project/pathling/">pathling</a> package
-using <a href="https://pip.pypa.io/">pip</a>. Note that Java 11 or later is required, with your 
-<code>JAVA_HOME</code> properly set.
-</p>;
+export const PythonInstallation = () => (
+  <p>
+    To use the Pathling encoders from Python, install the{" "}
+    <a href="https://pypi.org/project/pathling/">pathling</a> package using{" "}
+    <a href="https://pip.pypa.io/">pip</a>. Note that Java 11 or later is required, with your{" "}
+    <code>JAVA_HOME</code> properly set.
+  </p>
+);
 
-export const ScalaInstallation = () => <p>
-To use the Pathling encoders from Scala, install the <code>au.csiro.pathling:encoders</code> <a
-    href="https://maven.apache.org/">Maven</a> package.
-</p>;
-
-export const JavaInstallation = () => <p>
-To use the Pathling encoders from Java, install the <code>au.csiro.pathling:encoders</code> <a
-    href="https://maven.apache.org/">Maven</a> package.
-</p>;
+export const ScalaInstallation = () => (
+  <p>
+    To use the Pathling encoders from Scala, install the <code>au.csiro.pathling:encoders</code>{" "}
+    <a href="https://maven.apache.org/">Maven</a> package.
+  </p>
+);
 
+export const JavaInstallation = () => (
+  <p>
+    To use the Pathling encoders from Java, install the <code>au.csiro.pathling:encoders</code>{" "}
+    <a href="https://maven.apache.org/">Maven</a> package.
+  </p>
+);
diff --git a/terminology/pom.xml b/terminology/pom.xml
index ae78936450..a1b2ab184a 100644
--- a/terminology/pom.xml
+++ b/terminology/pom.xml
@@ -127,6 +127,11 @@
       <artifactId>wiremock-jre8</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-params</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java b/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java
index 1560ad813d..da9af94710 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java
@@ -1,17 +1,40 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
 package au.csiro.pathling.fhir;
 
+import java.beans.PropertyDescriptor;
 import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.NoArgsConstructor;
-import org.apache.commons.beanutils.BeanUtils;
+import org.apache.commons.beanutils.PropertyUtils;
 import org.hl7.fhir.r4.model.CodeType;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.Parameters;
 import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
+import org.hl7.fhir.r4.model.Type;
 import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
 
 /**
@@ -26,8 +49,27 @@ private ParametersUtils() {
   private static void setProperty(@Nonnull final Object bean, @Nonnull final String name,
       @Nullable final Object value) {
     try {
-      BeanUtils.setProperty(bean, name, value);
-    } catch (final IllegalAccessException | InvocationTargetException e) {
+      final PropertyDescriptor descriptor = PropertyUtils.getPropertyDescriptor(bean,
+          name);
+      if (descriptor != null) {
+        Object currentValue = descriptor.getReadMethod().invoke(bean);
+        if (currentValue == null) {
+          if (List.class.isAssignableFrom(descriptor.getPropertyType())) {
+            final List<Object> newList = new ArrayList<>();
+            newList.add(value);
+            descriptor.getWriteMethod().invoke(bean, newList);
+          } else {
+            descriptor.getWriteMethod().invoke(bean, value);
+          }
+        } else if (List.class.isAssignableFrom(descriptor.getPropertyType())) {
+          //noinspection unchecked
+          ((List<Object>) currentValue).add(value);
+        } else {
+          throw new IllegalStateException("Overwriting value of singular property: " + name);
+        }
+
+      }
+    } catch (final IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
       throw new RuntimeException(e);
     }
   }
@@ -45,7 +87,13 @@ private static void setProperty(@Nonnull final Object bean, @Nonnull final Strin
   public static <T> T partsToBean(@Nonnull final ParametersParameterComponent component,
       @Nonnull final Supplier<T> supplier) {
     final T result = supplier.get();
-    component.getPart().forEach(p -> setProperty(result, p.getName(), p.getValue()));
+    for (ParametersParameterComponent p : component.getPart()) {
+      if (p.hasValue()) {
+        setProperty(result, p.getName(), p.getValue());
+      } else if (p.hasPart()) {
+        setProperty(result, p.getName(), partsToBean(p, supplier));
+      }
+    }
     return result;
   }
 
@@ -106,4 +154,49 @@ public static Stream<MatchPart> toMatchParts(final @Nonnull Parameters parameter
                .map(ParametersUtils::componentToMatchPart)
            : Stream.empty();
   }
+
+  /**
+   * Object representation of the 'property' part from $lookup results.
+   */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class PropertyPart {
+
+    @Nonnull
+    CodeType code;
+
+    @Nullable
+    Type value;
+
+    @Nullable
+    List<PropertyPart> subproperty;
+  }
+
+  @Nullable
+  private static PropertyPart toProperty(@Nonnull final ParametersParameterComponent component) {
+    if (!component.hasPart()) {
+      return new PropertyPart(new CodeType(component.getName()), component.getValue(), null);
+    } else if ("property".equals(component.getName())) {
+      return partsToBean(component, PropertyPart::new);
+    } else {
+      return null;
+    }
+  }
+
+  /**
+   * Extracts 'property' parts from the result of '$lookup'
+   *
+   * @param parameters the parameters to convert.
+   * @return the stream of 'property' parts.
+   */
+  @Nonnull
+  public static Stream<PropertyPart> toProperties(
+      @Nonnull final Parameters parameters) {
+
+    return parameters.getParameter().stream()
+        .map(ParametersUtils::toProperty)
+        .filter(Objects::nonNull);
+  }
+
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java
index 7a13692c4e..97a66b2dd2 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java
@@ -1,3 +1,20 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
 package au.csiro.pathling.fhir;
 
 import static au.csiro.pathling.utilities.Preconditions.checkNotNull;
@@ -81,8 +98,7 @@ Parameters lookup(
       @Nonnull @OperationParam(name = "system") UriType system,
       @Nullable @OperationParam(name = "version") StringType version,
       @Nonnull @OperationParam(name = "code") CodeType code,
-      @Nullable @OperationParam(name = "property") CodeType property,
-      @Nullable @OperationParam(name = "displayLanguage") CodeType displayLanguage
+      @Nullable @OperationParam(name = "property") CodeType property
   );
 
 
@@ -97,7 +113,7 @@ Parameters lookup(
    * @param httpClient the {@link HttpClient} instance to use for HTTP request.
    * @return the new instance of {@link TerminologyClient2}.
    */
-  
+
   static TerminologyClient2 build(@Nonnull final FhirContext fhirContext,
       @Nonnull final String terminologyServerUrl,
       final boolean verboseRequestLogging, @Nonnull final TerminologyAuthConfiguration authConfig,
@@ -215,8 +231,7 @@ public Parameters subsumes(@Nonnull final CodeType codeA, @Nonnull final CodeTyp
   @Override
   public Parameters lookup(@Nonnull final UriType system,
       @Nullable final StringType version, @Nonnull final CodeType code,
-      @Nullable final CodeType property,
-      @Nullable final CodeType displayLanguage) {
+      @Nullable final CodeType property) {
     final Parameters params = new Parameters();
     params.addParameter().setName("system").setValue(system);
     params.addParameter().setName("code").setValue(code);
@@ -226,9 +241,6 @@ public Parameters lookup(@Nonnull final UriType system,
     if (property != null) {
       params.addParameter().setName("property").setValue(property);
     }
-    if (displayLanguage != null) {
-      params.addParameter().setName("displayLanguage").setValue(displayLanguage);
-    }
     return fhirClient.operation()
         .onType(CodeSystem.class)
         .named("$lookup")
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java b/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java
index aff55e4665..d2c94f103c 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java
@@ -1,15 +1,19 @@
 package au.csiro.pathling.sql;
 
+import static au.csiro.pathling.utilities.Preconditions.wrapInUserInputError;
+import static java.util.Objects.nonNull;
 import static org.apache.spark.sql.functions.call_udf;
 import static org.apache.spark.sql.functions.lit;
 
 import au.csiro.pathling.sql.udf.DisplayUdf;
+import au.csiro.pathling.sql.udf.PropertyUdf;
 import au.csiro.pathling.sql.udf.SubsumesUdf;
 import au.csiro.pathling.sql.udf.TranslateUdf;
 import au.csiro.pathling.sql.udf.MemberOfUdf;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import org.apache.spark.sql.Column;
+import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
 
 /**
  * JAVA API for terminology UDFs
@@ -111,8 +115,7 @@ static Column subsumes(@Nonnull final Column codingA, @Nonnull final Column codi
    * Boolean value, indicating whether the left Coding is subsumed by the right Coding.
    *
    * @param codingA a Column containing a struct representation of a Coding or an array of Codings.
-   * @param codingB a Column containing a struct representation of a Coding or an array of
-   * Codings.
+   * @param codingB a Column containing a struct representation of a Coding or an array of Codings.
    * @return the Column containing the result of the operation (boolean)
    */
   @Nonnull
@@ -124,10 +127,54 @@ static Column subsumed_by(@Nonnull final Column codingA, @Nonnull final Column c
    * Takes a Coding column as its input. Returns the Column, which contains the canonical display
    * name associated with the given code.
    *
+   * @param coding a Column containing a struct representation of a Coding
    * @return the Column containing the result of the operation (String)
    */
   @Nonnull
   static Column display(@Nonnull final Column coding) {
     return call_udf(DisplayUdf.FUNCTION_NAME, coding);
   }
+
+  /**
+   * Takes a Coding column as its input. Returns the Column, which contains the values of properties
+   * for this coding with specified names and types. The type of the result column depends on the
+   * types of the properties. Primitive FHIR types are mapped to their corresponding SQL primitives.
+   * Complex types are mapped to their corresponding structs. The allowed property types are: code |
+   * Coding | string | integer | boolean | dateTime | decimal.
+   *
+   * @param coding a Column containing a struct representation of a Coding
+   * @param propertyCode the code of the property to retrieve.
+   * @param propertyType the type of the property to retrieve.
+   * @return the Column containing the result of the operation (array of property values)
+   */
+  @Nonnull
+  static Column property_of(@Nonnull final Column coding, @Nonnull final String propertyCode,
+      @Nonnull final FHIRDefinedType propertyType) {
+    return call_udf(PropertyUdf.getNameForType(propertyType), coding, lit(propertyCode));
+  }
+
+  /**
+   * Retrieves properties of a concept.
+   *
+   * @see Terminology#property_of(Column, String, FHIRDefinedType)
+   */
+  @Nonnull
+  static Column property_of(@Nonnull final Column coding, @Nonnull final String propertyCode,
+      @Nullable final String propertyType) {
+
+    return property_of(coding, propertyCode,
+        nonNull(propertyType)
+        ? wrapInUserInputError(FHIRDefinedType::fromCode).apply(propertyType)
+        : PropertyUdf.DEFAULT_PROPERTY_TYPE);
+  }
+
+  /**
+   * Retrieves properties of a concept.
+   *
+   * @see Terminology#property_of(Column, String, FHIRDefinedType)
+   */
+  @Nonnull
+  static Column property_of(@Nonnull final Column coding, @Nonnull final String propertyCode) {
+    return property_of(coding, propertyCode, PropertyUdf.DEFAULT_PROPERTY_TYPE);
+  }
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/DisplayUdf.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/DisplayUdf.java
index 8d7408282e..0a04a04a42 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/DisplayUdf.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/DisplayUdf.java
@@ -25,9 +25,9 @@
 public class DisplayUdf implements SqlFunction,
     SqlFunction1<Row, String> {
 
-  public static final String DISPLAY_PROPERTY_CODE = "display";
   private static final long serialVersionUID = 7605853352299165569L;
-
+  
+  public static final String DISPLAY_PROPERTY_CODE = "display";
   public static final String FUNCTION_NAME = "display";
   public static final DataType RETURN_TYPE = DataTypes.StringType;
 
@@ -55,7 +55,7 @@ protected String doCall(@Nullable final Coding coding) {
     }
     final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
     final List<PropertyOrDesignation> result = terminologyService.lookup(
-        coding, DISPLAY_PROPERTY_CODE, null);
+        coding, DISPLAY_PROPERTY_CODE);
 
     final Optional<Property> maybeDisplayName = result.stream()
         .filter(s -> s instanceof Property)
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/PropertyUdf.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/PropertyUdf.java
new file mode 100644
index 0000000000..b97b852be4
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/PropertyUdf.java
@@ -0,0 +1,218 @@
+package au.csiro.pathling.sql.udf;
+
+import static au.csiro.pathling.fhirpath.encoding.CodingEncoding.decode;
+import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.isValidCoding;
+import static java.util.Objects.nonNull;
+import static java.util.Objects.requireNonNull;
+import static org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType.BOOLEAN;
+import static org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType.CODE;
+import static org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType.CODING;
+import static org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType.DATETIME;
+import static org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType.DECIMAL;
+import static org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType.INTEGER;
+import static org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType.STRING;
+
+import au.csiro.pathling.encoders.datatypes.DecimalCustomCoder;
+import au.csiro.pathling.errors.InvalidUserInputError;
+import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService2.Property;
+import au.csiro.pathling.terminology.TerminologyService2.PropertyOrDesignation;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import ca.uhn.fhir.model.api.annotation.DatatypeDef;
+import com.google.common.collect.ImmutableSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.DataTypes;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.DateTimeType;
+import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
+import org.hl7.fhir.r4.model.PrimitiveType;
+import org.hl7.fhir.r4.model.Type;
+
+/**
+ * The implementation of the 'property()' udf.
+ */
+@Slf4j
+public class PropertyUdf implements SqlFunction,
+    SqlFunction2<Row, String, Object[]> {
+
+  private static final long serialVersionUID = 7605853352299165569L;
+  private static final String FUNCTION_BASE_NAME = "property";
+
+  @Nonnull
+  public static final Set<FHIRDefinedType> ALLOWED_FHIR_TYPES = ImmutableSet.of(
+      STRING, CODE, INTEGER, BOOLEAN, DECIMAL, DATETIME, CODING
+  );
+
+  @Nonnull
+  private final FHIRDefinedType propertyType;
+
+  @Nonnull
+  private final TerminologyServiceFactory terminologyServiceFactory;
+
+  /**
+   * The default property FHIR type.
+   */
+  public static final FHIRDefinedType DEFAULT_PROPERTY_TYPE = STRING;
+
+
+  private PropertyUdf(@Nonnull final TerminologyServiceFactory terminologyServiceFactory,
+      @Nonnull final FHIRDefinedType propertyType) {
+    if (!ALLOWED_FHIR_TYPES.contains(propertyType)) {
+      throw new IllegalArgumentException("PropertyUDF does not support type: " + propertyType);
+    }
+    this.propertyType = propertyType;
+    this.terminologyServiceFactory = terminologyServiceFactory;
+  }
+
+  @Override
+  public String getName() {
+    return getNameForType(propertyType);
+  }
+
+
+  // TODO: This should somehow be integrated with the encoders
+  @Nonnull
+  private DataType geElementType() {
+    // code | Coding | string | integer | boolean | dateTime | decimal
+    switch (propertyType) {
+      case STRING:
+      case CODE:
+      case DATETIME:
+        return DataTypes.StringType;
+      case INTEGER:
+        return DataTypes.IntegerType;
+      case BOOLEAN:
+        return DataTypes.BooleanType;
+      case DECIMAL:
+        return DecimalCustomCoder.decimalType();
+      case CODING:
+        return CodingEncoding.codingStructType();
+      default:
+        throw new IllegalArgumentException("Cannot map FhirType: " + propertyType);
+    }
+  }
+
+  // TODO: This should somehow be integrated with the encoders
+  @Nonnull
+  private static Object toObjectValue(@Nonnull final Type value) {
+    // Special case for DateTimeType as we represent the as SQL strings
+    if (value instanceof DateTimeType) {
+      return value.primitiveValue();
+    } else {
+      return value instanceof PrimitiveType<?>
+             ? ((PrimitiveType<?>) value).getValue()
+             : value;
+    }
+  }
+
+  @Override
+  public DataType getReturnType() {
+    return DataTypes.createArrayType(geElementType());
+  }
+
+  @Nullable
+  protected Object[] doCall(@Nullable final Coding coding, @Nullable final String propertyCode) {
+    if (propertyCode == null || !isValidCoding(coding)) {
+      return null;
+    }
+    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    final List<PropertyOrDesignation> result = terminologyService.lookup(
+        requireNonNull(coding), propertyCode);
+
+    return result.stream()
+        .filter(s -> s instanceof Property)
+        .map(s -> (Property) s)
+        .filter(p -> propertyCode.equals(p.getCode()))
+        .map(Property::getValue)
+        .filter(v -> propertyType.toCode().equals(v.fhirType()))
+        .map(PropertyUdf::toObjectValue)
+        .toArray(Object[]::new);
+  }
+
+  @Nullable
+  private Object[] encodeArray(@Nullable Object[] objectRows) {
+    if (nonNull(objectRows) && CODING.equals(propertyType)) {
+      return Stream.of(objectRows)
+          .map(Coding.class::cast)
+          .map(CodingEncoding::encode)
+          .toArray(Row[]::new);
+    } else {
+      return objectRows;
+    }
+  }
+
+  @Nullable
+  @Override
+  public Object[] call(@Nullable final Row codingRow, @Nullable final String propertyCode) {
+    return encodeArray(doCall(nonNull(codingRow)
+                              ? decode(codingRow)
+                              : null, propertyCode));
+  }
+
+  /**
+   * Creates the instance of PropertyUdf for the specified FHIR type.
+   *
+   * @param terminologyServiceFactory the terminology service factory to use.
+   * @param propertyType the type of the properties returned by this UDF.
+   * @return a new instance of {@link PropertyUdf}
+   */
+  @Nonnull
+  public static PropertyUdf forType(
+      @Nonnull final TerminologyServiceFactory terminologyServiceFactory,
+      @Nonnull final FHIRDefinedType propertyType) {
+    return new PropertyUdf(terminologyServiceFactory, propertyType);
+  }
+
+  /**
+   * Creates the instance of PropertyUdf for the specified FHIR class.
+   *
+   * @param terminologyServiceFactory the terminology service factory to use.
+   * @param propertyClass the class FHIR clas of the returned by this UDF.
+   * @return a new instance of {@link PropertyUdf}
+   */
+  @Nonnull
+  public static PropertyUdf forClass(
+      @Nonnull final TerminologyServiceFactory terminologyServiceFactory,
+      @Nonnull final Class<? extends Type> propertyClass) {
+    final FHIRDefinedType propertyType = FHIRDefinedType.fromCode(
+        propertyClass.getAnnotation(DatatypeDef.class).name());
+    return forType(terminologyServiceFactory, propertyType);
+  }
+
+  /**
+   * Gets the name under which the UDF for given type is registered in SparkSession.
+   *
+   * @param propertyType the FHIR type.
+   * @return the name of the UDF.
+   */
+  @Nonnull
+  public static String getNameForType(FHIRDefinedType propertyType) {
+    if (!ALLOWED_FHIR_TYPES.contains(propertyType)) {
+      throw new InvalidUserInputError(
+          String.format("Type: '%s' is not supported for 'property' udf", propertyType.toCode()));
+    }
+    return String.format("%s_%s", FUNCTION_BASE_NAME, propertyType.getDisplay());
+  }
+
+  /**
+   * Creates variants of {@link PropertyUdf} for all allowed property types.
+   *
+   * @param terminologyServiceFactory the terminology service factory to use.
+   * @return the list of supported variants of {@link PropertyUdf}.
+   */
+  @Nonnull
+  public static List<PropertyUdf> createAll(
+      @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+    return ALLOWED_FHIR_TYPES.stream().map(t -> forType(terminologyServiceFactory, t))
+        .collect(Collectors.toUnmodifiableList());
+  }
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.java
index c34e180b78..fcfac722d0 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.java
@@ -21,7 +21,12 @@
 import java.util.Collections;
 import java.util.List;
 import javax.annotation.Nonnull;
+import com.google.common.collect.ImmutableList;
 import org.apache.spark.sql.SparkSession;
+import org.hl7.fhir.r4.model.CodeType;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.IntegerType;
+import org.hl7.fhir.r4.model.StringType;
 
 /**
  * The {@link SqlFunctionRegistrar} for terminology UDFs.
@@ -30,7 +35,10 @@ public class TerminologyUdfRegistrar extends SqlFunctionRegistrar {
 
   public TerminologyUdfRegistrar(@Nonnull TerminologyServiceFactory tsf) {
     super(List.of(new DisplayUdf(tsf)),
-        List.of(new MemberOfUdf(tsf)),
+        ImmutableList.<SqlFunction2<?, ?, ?>>builder()
+            .add(new MemberOfUdf(tsf))
+            .addAll(PropertyUdf.createAll(tsf))
+            .build(),
         List.of(new SubsumesUdf(tsf)),
         Collections.emptyList(),
         List.of(new TranslateUdf(tsf)));
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java
index 4e00db2218..8f0a03fc9f 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java
@@ -5,9 +5,11 @@
 import static au.csiro.pathling.fhir.ParametersUtils.toSubsumptionOutcome;
 import static java.util.Objects.isNull;
 import static java.util.Objects.nonNull;
-import static java.util.function.Predicate.not;
+import static java.util.Objects.requireNonNull;
 import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.NOTSUBSUMED;
 
+import au.csiro.pathling.fhir.ParametersUtils;
+import au.csiro.pathling.fhir.ParametersUtils.PropertyPart;
 import au.csiro.pathling.fhir.TerminologyClient2;
 import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
 import java.io.Closeable;
@@ -17,13 +19,13 @@
 import java.util.Objects;
 import java.util.function.Function;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import org.hl7.fhir.r4.model.BooleanType;
 import org.hl7.fhir.r4.model.CodeType;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.Parameters;
-import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
 import org.hl7.fhir.r4.model.StringType;
 import org.hl7.fhir.r4.model.UriType;
 import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
@@ -152,32 +154,37 @@ public ConceptSubsumptionOutcome subsumes(@Nonnull final Coding codingA,
 
   @Nonnull
   private static List<PropertyOrDesignation> toPropertiesAndDesignations(
-      @Nonnull final Parameters parameters, @Nullable final String propertyCode) {
-
-    return parameters.getParameter().stream()
-        .filter(not(ParametersParameterComponent::hasPart))
-        .filter(p -> isNull(propertyCode) || propertyCode.equals(p.getName()))
-        .map(p -> Property.of(p.getName(), p.getValue()))
+      @Nonnull final Parameters parameters,
+      @Nullable final String propertyCode) {
+
+    final List<PropertyPart> x = ParametersUtils.toProperties(
+        parameters).collect(Collectors.toUnmodifiableList());
+    return x.stream()
+        .flatMap(part -> nonNull(part.getSubproperty())
+                         ? part.getSubproperty().stream()
+                         : Stream.of(part))
+        .map(part -> Property.of(part.getCode().getValue(), requireNonNull(part.getValue())))
+        .filter(property -> Objects.isNull(propertyCode) || propertyCode.equals(property.getCode()))
         .collect(Collectors.toUnmodifiableList());
   }
 
   @Nonnull
   @Override
   public List<PropertyOrDesignation> lookup(@Nonnull final Coding coding,
-      @Nullable final String property,
-      @Nullable final String displayLanguage) {
+      @Nullable final String property) {
 
     if (isNull(coding.getSystem()) || isNull(coding.getCode())) {
       return Collections.emptyList();
     }
-
-    return toPropertiesAndDesignations(terminologyClient.lookup(
-        required(UriType::new, coding.getSystem()),
-        optional(StringType::new, coding.getVersion()),
-        required(CodeType::new, coding.getCode()),
-        optional(CodeType::new, property),
-        optional(CodeType::new, displayLanguage)
-    ), property);
+    try {
+      return toPropertiesAndDesignations(terminologyClient.lookup(
+          required(UriType::new, coding.getSystem()),
+          optional(StringType::new, coding.getVersion()),
+          required(CodeType::new, coding.getCode()),
+          optional(CodeType::new, property)), property);
+    } catch (final BaseServerResponseException e) {
+      return handleError(e, Collections.emptyList());
+    }
   }
 
   @Override
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java
index 3c1ebf4e5a..a379706eff 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java
@@ -152,13 +152,11 @@ public boolean equals(final Object o) {
    * operation.
    *
    * @param coding the coding to lookup.
-   * @param property the code of the property to lookup. If not null only the properties with
-   * matching names are returned.
-   * @param displayLanguage the language to use for narrowing down designations.
+   * @param propertyCode the code of the propertyCode to lookup. If not null only the properties
+   * with matching codes are returned.
    * @return the list of properties and/or designations.
    */
   @Nonnull
-  List<PropertyOrDesignation> lookup(@Nonnull Coding coding, @Nullable String property,
-      @Nullable String displayLanguage);
+  List<PropertyOrDesignation> lookup(@Nonnull Coding coding, @Nullable String propertyCode);
 
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java
index 70e05958e3..77dbb76bcd 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java
@@ -22,6 +22,8 @@
 import lombok.AllArgsConstructor;
 import lombok.Value;
 import org.apache.commons.lang3.tuple.Pair;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.CodeType;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.StringType;
 import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
@@ -171,14 +173,24 @@ public ConceptSubsumptionOutcome subsumes(@Nonnull final Coding codingA,
   @Nonnull
   @Override
   public List<PropertyOrDesignation> lookup(@Nonnull final Coding coding,
-      @Nullable final String property,
-      @Nullable final String displayLanguage) {
+      @Nullable final String propertyCode) {
 
     final Coding snomedCoding = new Coding("http://snomed.info/sct", "439319006",
         "Screening for phenothiazine in serum");
 
+    final Coding loincCoding = new Coding("http://loinc.org", "55915-3",
+        null);
+
     if (SystemAndCode.of(snomedCoding).equals(SystemAndCode.of(coding))) {
-      return List.of(Property.of("display", new StringType(snomedCoding.getDisplay())));
+      return List.of(
+          Property.of("display", new StringType(snomedCoding.getDisplay())),
+          Property.of("parent", new CodeType("785673007")),
+          Property.of("parent", new CodeType("74754006"))
+      );
+    } else if (SystemAndCode.of(loincCoding).equals(SystemAndCode.of(coding))) {
+      return List.of(
+          Property.of("inactive", new BooleanType(false))
+      );
     } else {
       return Collections.emptyList();
     }


Test patch:
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/DisplayFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/DisplayFunctionTest.java
index 1f51ce6cbe..bfc44ffa7d 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/DisplayFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/DisplayFunctionTest.java
@@ -142,7 +142,6 @@ public void displayCoding() {
         .hasFhirType(FHIRDefinedType.STRING)
         .isNotSingular()
         .selectOrderedResultWithEid()
-        .debugAllRows()
         .hasRows(expectedResult);
   }
 
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunctionTest.java
new file mode 100644
index 0000000000..6e17c900e1
--- /dev/null
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunctionTest.java
@@ -0,0 +1,349 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.fhirpath.function.terminology;
+
+import static au.csiro.pathling.test.assertions.Assertions.assertThat;
+import static au.csiro.pathling.test.builders.DatasetBuilder.makeEid;
+import static au.csiro.pathling.test.helpers.SparkHelpers.codingStructType;
+import static au.csiro.pathling.test.helpers.SparkHelpers.rowFromCoding;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+
+import au.csiro.pathling.errors.InvalidUserInputError;
+import au.csiro.pathling.fhirpath.FhirPath;
+import au.csiro.pathling.fhirpath.element.CodingPath;
+import au.csiro.pathling.fhirpath.element.ElementDefinition;
+import au.csiro.pathling.fhirpath.element.ElementPath;
+import au.csiro.pathling.fhirpath.function.NamedFunction;
+import au.csiro.pathling.fhirpath.function.NamedFunctionInput;
+import au.csiro.pathling.fhirpath.literal.IntegerLiteralPath;
+import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
+import au.csiro.pathling.fhirpath.parser.ParserContext;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.test.AbstractTerminologyTestBase;
+import au.csiro.pathling.test.builders.DatasetBuilder;
+import au.csiro.pathling.test.builders.ElementPathBuilder;
+import au.csiro.pathling.test.builders.ParserContextBuilder;
+import au.csiro.pathling.test.helpers.FhirHelpers;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
+import ca.uhn.fhir.context.FhirContext;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.DataTypes;
+import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.Type;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+/**
+ * @author Piotr Szul
+ */
+@SpringBootTest
+@Tag("UnitTest")
+class PropertyFunctionTest extends AbstractTerminologyTestBase {
+
+  @Autowired
+  SparkSession spark;
+
+  @Autowired
+  FhirContext fhirContext;
+
+  @Autowired
+  TerminologyServiceFactory terminologyServiceFactory;
+
+  @Autowired
+  TerminologyService2 terminologyService;
+
+  @BeforeEach
+  void setUp() {
+    reset(terminologyService);
+  }
+
+  private void checkPropertyOfCoding(final String propertyCode,
+      final Optional<String> maybePropertyType,
+      final Type[] propertyAFhirValues, final Type[] propertyBFhirValues,
+      final FHIRDefinedType expectedResultType,
+      final Dataset<Row> expectedResult) {
+    TerminologyServiceHelpers.setupLookup(terminologyService)
+        .withProperty(CODING_A, "propertyA", propertyAFhirValues)
+        .withProperty(CODING_B, "propertyB", propertyBFhirValues);
+
+    final Optional<ElementDefinition> optionalDefinition = FhirHelpers
+        .getChildOfResource(fhirContext, "Encounter", "class");
+    assertTrue(optionalDefinition.isPresent());
+    final ElementDefinition definition = optionalDefinition.get();
+
+    final Dataset<Row> inputDataset = new DatasetBuilder(spark)
+        .withIdColumn()
+        .withEidColumn()
+        .withStructTypeColumns(codingStructType())
+        .withRow("encounter-1", makeEid(0), rowFromCoding(CODING_A))
+        .withRow("encounter-1", makeEid(1), rowFromCoding(INVALID_CODING_0))
+        .withRow("encounter-2", makeEid(0), rowFromCoding(CODING_B))
+        .withRow("encounter-3", null, null)
+        .buildWithStructValue();
+
+    final CodingPath inputExpression = (CodingPath) new ElementPathBuilder(spark)
+        .dataset(inputDataset)
+        .idAndEidAndValueColumns()
+        .expression("Encounter.class")
+        .singular(false)
+        .definition(definition)
+        .buildDefined();
+
+    // Prepare the inputs to the function.
+    final ParserContext parserContext = new ParserContextBuilder(spark, fhirContext)
+        .idColumn(inputExpression.getIdColumn())
+        .terminologyClientFactory(terminologyServiceFactory)
+        .build();
+
+    final StringLiteralPath propertyCodeArgument = StringLiteralPath
+        .fromString("'" + propertyCode + "'", inputExpression);
+
+    final List<FhirPath> arguments = maybePropertyType
+        .map(propertyType -> {
+          final StringLiteralPath propertyTypeArgument = StringLiteralPath
+              .fromString("'" + propertyType + "'", inputExpression);
+          return List.<FhirPath>of(propertyCodeArgument, propertyTypeArgument);
+        }).orElse(List.of(propertyCodeArgument));
+
+    final NamedFunctionInput propertyInput = new NamedFunctionInput(parserContext, inputExpression,
+        arguments);
+
+    // Invoke the function.
+    final FhirPath result = NamedFunction.getInstance("property").invoke(propertyInput);
+
+    // Check the result.
+    assertThat(result)
+        .hasExpression(
+            maybePropertyType
+                .map(
+                    propertyType -> String.format("Encounter.class.property('%s', '%s')",
+                        propertyCode,
+                        propertyType))
+                .orElse(String.format("Encounter.class.property('%s')", propertyCode)
+                ))
+        .isElementPath(ElementPath.class)
+        .hasFhirType(expectedResultType)
+        .isNotSingular()
+        .selectOrderedResultWithEid()
+        .debugAllRows()
+        .hasRows(expectedResult);
+
+    // Check that the ElementPath for CODING type has the correct Coding definition.
+    if (FHIRDefinedType.CODING.equals(expectedResultType)) {
+      assertThat(result)
+          .isElementPath(ElementPath.class)
+          .hasDefinition(definition);
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("propertyParameters")
+  public void propertyAOfCoding(final String propertyType, final DataType resultDataType,
+      final Type[] propertyAFhirValues, final Type[] propertyBFhirValues,
+      final Object[] propertyASqlValues, final Object[] propertyBSqlValues) {
+
+    final Dataset<Row> expectedResult = new DatasetBuilder(spark)
+        .withIdColumn()
+        .withEidColumn()
+        .withColumn(resultDataType)
+        .withRow("encounter-1", makeEid(0, 0), propertyASqlValues[0])
+        .withRow("encounter-1", makeEid(1, 0), null)
+        .withRow("encounter-2", makeEid(0, 0), null)
+        .withRow("encounter-3", null, null)
+        .build();
+    checkPropertyOfCoding("propertyA", Optional.of(propertyType), propertyAFhirValues,
+        propertyBFhirValues,
+        FHIRDefinedType.fromCode(propertyType),
+        expectedResult);
+  }
+
+  @ParameterizedTest
+  @MethodSource("propertyParameters")
+  public void propertyBOfCoding(final String propertyType, final DataType resultDataType,
+      final Type[] propertyAFhirValues, final Type[] propertyBFhirValues,
+      final Object[] propertyASqlValues, final Object[] propertyBSqlValues) {
+
+    final Dataset<Row> expectedResult = new DatasetBuilder(spark)
+        .withIdColumn()
+        .withEidColumn()
+        .withColumn(resultDataType)
+        .withRow("encounter-1", makeEid(0, 0), null)
+        .withRow("encounter-1", makeEid(1, 0), null)
+        .withRow("encounter-2", makeEid(0, 0), propertyBSqlValues[0])
+        .withRow("encounter-2", makeEid(0, 1), propertyBSqlValues[1])
+        .withRow("encounter-3", null, null)
+        .build();
+
+    checkPropertyOfCoding("propertyB", Optional.of(propertyType), propertyAFhirValues,
+        propertyBFhirValues,
+        FHIRDefinedType.fromCode(propertyType),
+        expectedResult);
+  }
+
+  @Test
+  public void propertyAOfCodingWithDefaultType() {
+    final Dataset<Row> expectedResult = new DatasetBuilder(spark)
+        .withIdColumn()
+        .withEidColumn()
+        .withColumn(DataTypes.StringType)
+        .withRow("encounter-1", makeEid(0, 0), "value_1")
+        .withRow("encounter-1", makeEid(0, 1), "value_2")
+        .withRow("encounter-1", makeEid(1, 0), null)
+        .withRow("encounter-2", makeEid(0, 0), null)
+        .withRow("encounter-3", null, null)
+        .build();
+
+    checkPropertyOfCoding("propertyA", Optional.empty(),
+        new Type[]{new StringType("value_1"), new StringType("value_2")}, new Type[]{},
+        FHIRDefinedType.STRING,
+        expectedResult);
+  }
+
+  @Test
+  void throwsErrorIfInputTypeIsUnsupported() {
+    final FhirPath mockContext = new ElementPathBuilder(spark).build();
+    final ElementPath input = new ElementPathBuilder(spark)
+        .fhirType(FHIRDefinedType.STRING)
+        .expression("name.given")
+        .build();
+    final FhirPath argument = StringLiteralPath.fromString("some-property", mockContext);
+
+    final ParserContext parserContext = new ParserContextBuilder(spark, fhirContext)
+        .terminologyClientFactory(mock(TerminologyServiceFactory.class))
+        .build();
+
+    final NamedFunctionInput propertyInput = new NamedFunctionInput(parserContext, input,
+        Collections.singletonList(argument));
+
+    final InvalidUserInputError error = assertThrows(InvalidUserInputError.class,
+        () -> new PropertyFunction().invoke(propertyInput));
+    assertEquals("Input to property function must be Coding but is: name.given",
+        error.getMessage());
+  }
+
+  void assertThrowsErrorForArguments(@Nonnull final String expectedError,
+      @Nonnull final Function<ElementPath, List<FhirPath>> argsFactory) {
+
+    final Optional<ElementDefinition> optionalDefinition = FhirHelpers
+        .getChildOfResource(fhirContext, "Encounter", "class");
+    assertTrue(optionalDefinition.isPresent());
+    final ElementDefinition definition = optionalDefinition.get();
+
+    final ElementPath input = new ElementPathBuilder(spark)
+        .fhirType(FHIRDefinedType.CODING)
+        .definition(definition)
+        .buildDefined();
+
+    final ParserContext context = new ParserContextBuilder(spark, fhirContext)
+        .terminologyClientFactory(mock(TerminologyServiceFactory.class))
+        .build();
+
+    final NamedFunctionInput propertyInput = new NamedFunctionInput(context, input,
+        argsFactory.apply(input));
+
+    final InvalidUserInputError error = assertThrows(InvalidUserInputError.class,
+        () -> new PropertyFunction().invoke(propertyInput));
+    assertEquals(expectedError,
+        error.getMessage());
+
+  }
+
+  @Test
+  void throwsErrorIfNoArguments() {
+    assertThrowsErrorForArguments(
+        "property function accepts one required and one optional arguments",
+        input -> Collections.emptyList());
+  }
+
+  @Test
+  void throwsErrorIfFirstArgumentIsNotString() {
+    assertThrowsErrorForArguments("Function `property` expects `String literal` as argument 1",
+        input -> Collections.singletonList(
+            IntegerLiteralPath.fromString("4", input)));
+  }
+
+  @Test
+  void throwsErrorIfSecondArgumentIsNotBoolean() {
+    assertThrowsErrorForArguments("Function `property` expects `String literal` as argument 2",
+        input -> Arrays.asList(
+            StringLiteralPath.fromString("'foo'", input),
+            IntegerLiteralPath.fromString("5", input)));
+  }
+
+
+  @Test
+  void throwsErrorIfTooManyArguments() {
+    assertThrowsErrorForArguments(
+        "property function accepts one required and one optional arguments",
+        input -> Arrays.asList(
+            StringLiteralPath.fromString("'foo'", input),
+            StringLiteralPath.fromString("'false'", input),
+            StringLiteralPath.fromString("'false'", input)
+        ));
+  }
+
+  @Test
+  void throwsErrorIfCannotParsePropertyType() {
+    assertThrowsErrorForArguments(
+        "Unknown FHIRDefinedType code 'not-an-fhir-type'",
+        input -> Arrays.asList(
+            StringLiteralPath.fromString("'foo'", input),
+            StringLiteralPath.fromString("'not-an-fhir-type'", input)
+        ));
+  }
+
+  @Test
+  void throwsErrorIfTerminologyServiceNotConfigured() {
+    final ElementPath input = new ElementPathBuilder(spark)
+        .fhirType(FHIRDefinedType.CODING)
+        .build();
+    final FhirPath argument = StringLiteralPath.fromString("some string", input);
+
+    final ParserContext context = new ParserContextBuilder(spark, fhirContext)
+        .build();
+
+    final NamedFunctionInput translateInput = new NamedFunctionInput(context, input,
+        Collections.singletonList(argument));
+
+    final InvalidUserInputError error = assertThrows(InvalidUserInputError.class,
+        () -> new TranslateFunction().invoke(translateInput));
+    assertEquals(
+        "Attempt to call terminology function translate when terminology service has not been configured",
+        error.getMessage());
+  }
+}
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/ParserTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/ParserTest.java
index d426aecc5e..9e459d72a0 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/ParserTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/ParserTest.java
@@ -87,7 +87,13 @@ private void setupMockDisplayFor_195662009_444814009() {
         .withDisplay(CD_SNOMED_195662009)
         .withDisplay(CD_SNOMED_444814009);
   }
-  
+
+  private void setupMockPropertiesFor_195662009_444814009() {
+    TerminologyServiceHelpers.setupLookup(terminologyService2)
+        .withProperty(CD_SNOMED_195662009, "child", CD_SNOMED_40055000, CD_SNOMED_403190006)
+        .withProperty(CD_SNOMED_444814009, "child", CD_SNOMED_284551006);
+  }
+
   @Test
   void testContainsOperator() {
     assertThatResultOf("name.family contains 'Wuckert783'")
@@ -470,15 +476,32 @@ void testTranslateFunction() {
 
   @Test
   void testDisplayFunction() {
-
     setupMockDisplayFor_195662009_444814009();
-
     assertThatResultOf(ResourceType.CONDITION,
         "code.coding.display()")
         .selectOrderedResult()
         .hasRows(spark, "responses/ParserTest/testDisplayFunction.csv");
   }
 
+
+  @Test
+  void testPropertyFunctionWithDefaultType() {
+    setupMockDisplayFor_195662009_444814009();
+    assertThatResultOf(ResourceType.CONDITION,
+        "code.coding.property('display')")
+        .selectOrderedResult()
+        .hasRows(spark, "responses/ParserTest/testDisplayFunction.csv");
+  }
+
+  @Test
+  void testPropertyFunctionWithCodingType() {
+    setupMockPropertiesFor_195662009_444814009();
+    assertThatResultOf(ResourceType.CONDITION,
+        "code.coding.property('child', 'Coding').code")
+        .selectOrderedResult()
+        .hasRows(spark, "responses/ParserTest/testPropertyFunctionWithCodingType.csv");
+  }
+  
   @Test
   void testTranslateWithWhereAndTranslate() {
 
diff --git a/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdsfTest.java b/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdsfTest.java
index 133a22c0bd..322465f6e1 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdsfTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdsfTest.java
@@ -5,27 +5,32 @@
 import static au.csiro.pathling.sql.Terminology.subsumed_by;
 import static au.csiro.pathling.sql.Terminology.subsumes;
 import static au.csiro.pathling.sql.Terminology.translate;
-import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
 import static au.csiro.pathling.test.helpers.TestHelpers.LOINC_URL;
 import static au.csiro.pathling.test.helpers.TestHelpers.SNOMED_URL;
 import static org.apache.spark.sql.functions.lit;
 import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.RELATEDTO;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
-import static org.mockito.Mockito.when;
 
+import au.csiro.pathling.encoders.datatypes.DecimalCustomCoder;
+import au.csiro.pathling.errors.InvalidUserInputError;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
+import au.csiro.pathling.sql.Terminology;
 import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Property;
 import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.assertions.DatasetAssert;
 import au.csiro.pathling.test.builders.DatasetBuilder;
 import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
 import ca.uhn.fhir.parser.IParser;
+import java.math.BigDecimal;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import javax.annotation.Nonnull;
@@ -34,18 +39,29 @@
 import org.apache.spark.sql.Row;
 import org.apache.spark.sql.RowFactory;
 import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.types.DataType;
 import org.apache.spark.sql.types.DataTypes;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.CodeType;
 import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.DateTimeType;
+import org.hl7.fhir.r4.model.DecimalType;
+import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
+import org.hl7.fhir.r4.model.IntegerType;
 import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.Type;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 
 @Tag("UnitTest")
 @SpringBootTest
-public class TerminologyUdsfTest {
+public class TerminologyUdsfTest extends AbstractTerminologyTestBase {
 
   @Autowired
   private SparkSession spark;
@@ -74,12 +90,6 @@ void setUp() {
   private final static String CODING_1_VALUE_SET_URI = "uiid:ValueSet_coding1";
   private final static String CODING_2_VALUE_SET_URI = "uiid:ValueSet_coding2";
 
-
-  private static final Coding INVALID_CODING_0 = new Coding(null, null, "");
-  private static final Coding INVALID_CODING_1 = new Coding("uiid:system", null, "");
-  private static final Coding INVALID_CODING_2 = new Coding(null, "someCode", "");
-
-
   // helper functions
   private DatasetBuilder codingDatasetBuilder() {
     return DatasetBuilder.of(spark)
@@ -114,7 +124,7 @@ private void setupDisplayExpectations() {
         .withDisplay(CODING_1)
         .withDisplay(CODING_2);
   }
-  
+
   @Test
   public void testValidateCoding() {
     setupValidateCodingExpectations();
@@ -378,7 +388,6 @@ public void testSubsumes() {
     DatasetAssert.of(resultSwapped).hasRows(expectedResultSwapped);
   }
 
-
   @Test
   public void testSubsumedBy() {
 
@@ -448,5 +457,102 @@ public void testDisplay() {
         .build();
     DatasetAssert.of(result).hasRows(expectedResult);
   }
+
+  @ParameterizedTest
+  @MethodSource("propertyParameters")
+  public void testProperty(final String propertyType, final DataType resultDataType,
+      final Type[] propertyAFhirValues, final Type[] propertyBFhirValues,
+      final Object[] propertyASqlValues, final Object[] propertyBSqlValues) {
+    TerminologyServiceHelpers.setupLookup(terminologyService)
+        .withProperty(CODING_1, "property_a", propertyAFhirValues)
+        .withProperty(CODING_2, "property_b", propertyBFhirValues);
+
+    final Dataset<Row> ds = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("coding", CodingEncoding.DATA_TYPE)
+        .withRow("uc-null", null)
+        .withRow("uc-invalid", toRow(INVALID_CODING_0))
+        .withRow("uc-codingA", toRow(CODING_1))
+        .withRow("uc-codingB", toRow(CODING_2))
+        .build();
+
+    final Dataset<Row> resultA = ds.select(ds.col("id"),
+        Terminology.property_of(ds.col("coding"), "property_a", propertyType).alias("values"));
+
+    final Dataset<Row> expectedResultA = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", DataTypes.createArrayType(resultDataType))
+        .withRow("uc-null", null)
+        .withRow("uc-invalid", null)
+        .withRow("uc-codingA", Arrays.asList(propertyASqlValues))
+        .withRow("uc-codingB", Collections.emptyList())
+        .build();
+
+    DatasetAssert.of(resultA)
+        .hasRows(expectedResultA);
+
+    // Test the FHIRDefineType version as well
+    final Dataset<Row> resultB = ds.select(ds.col("id"),
+        Terminology.property_of(ds.col("coding"), "property_b",
+            FHIRDefinedType.fromCode(propertyType)).alias("values"));
+
+    final Dataset<Row> expectedResultB = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", DataTypes.createArrayType(resultDataType))
+        .withRow("uc-null", null)
+        .withRow("uc-invalid", null)
+        .withRow("uc-codingA", Collections.emptyList())
+        .withRow("uc-codingB", Arrays.asList(propertyBSqlValues))
+        .build();
+
+    DatasetAssert.of(resultB)
+        .hasRows(expectedResultB);
+  }
+
+  @Test
+  public void testPropertyWithDefaultType() {
+    TerminologyServiceHelpers.setupLookup(terminologyService)
+        .withProperty(CODING_1, "property_a", new StringType("value_a"));
+
+    final Dataset<Row> ds = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("coding", CodingEncoding.DATA_TYPE)
+        .withRow("uc-null", null)
+        .withRow("uc-codingA", toRow(CODING_1))
+        .withRow("uc-codingB", toRow(CODING_2))
+        .build();
+
+    final Dataset<Row> resultA = ds.select(ds.col("id"),
+        Terminology.property_of(ds.col("coding"), "property_a").alias("values"));
+
+    final Dataset<Row> expectedResultA = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", DataTypes.createArrayType(DataTypes.StringType))
+        .withRow("uc-null", null)
+        .withRow("uc-codingA", Collections.singletonList("value_a"))
+        .withRow("uc-codingB", Collections.emptyList())
+        .build();
+
+    DatasetAssert.of(resultA)
+        .hasRows(expectedResultA);
+  }
+
+  @Test
+  void testInputErrorForPropertyOfUnknownType() {
+    assertEquals(
+        "Type: 'instant' is not supported for 'property' udf",
+        assertThrows(InvalidUserInputError.class,
+            () -> Terminology.property_of(lit(null), "display",
+                FHIRDefinedType.INSTANT)).getMessage()
+    );
+    assertEquals(
+        "Type: 'Quantity' is not supported for 'property' udf",
+        assertThrows(InvalidUserInputError.class,
+            () -> Terminology.property_of(lit(null), "display", "Quantity")).getMessage()
+    );
+
+    assertEquals(
+        "Unknown FHIRDefinedType code 'NotAFhirType'",
+        assertThrows(InvalidUserInputError.class,
+            () -> Terminology.property_of(lit(null), "display", "NotAFhirType")).getMessage()
+    );
+  }
 }
  
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/assertions/ElementPathAssertion.java b/fhir-server/src/test/java/au/csiro/pathling/test/assertions/ElementPathAssertion.java
index d9bf7459ef..6846a64400 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/assertions/ElementPathAssertion.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/assertions/ElementPathAssertion.java
@@ -18,7 +18,9 @@
 package au.csiro.pathling.test.assertions;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import au.csiro.pathling.fhirpath.element.ElementDefinition;
 import au.csiro.pathling.fhirpath.element.ElementPath;
 import javax.annotation.Nonnull;
 import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
@@ -26,6 +28,7 @@
 /**
  * @author John Grimes
  */
+@SuppressWarnings("UnusedReturnValue")
 public class ElementPathAssertion extends BaseFhirPathAssertion<ElementPathAssertion> {
 
   @Nonnull
@@ -42,4 +45,11 @@ public ElementPathAssertion hasFhirType(@Nonnull final FHIRDefinedType type) {
     return this;
   }
 
+
+  @Nonnull
+  public ElementPathAssertion hasDefinition(@Nonnull final ElementDefinition elementDefinition) {
+    assertTrue(fhirPath.getDefinition().isPresent());
+    assertEquals(elementDefinition, fhirPath.getDefinition().get());
+    return this;
+  }
 }
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyService2IntegrationTest.java b/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyService2IntegrationTest.java
index 1e8b6f9efa..906d5f8cbc 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyService2IntegrationTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyService2IntegrationTest.java
@@ -20,6 +20,7 @@
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.AUTOMAP_INPUT_URI;
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_AST_VIC;
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_107963000;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_2121000032108;
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_284551006;
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_444814009;
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_63816008;
@@ -54,6 +55,7 @@
 import java.util.List;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.spark.sql.SparkSession;
+import org.hl7.fhir.r4.model.BooleanType;
 import org.hl7.fhir.r4.model.CodeType;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.StringType;
@@ -247,16 +249,16 @@ void testUserAgentHeader() {
     verify(anyRequestedFor(urlPathMatching("/fhir/(.*)"))
         .withHeader("User-Agent", matching("pathling/(.*)")));
   }
-  
+
   @Test
   void testLookupStandardPropertiesForKnownAndUnknownSystems() {
     assertEquals(
         List.of(Property.of("display", new StringType("Left hepatectomy"))),
-        terminologyService.lookup(CD_SNOMED_VER_63816008, "display", null));
+        terminologyService.lookup(CD_SNOMED_VER_63816008, "display"));
 
     assertEquals(
         List.of(Property.of("code", new CodeType("55915-3"))),
-        terminologyService.lookup(LC_55915_3, "code", "en"));
+        terminologyService.lookup(LC_55915_3, "code"));
 
     // TODO: Unexpected: ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException: HTTP 404 Not Found: [0dbaeea4-1bcc-40c0-b7b1-61fe4b4e188a]: A usable code system with URL uuid:unknown could not be resolved.
 
@@ -270,4 +272,27 @@ void testLookupStandardPropertiesForKnownAndUnknownSystems() {
     //     terminologyService.lookup(CD_SNOMED_403190006_VERSION_UNKN, "display", null));
   }
 
+  @Test
+  void testLookupNamedProperties() {
+    assertEquals(
+        List.of(
+            Property.of("parent", new CodeType("283357002")),
+            Property.of("parent", new CodeType("125663008"))
+        ),
+        terminologyService.lookup(CD_SNOMED_284551006, "parent"));
+    assertEquals(
+        List.of(
+            Property.of("inactive", new BooleanType("false"))
+        ),
+        terminologyService.lookup(LC_55915_3, "inactive"));
+  }
+
+  @Test
+  void testLookupSubproperties() {
+    assertEquals(
+        List.of(
+            Property.of("260686004", new CodeType("129304002"))
+            ),
+        terminologyService.lookup(CD_SNOMED_2121000032108, "260686004"));
+  }
 }
diff --git a/fhir-server/src/test/resources/responses/ParserTest/testPropertyFunctionWithCodingType.csv b/fhir-server/src/test/resources/responses/ParserTest/testPropertyFunctionWithCodingType.csv
new file mode 100644
index 0000000000..4004cb5ae2
--- /dev/null
+++ b/fhir-server/src/test/resources/responses/ParserTest/testPropertyFunctionWithCodingType.csv
@@ -0,0 +1,77 @@
+01fac610-c309-46f8-9a0f-6b0f55c9915a,
+031f1c86-16ec-42e1-a155-81456e778fed,284551006
+0447de38-1f2a-411c-9fd2-a9c62ab1f221,284551006
+061521f0-7f7e-41a5-ad35-b30fe958dea7,40055000
+061521f0-7f7e-41a5-ad35-b30fe958dea7,403190006
+06e446da-6d66-4d97-a457-4abf2a0d2e24,
+08b4f1b6-f99b-400d-90f1-ec5cc6ed1556,40055000
+08b4f1b6-f99b-400d-90f1-ec5cc6ed1556,403190006
+1146cf52-573f-4667-97c3-abf235f9a83b,
+1452c50c-b0b9-472c-afb7-2f9e5a3c4717,
+171ad058-c082-46ab-b9cc-d7bba6e4e3cd,
+1cea8432-f8a0-4746-ab5c-8f195fc07c55,
+1dcedab1-cf3c-46dc-859b-cc3fb63e8375,284551006
+21372b39-2be0-4d0d-85c4-5d7e250d8f78,
+248e951c-e0ce-4e4b-afae-6273fd109c9d,
+251ddff9-1588-4790-8f40-cd6ab1d9b7fc,
+258191b8-2b42-4473-9ba2-be0ba3561767,
+29386b12-9af9-4c21-9f82-858998b388bb,
+31393667-dd7f-4c94-90c3-6cde75c67d3e,
+31925a68-7a48-412c-b4f8-aef92498dda1,284551006
+38171e22-b35d-4161-be15-80243be37b99,40055000
+38171e22-b35d-4161-be15-80243be37b99,403190006
+39170d67-8205-4636-84d7-d8575115d14c,
+4025ceb3-04a6-41fb-8569-16a8dcce7ccc,
+46d69851-eca3-42e2-b142-88c5578f6cff,284551006
+475461a2-3bd2-43fd-a5aa-7ce424203ae8,284551006
+4dd7824b-4b41-4402-bc42-de016e1bfcd9,
+5300158c-92cb-40fe-bc14-a449d7b3c1c5,
+5378726d-7f2b-4c83-9762-eaf385915fa7,
+549d3ef5-a05e-4b8e-acba-3d70f26a82f6,
+55cde383-2eb1-42d9-b5c6-698d6eade389,284551006
+5a406b78-ae23-4b4f-a3c8-9d9ed2f9d325,
+5ef84858-0480-4a34-8f43-fc962fe627b2,
+6464e20b-55ec-4685-be3e-55bc3b9602d4,40055000
+6464e20b-55ec-4685-be3e-55bc3b9602d4,403190006
+66b38727-96ea-43ad-bff7-5f4df5d779a9,284551006
+6794fbaf-e715-4e22-bafa-b7e594550ff7,
+77fc5f45-e51d-41e2-8356-daa27ebd8268,
+7d18555a-54c9-4c0a-bf3b-6305d0392a2a,40055000
+7d18555a-54c9-4c0a-bf3b-6305d0392a2a,403190006
+7ed34fb1-0bbf-4c41-a101-1316ec483aa7,284551006
+80787532-559e-4999-ac25-75c462ce4ef1,
+8922ff1b-4bc5-41b4-8512-5aad1517e2eb,
+8ddb74fc-46d5-4e94-a0ee-6761b292ae95,
+8dfd419b-0b83-4ba7-8fe6-324e2f382bd6,
+8f3ad6ad-a457-484e-a455-f0711e77b2ba,284551006
+9e598086-27bb-4e50-8988-9a40eb3c178f,40055000
+9e598086-27bb-4e50-8988-9a40eb3c178f,403190006
+9ed0f743-5a93-46ba-bf3f-e00c6f8c5bbc,
+a16f311d-d6dc-486e-8eb0-7cd53a5333ee,
+aa5a32b3-3993-4eb5-afce-489d0e7413cf,
+abecd476-3911-4a6b-a1c6-b8ecea2fe63e,
+ad679e19-3e17-4c67-8b0d-dd4f7d862207,
+b77b04ef-5eda-418c-a6fb-528a2d0a171a,284551006
+b8eccdce-7261-4402-9aa0-7360ae6bf53b,
+b972a4a8-4f7e-4e9b-8e2c-71140bf0c6cc,
+badbd940-9839-4472-aa66-3382d8823c80,
+bb9c4fc1-795a-4492-b065-1f497fe18bb2,
+be4b757d-70f1-464c-bb29-6f9a0f6cb05e,
+c39927f7-d23a-475c-b325-c1de4b51e280,
+c879c300-7fdf-4b53-aa6a-a2b4a266b30c,284551006
+c892bf2f-a7bc-407e-9508-c778a9b8761c,
+d12274a5-9e03-4c78-ae3e-6cc27e91d737,
+d554ba19-e081-4979-9c0c-a72cd6e5e8ea,
+da8db730-f051-42d0-a2db-a3577d96e9bd,
+db8c0b3e-48f0-43ac-aa03-9ae9a251cb82,
+df98a2ea-8129-4d1a-9a9f-13a0292f6d1d,
+e2f8e85b-ed68-42a1-bae2-b5d7c2a69a76,
+e35f5823-4533-49e5-9652-79d733be6bef,
+e620d7ee-6cfe-4f04-ba06-1d0b39f7624d,
+e87fbe4b-74ef-44e4-8e36-70b2abb48895,
+eaee8845-e4ed-42e1-9098-99b3f7e14dc3,284551006
+eaf99c09-419d-4162-8417-5a9d7e042cd4,
+eb373264-da60-4e98-af7c-e3021fdd8d4b,
+f1dd0d5f-c410-484b-984c-2ba38cee79ba,
+f1fd855c-802c-417a-ab7c-14a3c9daafc6,
+f41b6458-2358-43e7-ae34-d2a0fbe083d8,
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-2bd2b641-a60a-4dca-b3b9-5d0fa3b116d8.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-2bd2b641-a60a-4dca-b3b9-5d0fa3b116d8.json
index ca77a9d1bc..a5ec77b5db 100644
--- a/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-2bd2b641-a60a-4dca-b3b9-5d0fa3b116d8.json
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-2bd2b641-a60a-4dca-b3b9-5d0fa3b116d8.json
@@ -2,7 +2,7 @@
   "id" : "2bd2b641-a60a-4dca-b3b9-5d0fa3b116d8",
   "name" : "fhir_codesystem_lookup",
   "request" : {
-    "url" : "/fhir/CodeSystem/$lookup?system=http%3A%2F%2Floinc.org&code=55915-3&property=code&displayLanguage=en",
+    "url" : "/fhir/CodeSystem/$lookup?system=http%3A%2F%2Floinc.org&code=55915-3&property=code",
     "method" : "GET"
   },
   "response" : {
@@ -26,4 +26,4 @@
   "uuid" : "2bd2b641-a60a-4dca-b3b9-5d0fa3b116d8",
   "persistent" : true,
   "insertionIndex" : 66
-}
\ No newline at end of file
+}
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-9de40c0e-bad9-4161-8f7f-fea2f27c01b2.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-9de40c0e-bad9-4161-8f7f-fea2f27c01b2.json
new file mode 100644
index 0000000000..a4ed1eaded
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-9de40c0e-bad9-4161-8f7f-fea2f27c01b2.json
@@ -0,0 +1,29 @@
+{
+  "id" : "9de40c0e-bad9-4161-8f7f-fea2f27c01b2",
+  "name" : "fhir_codesystem_lookup",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$lookup?system=http%3A%2F%2Fsnomed.info%2Fsct&code=2121000032108&property=260686004",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"code\",\"valueCode\":\"2121000032108\"},{\"name\":\"display\",\"valueString\":\"Laparoscopic-assisted partial hepatectomy\"},{\"name\":\"name\",\"valueString\":\"SNOMED Clinical Terms Australian extension\"},{\"name\":\"property\",\"part\":[{\"name\":\"code\",\"valueCode\":\"609096000\"},{\"name\":\"subproperty\",\"part\":[{\"name\":\"code\",\"valueCode\":\"260686004\"},{\"name\":\"value\",\"valueCode\":\"129304002\"},{\"name\":\"valueCode\",\"valueCode\":\"129304002\"}]}]}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "YbR8GqXZvHyG8x1k",
+      "ETag" : "W/\"1669993369529\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=272",
+      "Date" : "Wed, 07 Dec 2022 05:24:30 GMT"
+    }
+  },
+  "uuid" : "9de40c0e-bad9-4161-8f7f-fea2f27c01b2",
+  "persistent" : true,
+  "insertionIndex" : 69
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-dab1df59-46fc-4fae-b477-a7a2f029236f.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-dab1df59-46fc-4fae-b477-a7a2f029236f.json
new file mode 100644
index 0000000000..d6b0af9921
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-dab1df59-46fc-4fae-b477-a7a2f029236f.json
@@ -0,0 +1,29 @@
+{
+  "id" : "dab1df59-46fc-4fae-b477-a7a2f029236f",
+  "name" : "fhir_codesystem_lookup",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$lookup?system=http%3A%2F%2Fsnomed.info%2Fsct&code=284551006&property=parent",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"code\",\"valueCode\":\"284551006\"},{\"name\":\"display\",\"valueString\":\"Laceration of foot\"},{\"name\":\"name\",\"valueString\":\"SNOMED Clinical Terms Australian extension\"},{\"name\":\"property\",\"part\":[{\"name\":\"code\",\"valueCode\":\"parent\"},{\"name\":\"value\",\"valueCode\":\"283357002\"}]},{\"name\":\"property\",\"part\":[{\"name\":\"code\",\"valueCode\":\"parent\"},{\"name\":\"value\",\"valueCode\":\"125663008\"}]}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "y3meorMshmSJTZs6",
+      "ETag" : "W/\"1669993369529\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=281",
+      "Date" : "Wed, 07 Dec 2022 05:24:51 GMT"
+    }
+  },
+  "uuid" : "dab1df59-46fc-4fae-b477-a7a2f029236f",
+  "persistent" : true,
+  "insertionIndex" : 71
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-eb0ae1b6-5e0b-49b0-a77c-92f4e2c43fca.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-eb0ae1b6-5e0b-49b0-a77c-92f4e2c43fca.json
new file mode 100644
index 0000000000..b581988b41
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_lookup-eb0ae1b6-5e0b-49b0-a77c-92f4e2c43fca.json
@@ -0,0 +1,29 @@
+{
+  "id" : "eb0ae1b6-5e0b-49b0-a77c-92f4e2c43fca",
+  "name" : "fhir_codesystem_lookup",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$lookup?system=http%3A%2F%2Floinc.org&code=55915-3&property=inactive",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"code\",\"valueCode\":\"55915-3\"},{\"name\":\"display\",\"valueString\":\"Beta 2 globulin [Mass/volume] in Cerebral spinal fluid by Electrophoresis\"},{\"name\":\"name\",\"valueString\":\"LOINC v2.73\"},{\"name\":\"property\",\"part\":[{\"name\":\"code\",\"valueCode\":\"inactive\"},{\"name\":\"value\",\"valueBoolean\":false},{\"name\":\"valueBoolean\",\"valueBoolean\":false}]}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "tyKONpnYQC5k11dK",
+      "ETag" : "W/\"1664358550916\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=285",
+      "Date" : "Wed, 07 Dec 2022 05:24:51 GMT"
+    }
+  },
+  "uuid" : "eb0ae1b6-5e0b-49b0-a77c-92f4e2c43fca",
+  "persistent" : true,
+  "insertionIndex" : 72
+}
\ No newline at end of file
diff --git a/lib/python/tests/test_udfs.py b/lib/python/tests/test_udfs.py
index 6dc9e82115..c403d011dc 100644
--- a/lib/python/tests/test_udfs.py
+++ b/lib/python/tests/test_udfs.py
@@ -17,7 +17,7 @@
 import os
 from tempfile import mkdtemp
 
-from pyspark.sql import SparkSession
+from pyspark.sql import SparkSession, DataFrame
 from pyspark.sql.types import StructType, StringType, StructField, BooleanType, Row, ArrayType, \
     IntegerType
 from pytest import fixture
@@ -26,7 +26,8 @@
 from pathling.coding import Coding
 from pathling.etc import SNOMED_URI
 from pathling.etc import find_jar as find_pathling_jar
-from pathling.udfs import member_of, subsumes, subsumed_by, translate, display
+from pathling.udfs import member_of, subsumes, subsumed_by, translate, display, PropertyType, \
+    property_of
 
 PROJECT_DIR = os.path.abspath(
         os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))
@@ -52,15 +53,11 @@ def spark(request):
         .getOrCreate()
 
     request.addfinalizer(lambda: spark.stop())
+    PathlingContext.create(spark, mock_terminology=True)
 
     return spark
 
 
-@fixture(scope="module")
-def ptl(spark):
-    return PathlingContext.create(spark, mock_terminology=True)
-
-
 CODING_TYPE = StructType([
     StructField("id", StringType(), True),
     StructField("system", StringType(), True),
@@ -87,7 +84,7 @@ def loinc_coding_row(code: str):
 Result = Row("id", "result")
 
 
-def test_member_of(spark: SparkSession, ptl: PathlingContext):
+def test_member_of(spark: SparkSession):
     df = spark.createDataFrame(
             [
                 ("code-1", snomed_coding_row("368529001")),
@@ -125,8 +122,7 @@ def test_member_of(spark: SparkSession, ptl: PathlingContext):
     assert result_df_coding.collect() == [Result("code-1", True)]
 
 
-def test_member_of_array(spark: SparkSession, ptl: PathlingContext):
-    PathlingContext.create(spark, mock_terminology=True)
+def test_member_of_array(spark: SparkSession):
     df = spark.createDataFrame(
             [
                 ("code-1", [snomed_coding_row("368529001"), snomed_coding_row("368529002")]),
@@ -180,7 +176,7 @@ def subsumption_df(spark: SparkSession):
     )
 
 
-def test_subsumes(subsumption_df, ptl: PathlingContext):
+def test_subsumes(subsumption_df):
     result_df = subsumption_df.select("id",
                                       subsumes(subsumption_df["codeA"], "codeB").alias("result"))
     assert result_df.collect() == [
@@ -208,7 +204,7 @@ def test_subsumes(subsumption_df, ptl: PathlingContext):
     ]
 
 
-def test_subsumed_by(subsumption_df, ptl: PathlingContext):
+def test_subsumed_by(subsumption_df):
     result_df = subsumption_df.select("id",
                                       subsumed_by(subsumption_df["codeB"], "codeA").alias("result"))
     assert result_df.collect() == [
@@ -236,7 +232,7 @@ def test_subsumed_by(subsumption_df, ptl: PathlingContext):
     ]
 
 
-def test_translate(spark: SparkSession, ptl: PathlingContext):
+def test_translate(spark: SparkSession):
     df = spark.createDataFrame(
             [
                 ("id-1", snomed_coding_row("368529001")),
@@ -293,8 +289,9 @@ def test_translate(spark: SparkSession, ptl: PathlingContext):
     ];
 
 
-def test_display(spark: SparkSession, ptl: PathlingContext):
-    df = spark.createDataFrame(
+@fixture(scope="module")
+def property_df(spark: SparkSession):
+    return spark.createDataFrame(
             [
                 ("id-1", snomed_coding_row("439319006")),
                 ("id-2", loinc_coding_row("55915-3")),
@@ -304,19 +301,48 @@ def test_display(spark: SparkSession, ptl: PathlingContext):
                                StructField("code", CODING_TYPE)])
     )
 
+
+def test_display(property_df: DataFrame):
     expected_result = [
         Result("id-1", "Screening for phenothiazine in serum"),
         Result("id-2", None),
         Result("id-3", None),
     ]
 
-    result_df = df.select("id", display("code").alias("result"))
+    result_df = property_df.select("id", display("code").alias("result"))
     assert result_df.collect() == expected_result
 
-    result_df = df.select("id", display(df["code"]).alias("result"))
+    result_df = property_df.select("id", display(property_df["code"]).alias("result"))
     assert result_df.collect() == expected_result
 
-    result_df = df.limit(1).select("id", display(Coding(SNOMED_URI, "439319006")).alias("result"))
+    result_df = property_df.limit(1) \
+        .select("id", display(Coding(SNOMED_URI, "439319006")).alias("result"))
     assert result_df.collect() == [
         Result("id-1", "Screening for phenothiazine in serum")
     ];
+
+
+def test_property_of(property_df: DataFrame):
+    result_df = property_df \
+        .select("id", property_of("code", "parent", PropertyType.CODE).alias("result"))
+    assert result_df.collect() == [
+        Result("id-1", ["785673007", "74754006"]),
+        Result("id-2", []),
+        Result("id-3", None),
+    ]
+
+    result_df = property_df \
+        .select("id",
+                property_of(property_df["code"], "inactive", PropertyType.BOOLEAN).alias("result"))
+    assert result_df.collect() == [
+        Result("id-1", []),
+        Result("id-2", [False]),
+        Result("id-3", None),
+    ]
+
+    result_df = property_df.limit(1) \
+        .select("id",
+                property_of(Coding(SNOMED_URI, "439319006"), "display").alias("result"))
+    assert result_df.collect() == [
+        Result("id-1", ["Screening for phenothiazine in serum"])
+    ];
diff --git a/terminology/src/test/java/au/csiro/pathling/sql/udf/PropertyUdfTest.java b/terminology/src/test/java/au/csiro/pathling/sql/udf/PropertyUdfTest.java
new file mode 100644
index 0000000000..7e80aa256a
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/PropertyUdfTest.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.sql.udf;
+
+import static au.csiro.pathling.fhirpath.encoding.CodingEncoding.encode;
+import static au.csiro.pathling.sql.udf.PropertyUdfTest.Values.ofPrimitive;
+import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
+import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.setupLookup;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.test.AbstractTerminologyTestBase;
+import com.google.common.collect.ImmutableMap;
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import lombok.Value;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.CodeType;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.DateTimeType;
+import org.hl7.fhir.r4.model.DecimalType;
+import org.hl7.fhir.r4.model.IntegerType;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.Type;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class PropertyUdfTest extends AbstractTerminologyTestBase {
+
+  public static final int RANGE_ONE_FROM = 0;
+  public static final int RANGE_ONE_TO = 2;
+  public static final int RANGE_TWO_FROM = 1;
+  public static final int RANGE_TWO_TO = 4;
+
+  @Value(staticConstructor = "of")
+  static class Values<FT extends Type, T> {
+
+    Type[] fhirValues;
+    T[] objectValues;
+
+    Type fhirValueAt(final int i) {
+      return fhirValues[i];
+    }
+
+    Object[] objectValueAt(final int i) {
+      return Arrays.copyOfRange(objectValues, i, i + 1);
+    }
+
+    Stream<Type> fhirRangeOne() {
+      return Stream.of(Arrays.copyOfRange(fhirValues, RANGE_ONE_FROM, RANGE_ONE_TO));
+    }
+
+    Stream<Type> fhirRangeTwo() {
+      return Stream.of(Arrays.copyOfRange(fhirValues, RANGE_TWO_FROM, RANGE_TWO_TO));
+    }
+
+    Object[] objectRangeOne() {
+      return Arrays.copyOfRange(objectValues, RANGE_ONE_FROM, RANGE_ONE_TO);
+    }
+
+    Object[] objectRangeTwo() {
+      return Arrays.copyOfRange(objectValues, RANGE_TWO_FROM, RANGE_TWO_TO);
+    }
+
+    @SafeVarargs
+    public static <T, FT extends Type> Values<FT, T> ofPrimitive(
+        Function<T, FT> constructor,
+        final T... values) {
+      return of(Stream.of(values).map(constructor).toArray(Type[]::new), values);
+    }
+  }
+
+  private static final Map<Class<?>, Values<?, ?>> TEST_VALUES = ImmutableMap.of(
+      // The last value is repeated for purpose for testing that
+      // duplicate values are allowed in the result
+      StringType.class, ofPrimitive(StringType::new,
+          "value_0", "value_1", "value_2", "value_2"),
+      CodeType.class, ofPrimitive(CodeType::new, "value_0", "value_1", "value_2", "value_2"),
+      BooleanType.class, ofPrimitive(BooleanType::new, true, false, true, true),
+      IntegerType.class, ofPrimitive(IntegerType::new, 1, 2, 3, 3),
+      DecimalType.class, ofPrimitive(DecimalType::new, new BigDecimal("1.1"),
+          new BigDecimal("2.2"), new BigDecimal("3.3"), new BigDecimal("3.3")),
+      DateTimeType.class, ofPrimitive(DateTimeType::new, "2001-01-01",
+          "2002-02-02", "2003-03-03", "2003-03-03"),
+      Coding.class, Values.of(new Coding[]{CODING_A, CODING_BB_VERSION1, CODING_C, CODING_C},
+          asArray(CODING_A, CODING_BB_VERSION1, CODING_C, CODING_C))
+  );
+
+  private static Stream<Values<?, ?>> allTestValues() {
+    return TEST_VALUES.values().stream();
+  }
+
+  private PropertyUdf propertyUdf;
+  private TerminologyService2 terminologyService2;
+  private TerminologyServiceFactory terminologyServiceFactory;
+
+  @BeforeEach
+  public void setUp() {
+    terminologyService2 = mock(TerminologyService2.class);
+    terminologyServiceFactory = mock(TerminologyServiceFactory.class);
+    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+  }
+
+  public static Stream<Class<?>> inputs() {
+    return TEST_VALUES.keySet().stream();
+  }
+
+  private void setupUdf(Class<? extends Type> udfClass) {
+    propertyUdf = PropertyUdf.forClass(terminologyServiceFactory, udfClass);
+  }
+
+  @ParameterizedTest
+  @MethodSource("inputs")
+  public void testReturnsNullWhenNullCoding(Class<? extends Type> udfClass) {
+    setupUdf(udfClass);
+    assertNull(propertyUdf.call(null, "display"));
+    verifyNoMoreInteractions(terminologyService2);
+  }
+
+  @ParameterizedTest
+  @MethodSource("inputs")
+  public void testReturnsNullWhenInvalidCodings(Class<? extends Type> udfClass) {
+    setupUdf(udfClass);
+    assertNull(propertyUdf.call(encode(INVALID_CODING_0), "display"));
+    assertNull(propertyUdf.call(encode(INVALID_CODING_1), "name"));
+    assertNull(propertyUdf.call(encode(INVALID_CODING_2), "code"));
+    verifyNoMoreInteractions(terminologyService2);
+  }
+
+  @ParameterizedTest
+  @MethodSource("inputs")
+  public void testReturnsNullWhenNullPropertyCode(Class<? extends Type> udfClass) {
+    setupUdf(udfClass);
+    assertNull(propertyUdf.call(encode(CODING_A), null));
+    verifyNoMoreInteractions(terminologyService2);
+  }
+
+  @ParameterizedTest
+  @MethodSource("inputs")
+  public void testReturnsEmptyArrayWhenNonExisingProperty(Class<? extends Type> udfClass) {
+    setupUdf(udfClass);
+    assertArrayEquals(new String[RANGE_ONE_FROM],
+        propertyUdf.call(encode(CODING_A), "unknownProperty_0"));
+    assertArrayEquals(new String[RANGE_ONE_FROM],
+        propertyUdf.call(encode(CODING_BB_VERSION1), "unknownProperty_1"));
+
+    verify(terminologyService2).lookup(deepEq(CODING_A), eq("unknownProperty_0"));
+    verify(terminologyService2).lookup(deepEq(CODING_BB_VERSION1), eq("unknownProperty_1"));
+    verifyNoMoreInteractions(terminologyService2);
+  }
+
+  @ParameterizedTest
+  @MethodSource("inputs")
+  public void testReturnsASingleValueForKnownProperty(final Class<? extends Type> udfClass) {
+    setupUdf(udfClass);
+    setupLookup(terminologyService2)
+        .withProperty(CODING_A, "property_0",
+            allTestValues().map(v -> v.fhirValueAt(0)).toArray(Type[]::new))
+        .withProperty(CODING_BB_VERSION1, "property_1",
+            allTestValues().map(v -> v.fhirValueAt(1)).toArray(Type[]::new))
+        .withProperty(CODING_A, "property_1",
+            allTestValues().map(v -> v.fhirValueAt(2)).toArray(Type[]::new));
+
+    final Object[] expectedSingletonOne = TEST_VALUES.get(udfClass).objectValueAt(0);
+    final Object[] expectedSingletonTwo = TEST_VALUES.get(udfClass).objectValueAt(1);
+
+    assertArrayEquals(expectedSingletonOne,
+        propertyUdf.call(encode(CODING_A), "property_0"));
+    assertArrayEquals(expectedSingletonTwo,
+        propertyUdf.call(encode(CODING_BB_VERSION1), "property_1"));
+  }
+
+  @ParameterizedTest
+  @MethodSource("inputs")
+  public void testReturnsManyValueForKnownProperty(final Class<? extends Type> udfClass) {
+    setupUdf(udfClass);
+    setupLookup(terminologyService2)
+        .withProperty(CODING_AA_VERSION1, "property_a",
+            allTestValues().flatMap(Values::fhirRangeOne).toArray(Type[]::new))
+        .withProperty(CODING_B, "property_b",
+            allTestValues().flatMap(Values::fhirRangeTwo).toArray(Type[]::new));
+
+    final Object[] expectedArrayOne = TEST_VALUES.get(udfClass).objectRangeOne();
+    final Object[] expectedArrayTwo = TEST_VALUES.get(udfClass).objectRangeTwo();
+
+    assertArrayEquals(expectedArrayOne,
+        propertyUdf.call(encode(CODING_AA_VERSION1), "property_a"));
+    assertArrayEquals(expectedArrayTwo,
+        propertyUdf.call(encode(CODING_B), "property_b"));
+  }
+}
+
+ 
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java
index 4d84cc3e50..e020657ce0 100644
--- a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java
@@ -1,6 +1,23 @@
-package au.csiro.pathling.terminology;
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
+package au.csiro.pathling.terminology;
 
+import static au.csiro.pathling.terminology.PropertiesParametersBuilder.standardProperties;
 import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
 import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.OUTCOME_EQUIVALENT;
 import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.OUTCOME_SUBSUMEDBY;
@@ -14,6 +31,7 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
@@ -26,9 +44,11 @@
 import java.util.Collections;
 import java.util.List;
 import javax.annotation.Nonnull;
+import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
 import org.hl7.fhir.r4.model.BooleanType;
 import org.hl7.fhir.r4.model.CodeType;
 import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.IntegerType;
 import org.hl7.fhir.r4.model.Parameters;
 import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
 import org.hl7.fhir.r4.model.StringType;
@@ -47,6 +67,12 @@ public class DefaultTerminologyService2Test extends AbstractTerminologyTestBase
 
   private static final List<Translation> EMPTY_TRANSLATION = Collections.emptyList();
 
+
+  public static final Coding USE_PREFFERED_FOR_LANG = new Coding(
+      "http://terminology.hl7.org/CodeSystem/hl7TermMaintInfra",
+      "preferredForLanguage", "Preferred For Language"
+  );
+
   @Nonnull
   private static Parameters translation(@Nonnull final Translation... entries) {
     final Parameters translateResponse = new Parameters()
@@ -236,22 +262,14 @@ public void testTranslatesInvalidsCoding() {
   @Test
   public void testLooksUpInvalidCoding() {
     assertEquals(Collections.emptyList(),
-        terminologyService.lookup(INVALID_CODING_0, null, null));
+        terminologyService.lookup(INVALID_CODING_0, null));
     assertEquals(Collections.emptyList(),
-        terminologyService.lookup(INVALID_CODING_1, "display", null));
+        terminologyService.lookup(INVALID_CODING_1, "display"));
     assertEquals(Collections.emptyList(),
-        terminologyService.lookup(INVALID_CODING_2, "designation", "en"));
+        terminologyService.lookup(INVALID_CODING_2, "designation"));
     verifyNoMoreInteractions(terminologClient);
   }
 
-  @Nonnull
-  private static Parameters standardProperties(@Nonnull final Coding coding) {
-    return new Parameters()
-        .addParameter("display", coding.getDisplay())
-        .addParameter("code", new CodeType(coding.getCode()))
-        .addParameter("name", "My Test Coding System");
-  }
-
   @Test
   public void testLooksUpStandardProperty() {
 
@@ -259,23 +277,73 @@ public void testLooksUpStandardProperty() {
         deepEq(new UriType(SYSTEM_A)),
         isNull(),
         deepEq(new CodeType(CODE_A)),
-        deepEq(new CodeType("display")),
-        isNull())).thenReturn(standardProperties(CODING_A));
+        deepEq(new CodeType("display"))))
+        .thenReturn(standardProperties(CODING_A).build());
+
+    assertEquals(List.of(Property.of("display", new StringType(CODING_AA.getDisplay()))),
+        terminologyService.lookup(CODING_AA, "display"));
+  }
+
+  @Test
+  public void testLooksNamedProperties() {
+    final Parameters response = standardProperties(CODING_BB_VERSION1)
+        .withProperty("property_A", "string_value_a")
+        .withProperty("property_A", new IntegerType(333))
+        .withProperty("property_A", new CodeType("code_value_a"))
+        .withProperty("property_A", new BooleanType(true))
+        // adding some unexpected elemnts to make they are excluded
+        .withProperty("property_B", "string_value_b")
+        .withDesignation("en", USE_PREFFERED_FOR_LANG, "Coding BB")
+        .build();
 
     when(terminologClient.lookup(
         deepEq(new UriType(SYSTEM_B)),
         deepEq(new StringType(VERSION_1)),
         deepEq(new CodeType(CODE_B)),
-        deepEq(new CodeType("code")),
-        deepEq(new CodeType("en")))).thenReturn(standardProperties(CODING_BB_VERSION1));
-    
-    assertEquals(List.of(Property.of("display", new StringType(CODING_AA.getDisplay()))),
-        terminologyService.lookup(CODING_AA, "display", null));
-
-    assertEquals(List.of(Property.of("code", new CodeType(CODING_BB_VERSION1.getCode()))),
-        terminologyService.lookup(CODING_BB_VERSION1, "code", "en"));
+        deepEq(new CodeType("property_A"))
+    )).thenReturn(response);
+
+    assertEquals(List.of(
+            Property.of("property_A", new StringType("string_value_a")),
+            Property.of("property_A", new IntegerType(333)),
+            Property.of("property_A", new CodeType("code_value_a")),
+            Property.of("property_A", new BooleanType(true))
+        ),
+        terminologyService.lookup(CODING_BB_VERSION1, "property_A"));
   }
 
-}
+  @Test
+  public void testLooksUpSupProperties() {
+    final Parameters response = standardProperties(CODING_C)
+        .withPropertyGroup("group_C")
+        .withSubProperty("property_C", new StringType("string_value_c"))
+        .withSubProperty("property_C", new CodeType("code_value_c"))
+        .withSubProperty("property_D", new StringType("string_value_D"))
+        .build();
+
+    when(terminologClient.lookup(
+        deepEq(new UriType(SYSTEM_C)),
+        isNull(),
+        deepEq(new CodeType(CODE_C)),
+        any()
+    )).thenReturn(response);
+
+    assertEquals(List.of(
+            Property.of("property_C", new StringType("string_value_c")),
+            Property.of("property_C", new CodeType("code_value_c"))
+        ),
+        terminologyService.lookup(CODING_C, "property_C"));
+    // does not include grouping property in the results
+    assertEquals(Collections.emptyList(),
+        terminologyService.lookup(CODING_C, "group_C"));
+  }
 
+  @Test
+  public void testLookupHandles404Exceptions() {
+    when(terminologClient.lookup(any(), any(), any(), any()
+    )).thenThrow(BaseServerResponseException.newInstance(404, "Resource Not Found"));
 
+    assertEquals(Collections.emptyList(),
+        terminologyService.lookup(CODING_C, "property_A"));
+  }
+}
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/PropertiesParametersBuilder.java b/terminology/src/test/java/au/csiro/pathling/terminology/PropertiesParametersBuilder.java
new file mode 100644
index 0000000000..e47b4b96cb
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/PropertiesParametersBuilder.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology;
+
+import org.hl7.fhir.r4.model.*;
+import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
+
+import javax.annotation.Nonnull;
+
+@SuppressWarnings({"unused", "SameParameterValue"})
+class PropertiesParametersBuilder {
+
+  private final Parameters parameters = new Parameters();
+  private Parameters.ParametersParameterComponent currentGroup = null;
+
+  PropertiesParametersBuilder withStdProperty(@Nonnull final String code,
+      @Nonnull final Type value) {
+    parameters.addParameter(code, value);
+    return this;
+  }
+
+  PropertiesParametersBuilder withStdProperty(@Nonnull final String code,
+      @Nonnull final String value) {
+    parameters.addParameter(code, value);
+    return this;
+  }
+
+  PropertiesParametersBuilder withStdProperties(@Nonnull final Coding coding) {
+    return this
+        .withStdProperty("display", coding.getDisplay())
+        .withStdProperty("code", new CodeType(coding.getCode()))
+        .withStdProperty("name", "My Test Coding System");
+  }
+
+  PropertiesParametersBuilder withProperty(@Nonnull final String code,
+      @Nonnull final Type value) {
+    final Parameters.ParametersParameterComponent component = parameters.addParameter();
+    component.setName("property");
+    component.addPart().setName("code").setValue(new CodeType(code));
+    component.addPart().setName("value").setValue(value);
+    return this;
+  }
+
+  PropertiesParametersBuilder withProperty(@Nonnull final String code,
+      @Nonnull final String stringValue) {
+    return withProperty(code, new StringType(stringValue));
+  }
+
+  PropertiesParametersBuilder withPropertyGroup(@Nonnull final String code) {
+    final Parameters.ParametersParameterComponent component = parameters.addParameter();
+    component.setName("property");
+    component.addPart().setName("code").setValue(new CodeType(code));
+    currentGroup = component;
+    return this;
+  }
+
+  PropertiesParametersBuilder withSubProperty(@Nonnull final String code,
+      @Nonnull final Type value) {
+    final ParametersParameterComponent subcomponent = currentGroup.addPart()
+        .setName("subproperty");
+    subcomponent.addPart().setName("code").setValue(new CodeType(code));
+    subcomponent.addPart().setName("value").setValue(value);
+    return this;
+  }
+
+  PropertiesParametersBuilder withSubProperty(@Nonnull final String code,
+      @Nonnull final String stringValue) {
+    return withSubProperty(code, new StringType(stringValue));
+  }
+
+  PropertiesParametersBuilder withDesignation(@Nonnull final String languageCode,
+      @Nonnull final Coding use,
+      @Nonnull final String value) {
+    final Parameters.ParametersParameterComponent component = parameters.addParameter();
+    component.setName("designation");
+    component.addPart().setName("language").setValue(new CodeType(languageCode));
+    component.addPart().setName("use").setValue(use);
+    component.addPart().setName("value").setValue(new StringType(value));
+    return this;
+  }
+
+  @Nonnull
+  Parameters build() {
+    return parameters;
+  }
+
+  @Nonnull
+  static PropertiesParametersBuilder standardProperties(@Nonnull final Coding coding) {
+    return new PropertiesParametersBuilder().withStdProperties(coding);
+  }
+}
diff --git a/terminology/src/test/java/au/csiro/pathling/test/AbstractTerminologyTestBase.java b/terminology/src/test/java/au/csiro/pathling/test/AbstractTerminologyTestBase.java
index 064274a2f1..beaff9be64 100644
--- a/terminology/src/test/java/au/csiro/pathling/test/AbstractTerminologyTestBase.java
+++ b/terminology/src/test/java/au/csiro/pathling/test/AbstractTerminologyTestBase.java
@@ -1,12 +1,28 @@
 package au.csiro.pathling.test;
 
+import au.csiro.pathling.encoders.datatypes.DecimalCustomCoder;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.function.Function;
 import java.util.stream.Stream;
 import javax.annotation.Nonnull;
 import org.apache.spark.sql.Row;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.DataTypes;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.CodeType;
 import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.DateTimeType;
+import org.hl7.fhir.r4.model.DecimalType;
+import org.hl7.fhir.r4.model.IntegerType;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.Type;
+import org.junit.jupiter.params.provider.Arguments;
 import scala.collection.mutable.WrappedArray;
 
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
 public abstract class AbstractTerminologyTestBase {
 
   public static final String SYSTEM_A = "uuid:systemA";
@@ -17,6 +33,9 @@ public abstract class AbstractTerminologyTestBase {
   public static final String CODE_C = "codeC";
   public static final String SYSTEM_D = "uuid:systemD";
   public static final String CODE_D = "codeD";
+  public static final String SYSTEM_E = "uuid:systemE";
+  public static final String CODE_E = "codeE";
+
 
   public static final Coding CODING_AA = new Coding(SYSTEM_A, CODE_A, "displayAA");
   public static final Coding CODING_AB = new Coding(SYSTEM_A, CODE_B, "displayAB");
@@ -44,6 +63,7 @@ public abstract class AbstractTerminologyTestBase {
   public static final Coding CODING_B = CODING_BB;
   public static final Coding CODING_C = new Coding(SYSTEM_C, CODE_C, "displayCC");
   public static final Coding CODING_D = new Coding(SYSTEM_D, CODE_D, "displayDD");
+  public static final Coding CODING_E = new Coding(SYSTEM_E, CODE_E, "displayEE");
 
   public static final Coding INVALID_CODING_0 = new Coding(null, null, "");
   public static final Coding INVALID_CODING_1 = new Coding("uiid:system", null, "");
@@ -59,4 +79,52 @@ public static Row[] asArray(@Nonnull final Coding... codings) {
   public static WrappedArray<Row> encodeMany(Coding... codings) {
     return WrappedArray.make(Stream.of(codings).map(CodingEncoding::encode).toArray(Row[]::new));
   }
+
+  @Nonnull
+  private static <T> Arguments primitiveArguments(
+      final String fhirType, final DataType sqlType,
+      final Function<T, ? extends Type> constructor,
+      final T[] propertyAValues, final T[] propertyBValues) {
+    return arguments(fhirType, sqlType,
+        Stream.of(propertyAValues).map(constructor).toArray(Type[]::new),
+        Stream.of(propertyBValues).map(constructor).toArray(Type[]::new),
+        propertyAValues,
+        propertyBValues);
+  }
+
+  @Nonnull
+  public static Stream<Arguments> propertyParameters() {
+    return Stream.of(
+        primitiveArguments("string", DataTypes.StringType, StringType::new,
+            new String[]{"string_a"},
+            new String[]{"string_b.0", "string_b.1"}
+        ),
+        primitiveArguments("code", DataTypes.StringType, CodeType::new,
+            new String[]{"code_a"},
+            new String[]{"code_b.0", "code_b.1"}
+        ),
+        primitiveArguments("integer", DataTypes.IntegerType, IntegerType::new,
+            new Integer[]{111},
+            new Integer[]{222, 333}
+        ),
+        primitiveArguments("boolean", DataTypes.BooleanType, BooleanType::new,
+            new Boolean[]{true},
+            new Boolean[]{false, true}
+        ),
+        primitiveArguments("decimal", DecimalCustomCoder.decimalType(), DecimalType::new,
+            new BigDecimal[]{new BigDecimal("1.11")},
+            new BigDecimal[]{new BigDecimal("2.22"), new BigDecimal("3.33")}
+        ),
+        primitiveArguments("dateTime", DataTypes.StringType, DateTimeType::new,
+            new String[]{"1999-01-01"},
+            new String[]{"2222-02-02", "3333-03-03"}
+        ),
+        arguments("Coding", CodingEncoding.DATA_TYPE,
+            new Coding[]{CODING_C},
+            new Coding[]{CODING_D, CODING_E},
+            CodingEncoding.encodeList(List.of(CODING_C)),
+            CodingEncoding.encodeList(List.of(CODING_D, CODING_E))
+        )
+    );
+  }
 }
diff --git a/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyHelpers.java b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyHelpers.java
index 2c73ef650c..17e2d47676 100644
--- a/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyHelpers.java
+++ b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyHelpers.java
@@ -65,6 +65,8 @@ private TerminologyHelpers() {
   public static final Coding CD_SNOMED_284551006 = snomedCoding("284551006",
       "Laceration of foot");
 
+  public static final Coding CD_SNOMED_2121000032108 = snomedCoding("2121000032108",
+      "Laparoscopic-assisted partial hepatectomy");
 
   public static final Coding CD_SNOMED_444814009 = snomedCoding("444814009",
       "Viral sinusitis (disorder)");
diff --git a/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java
index d0cfbe0ee5..2216fa28ae 100644
--- a/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java
+++ b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java
@@ -1,6 +1,7 @@
 package au.csiro.pathling.test.helpers;
 
 import static au.csiro.pathling.test.helpers.FhirMatchers.codingEq;
+import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.codingEquals;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
@@ -15,12 +16,15 @@
 import au.csiro.pathling.terminology.TerminologyService2.Translation;
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
+import java.util.stream.Stream;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.Parameters;
 import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.Type;
 import org.hl7.fhir.r4.model.ValueSet;
 import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
 import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
@@ -172,22 +176,34 @@ public static class LookupExpectations {
     public LookupExpectations(final TerminologyService2 mockService) {
       this.mockService = mockService;
       clearInvocations(mockService);
-      when(mockService.lookup(any(), any(), any())).thenReturn(Collections.emptyList());
+      when(mockService.lookup(any(), any())).thenReturn(Collections.emptyList());
     }
 
     @Nonnull
     public LookupExpectations withDisplay(@Nonnull final Coding coding,
         @Nonnull final String displayName) {
-      when(mockService.lookup(codingEq(coding), eq("display"), any()))
+      when(mockService.lookup(codingEq(coding), eq("display")))
           .thenReturn(List.of(
               Property.of("display", new StringType(displayName))));
       return this;
     }
-    
+
     @Nonnull
     public LookupExpectations withDisplay(@Nonnull final Coding coding) {
       return withDisplay(coding, coding.getDisplay());
     }
+
+    @SafeVarargs
+    @Nonnull
+    public final <T extends Type> LookupExpectations withProperty(@Nonnull final Coding coding,
+        @Nonnull final String propertyCode, T... values) {
+      when(mockService.lookup(deepEq(coding), eq(propertyCode))).thenReturn(
+          Stream.of(values)
+              .map(v -> Property.of(propertyCode, v))
+              .collect(Collectors.toUnmodifiableList())
+      );
+      return this;
+    }
   }
 
   @Nonnull