Repository: FHIR/sushi (ts)

Issue:
SUSHI should allow binding to Logical Models that have the #can-bind characteristic
Logical Models support specifing type characteristics through the `Characteristics` keyword. One of the values supported by this extension is `can-bind`, which states that the type can be bound to a terminology. SUSHI should allow Logical Models that have this characteristic specified to be used in bindings. If the characteristic is not specified, but the type is used in a binding, a warning should be logged (this should be similar to the behavior in SUSHI when a Logical Model is used as a reference without the `can-be-target` characteristic).

Pull request:
Allow binding to types with the #can-bind characteristic
Fixes #1347.

This PR makes it so that if a Logical Model includes `#can-bind` included in the `Characteristics` or if it has the `type-characteristics` extension with the `can-bind` value, then that type can have bindings applied to it.

If an element tries to add a binding and is not one of the bindable types and one of the allowed types is a Logical Model, SUSHI will now log a warning. If an element tries to add a binding and it is not a bindable type and none of the types are a Logical Model, SUSHI's original behavior is the same and an error is thrown.

I did update the message in that error to reflect that the type can have a binding if it is a logical model with #can-bind because it seemed to make the error message the most correct, but there technically is not a case when that error message will be logged and the fix will be to add `#can-bind` because that case would case the warning to be logged instead. If you disagree with this approach, let me know.

The approach I took to implement this was to add another metadata field `canBind`, similar to `canBeTarget`. After talking to @cmoesel, we might want to consider refactoring this to have one `characteristics` metadata field, which can be an object that has each characteristic's value. We decided that this refactor could be a separate task, but I wanted to note it here for context.

Patch:
diff --git a/src/errors/CodedTypeNotFoundError.ts b/src/errors/CodedTypeNotFoundError.ts
index 7b0c7a7..84b7768 100644
--- a/src/errors/CodedTypeNotFoundError.ts
+++ b/src/errors/CodedTypeNotFoundError.ts
@@ -8,7 +8,7 @@ export class CodedTypeNotFoundError extends Error implements Annotated {
     super(
       `Cannot bind value set to ${foundTypes.join(
         ','
-      )}; must be coded (code, Coding, CodeableConcept, Quantity, CodeableReference), or the data types (string, uri).`
+      )}; must be coded (code, Coding, CodeableConcept, Quantity, CodeableReference), the data types (string, uri), or a logical model with the #can-bind characteristic.`
     );
   }
 }
diff --git a/src/export/Package.ts b/src/export/Package.ts
index d19b25f..d695f77 100644
--- a/src/export/Package.ts
+++ b/src/export/Package.ts
@@ -192,6 +192,16 @@ export class Package implements Fishable {
           if (metadata.canBeTarget === false && result.inProgress) {
             return undefined;
           }
+
+          metadata.canBind =
+            result.extension?.some(
+              ext => ext?.url === TYPE_CHARACTERISTICS_EXTENSION && ext?.valueCode === 'can-bind'
+            ) ?? false;
+          // if the export is still in progress, there may be unprocessed rules that make this a valid bindable type
+          // return undefined to force the metadata to come from the tank
+          if (metadata.canBeTarget === false && result.inProgress) {
+            return undefined;
+          }
         }
       }
       return metadata;
diff --git a/src/export/StructureDefinitionExporter.ts b/src/export/StructureDefinitionExporter.ts
index 192ebda..746245e 100644
--- a/src/export/StructureDefinitionExporter.ts
+++ b/src/export/StructureDefinitionExporter.ts
@@ -857,7 +857,8 @@ export class StructureDefinitionExporter implements Fishable {
             element.bindToVS(
               vsURI,
               rule.strength as ElementDefinitionBindingStrength,
-              rule.sourceInfo
+              rule.sourceInfo,
+              this.fisher
             );
           } else if (rule instanceof ContainsRule) {
             const isExtension =
diff --git a/src/fhirdefs/FHIRDefinitions.ts b/src/fhirdefs/FHIRDefinitions.ts
index fbf989a..02598dd 100644
--- a/src/fhirdefs/FHIRDefinitions.ts
+++ b/src/fhirdefs/FHIRDefinitions.ts
@@ -114,6 +114,7 @@ export class FHIRDefinitions extends BaseFHIRDefinitions implements Fishable {
     const result = this.fishForFHIR(item, ...types);
     if (result) {
       let canBeTarget: boolean;
+      let canBind: boolean;
       if (result.resourceType === 'StructureDefinition' && result.kind === 'logical') {
         canBeTarget =
           result.extension?.some((ext: any) => {
@@ -122,6 +123,11 @@ export class FHIRDefinitions extends BaseFHIRDefinitions implements Fishable {
               (ext?.url === LOGICAL_TARGET_EXTENSION && ext?.valueBoolean === true)
             );
           }) ?? false;
+        canBind =
+          result.extension?.some(
+            (ext: any) =>
+              ext?.url === TYPE_CHARACTERISTICS_EXTENSION && ext?.valueCode === 'can-bind'
+          ) ?? false;
       }
       return {
         id: result.id as string,
@@ -132,7 +138,8 @@ export class FHIRDefinitions extends BaseFHIRDefinitions implements Fishable {
         abstract: result.abstract as boolean,
         version: result.version as string,
         resourceType: result.resourceType as string,
-        canBeTarget
+        canBeTarget,
+        canBind
       };
     }
   }
diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts
index 52f3665..f9aafea 100644
--- a/src/fhirtypes/ElementDefinition.ts
+++ b/src/fhirtypes/ElementDefinition.ts
@@ -1623,7 +1623,8 @@ export class ElementDefinition {
   bindToVS(
     vsURI: string,
     strength: ElementDefinitionBindingStrength,
-    ruleSourceInfo?: SourceInfo
+    ruleSourceInfo?: SourceInfo,
+    fisher?: Fishable
   ): void {
     // Check if this is a valid type to be bound against
     const validTypes = this.findTypesByCode(
@@ -1635,8 +1636,19 @@ export class ElementDefinition {
       'string',
       'uri'
     );
-    if (isEmpty(validTypes)) {
-      throw new CodedTypeNotFoundError(this.type ? this.type.map(t => t.code) : []);
+    const isBindable =
+      validTypes.length ||
+      this.type?.some(type => fisher?.fishForMetadata(type.code, Type.Logical)?.canBind);
+    if (!isBindable) {
+      if (this.type?.some(type => fisher?.fishForMetadata(type.code, Type.Logical) != null)) {
+        // Only warn if it was from a logical model that doesn't have #can-bind characteristic
+        logger.warn(
+          `Bindings can only be applied to logical model types with the #can-bind characteristic. Update the target logical model to declare the #can-bind characteristic or remove the binding from ${this.id}.`,
+          ruleSourceInfo
+        );
+      } else {
+        throw new CodedTypeNotFoundError(this.type ? this.type.map(t => t.code) : []);
+      }
     }
     // Check if a CodeableReference is attempting to bind directly to the concept element
     if (
@@ -1662,7 +1674,7 @@ export class ElementDefinition {
     connectedElements.forEach(ce => {
       if (vsURI == ce.binding?.valueSet) {
         try {
-          ce.bindToVS(vsURI, strength);
+          ce.bindToVS(vsURI, strength, null, fisher);
         } catch (ex) {
           // receiving a BindingStrengthError on a slice is not a problem, because
           // it is fine if the slice has a stronger binding than the list element.
diff --git a/src/import/FSHTank.ts b/src/import/FSHTank.ts
index 87f8a20..1c6601c 100644
--- a/src/import/FSHTank.ts
+++ b/src/import/FSHTank.ts
@@ -419,82 +419,8 @@ export class FSHTank implements Fishable {
           // Ref: https://chat.fhir.org/#narrow/stream/179177-conformance/topic/StructureDefinition.2Etype.20for.20Logical.20Models.2FCustom.20Resources/near/240488388
           const HL7_URL = 'http://hl7.org/fhir/StructureDefinition/';
           meta.sdType = meta.url.startsWith(HL7_URL) ? meta.url.slice(HL7_URL.length) : meta.url;
-          meta.canBeTarget = false;
-          if (result.characteristics.includes('can-be-target')) {
-            meta.canBeTarget = true;
-          } else {
-            // * ^extension[0].url = "http://hl7.org/fhir/StructureDefinition/structuredefinition-type-characteristics"
-            // * ^extension[0].valueCode = #can-be-target
-            // or
-            // * ^extension[0].url = "http://hl7.org/fhir/tools/StructureDefinition/logical-target"
-            // * ^extension[0].valueBoolean = true
-            // - give me every caret rule where path is empty and caretPath is extension.url or extension\[\d+\].url
-            // - for each index, tell me what the last url is. so now we have a mapping of "index" to "url"
-            // - filter that down to indices where the url is the one i want. so now i have the indices to check.
-            // - now, give me every caret rule with no path, caretPath is extension.valueWhatever, and the index is in my list
-            // - for each index, tell me what the last value is. that's the actual value.
-            // - if one of them has The Value I Seek, then the overall condition is true
-            // - otherwise, the overall condition is false
-            const extensionUrlRules = result.rules.filter(rule => {
-              return (
-                rule instanceof CaretValueRule &&
-                rule.path === '' &&
-                /^extension(\[\d+\])?\.url$/.test(rule.caretPath)
-              );
-            }) as CaretValueRule[];
-            const extensionUrls = new Map<number, string>();
-            extensionUrlRules.forEach(urlRule => {
-              const match = urlRule.caretPath.match(/^extension(\[(\d+)\])?\.url$/);
-              if (match?.[2]) {
-                extensionUrls.set(parseInt(match[2]), urlRule.value.toString());
-              } else {
-                extensionUrls.set(0, urlRule.value.toString());
-              }
-            });
-            for (const [idx, url] of extensionUrls.entries()) {
-              if (url === TYPE_CHARACTERISTICS_EXTENSION) {
-                const caretPaths = [`extension[${idx}].valueCode`];
-                if (idx === 0) {
-                  caretPaths.push('extension.valueCode');
-                }
-                const valueRule = result.rules
-                  .filter(rule => {
-                    return (
-                      rule instanceof CaretValueRule &&
-                      rule.path === '' &&
-                      caretPaths.includes(rule.caretPath)
-                    );
-                  })
-                  .pop() as CaretValueRule;
-                if (
-                  valueRule &&
-                  valueRule.value instanceof FshCode &&
-                  valueRule.value.code === 'can-be-target'
-                ) {
-                  meta.canBeTarget = true;
-                  break;
-                }
-              } else if (url === LOGICAL_TARGET_EXTENSION) {
-                const caretPaths = [`extension[${idx}].valueBoolean`];
-                if (idx === 0) {
-                  caretPaths.push('extension.valueBoolean');
-                }
-                const valueRule = result.rules
-                  .filter(rule => {
-                    return (
-                      rule instanceof CaretValueRule &&
-                      rule.path === '' &&
-                      caretPaths.includes(rule.caretPath)
-                    );
-                  })
-                  .pop() as CaretValueRule;
-                if (valueRule?.value === true) {
-                  meta.canBeTarget = true;
-                  break;
-                }
-              }
-            }
-          }
+          meta.canBeTarget = hasLogicalCharacteristic(result, 'can-be-target');
+          meta.canBind = hasLogicalCharacteristic(result, 'can-bind');
         }
       } else if (result instanceof FshValueSet || result instanceof FshCodeSystem) {
         meta.url = getUrlFromFshDefinition(result, this.config.canonical);
@@ -523,3 +449,76 @@ export class FSHTank implements Fishable {
     // in order to implement the Fishable interface
   }
 }
+
+function hasLogicalCharacteristic(logical: Logical, code: string) {
+  if (logical.characteristics.includes(code)) {
+    return true;
+  } else {
+    // * ^extension[0].url = "http://hl7.org/fhir/StructureDefinition/structuredefinition-type-characteristics"
+    // * ^extension[0].valueCode = #can-be-target
+    // or
+    // * ^extension[0].url = "http://hl7.org/fhir/tools/StructureDefinition/logical-target"
+    // * ^extension[0].valueBoolean = true
+    // - give me every caret rule where path is empty and caretPath is extension.url or extension\[\d+\].url
+    // - for each index, tell me what the last url is. so now we have a mapping of "index" to "url"
+    // - filter that down to indices where the url is the one i want. so now i have the indices to check.
+    // - now, give me every caret rule with no path, caretPath is extension.valueWhatever, and the index is in my list
+    // - for each index, tell me what the last value is. that's the actual value.
+    // - if one of them has The Value I Seek, then the overall condition is true
+    // - otherwise, the overall condition is false
+    const extensionUrlRules = logical.rules.filter(rule => {
+      return (
+        rule instanceof CaretValueRule &&
+        rule.path === '' &&
+        /^extension(\[\d+\])?\.url$/.test(rule.caretPath)
+      );
+    }) as CaretValueRule[];
+    const extensionUrls = new Map<number, string>();
+    extensionUrlRules.forEach(urlRule => {
+      const match = urlRule.caretPath.match(/^extension(\[(\d+)\])?\.url$/);
+      if (match?.[2]) {
+        extensionUrls.set(parseInt(match[2]), urlRule.value.toString());
+      } else {
+        extensionUrls.set(0, urlRule.value.toString());
+      }
+    });
+    for (const [idx, url] of extensionUrls.entries()) {
+      if (url === TYPE_CHARACTERISTICS_EXTENSION) {
+        const caretPaths = [`extension[${idx}].valueCode`];
+        if (idx === 0) {
+          caretPaths.push('extension.valueCode');
+        }
+        const valueRule = logical.rules
+          .filter(rule => {
+            return (
+              rule instanceof CaretValueRule &&
+              rule.path === '' &&
+              caretPaths.includes(rule.caretPath)
+            );
+          })
+          .pop() as CaretValueRule;
+        if (valueRule && valueRule.value instanceof FshCode && valueRule.value.code === code) {
+          return true;
+        }
+      } else if (url === LOGICAL_TARGET_EXTENSION && code === 'can-be-target') {
+        const caretPaths = [`extension[${idx}].valueBoolean`];
+        if (idx === 0) {
+          caretPaths.push('extension.valueBoolean');
+        }
+        const valueRule = logical.rules
+          .filter(rule => {
+            return (
+              rule instanceof CaretValueRule &&
+              rule.path === '' &&
+              caretPaths.includes(rule.caretPath)
+            );
+          })
+          .pop() as CaretValueRule;
+        if (valueRule?.value === true) {
+          return true;
+        }
+      }
+    }
+  }
+  return false;
+}
diff --git a/src/utils/Fishable.ts b/src/utils/Fishable.ts
index c1393c4..561d0b9 100644
--- a/src/utils/Fishable.ts
+++ b/src/utils/Fishable.ts
@@ -25,6 +25,7 @@ export interface Metadata {
   version?: string;
   instanceUsage?: Instance['usage'];
   canBeTarget?: boolean;
+  canBind?: boolean;
 }
 
 export interface Fishable {


Test patch:
diff --git a/test/export/Package.test.ts b/test/export/Package.test.ts
index 2d654a6..b2e65fd 100644
--- a/test/export/Package.test.ts
+++ b/test/export/Package.test.ts
@@ -93,6 +93,24 @@ describe('Package', () => {
       }
     ];
     pkg.logicals.push(logical2);
+    // Logical[3]: IcedCoffee / iced-coffee / iced-coffee
+    const logical3 = new StructureDefinition();
+    logical3.name = 'IcedCoffee';
+    logical3.id = 'iced-coffee';
+    logical3.type = 'iced-coffee';
+    logical3.url = 'http://hl7.org/fhir/us/minimal/StructureDefinition/iced-coffee';
+    logical3.baseDefinition = 'http://hl7.org/fhir/StructureDefinition/Basic';
+    logical3.kind = 'logical';
+    logical3.derivation = 'specialization';
+    logical3.fhirVersion = '4.0.1';
+    logical3.version = '1.0.2';
+    logical3.extension = [
+      {
+        url: 'http://hl7.org/fhir/StructureDefinition/structuredefinition-type-characteristics',
+        valueCode: 'can-bind'
+      }
+    ];
+    pkg.logicals.push(logical3);
     // Resource[0]: Destination / Destination / Destination
     const resource0 = new StructureDefinition();
     resource0.name = 'Destination';
@@ -723,7 +741,7 @@ describe('Package', () => {
       ).toEqual(poorTasteExtensionByID);
     });
 
-    it('should find logicals that can not be a reference target', () => {
+    it('should find logicals that can not be a reference target or have bindings', () => {
       const wheatBeerLogicalByID = pkg.fishForMetadata('wheat-beer', Type.Logical);
       expect(wheatBeerLogicalByID).toEqual({
         id: 'wheat-beer',
@@ -733,7 +751,8 @@ describe('Package', () => {
         parent: 'http://hl7.org/fhir/StructureDefinition/Element',
         resourceType: 'StructureDefinition',
         version: '1.0.0',
-        canBeTarget: false
+        canBeTarget: false,
+        canBind: false
       });
       expect(pkg.fishForMetadata('WheatBeer', Type.Logical)).toEqual(wheatBeerLogicalByID);
       expect(
@@ -754,7 +773,8 @@ describe('Package', () => {
         parent: 'http://hl7.org/fhir/StructureDefinition/Basic',
         resourceType: 'StructureDefinition',
         version: '1.0.0',
-        canBeTarget: true
+        canBeTarget: true,
+        canBind: false
       });
       expect(pkg.fishForMetadata('RedWine', Type.Logical)).toEqual(redWineLogicalByID);
       expect(
@@ -775,7 +795,8 @@ describe('Package', () => {
         parent: 'http://hl7.org/fhir/StructureDefinition/Basic',
         resourceType: 'StructureDefinition',
         version: '1.0.2',
-        canBeTarget: true
+        canBeTarget: true,
+        canBind: false
       });
       expect(pkg.fishForMetadata('SparklingWater', Type.Logical)).toEqual(
         sparklingWaterLogicalByID
@@ -788,6 +809,21 @@ describe('Package', () => {
       ).toEqual(sparklingWaterLogicalByID);
     });
 
+    it('should find logicals that can have a binding using the can-bind extension', () => {
+      const icedCoffeeLogicalById = pkg.fishForMetadata('iced-coffee', Type.Logical);
+      expect(icedCoffeeLogicalById).toEqual({
+        id: 'iced-coffee',
+        name: 'IcedCoffee',
+        sdType: 'iced-coffee',
+        url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/iced-coffee',
+        parent: 'http://hl7.org/fhir/StructureDefinition/Basic',
+        resourceType: 'StructureDefinition',
+        version: '1.0.2',
+        canBeTarget: false,
+        canBind: true
+      });
+    });
+
     it('should find resources', () => {
       const destinationResourceByID = pkg.fishForMetadata('Destination', Type.Resource);
       expect(destinationResourceByID).toEqual({
@@ -974,7 +1010,8 @@ describe('Package', () => {
         parent: 'http://hl7.org/fhir/StructureDefinition/Element',
         resourceType: 'StructureDefinition',
         version: '1.0.0',
-        canBeTarget: false
+        canBeTarget: false,
+        canBind: false
       });
       expect(pkg.fishForMetadata('WheatBeer')).toEqual(wheatBeerLogicalByID);
       expect(
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index 31c5cb8..e19481e 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -3455,6 +3455,120 @@ describe('StructureDefinitionExporter R4', () => {
       expect(element.binding.strength).toBe('extensible');
     });
 
+    it('should apply a value set rule on an element that has the #can-bind characteristic', () => {
+      // Logical: BindableLM
+      // Characteristics: #can-bind
+      const bindableLM = new Logical('BindableLM');
+      bindableLM.characteristics = ['can-bind'];
+      doc.logicals.set(bindableLM.name, bindableLM);
+
+      const erasVS = new FshValueSet('ErasVS');
+      doc.valueSets.set(erasVS.name, erasVS);
+
+      // Logical: FutureResource
+      // * era  0..1 BindableLM "Future era"
+      // * era from Eras (extensible)
+      const logical = new Logical('FutureResource');
+      const eraElement = new AddElementRule('era');
+      eraElement.min = 0;
+      eraElement.max = '1';
+      eraElement.types = [{ type: 'BindableLM' }];
+      eraElement.short = 'Future era';
+      const eraBindingRule = new BindingRule('era');
+      eraBindingRule.valueSet = 'ErasVS';
+      eraBindingRule.strength = 'extensible';
+      logical.rules.push(eraElement, eraBindingRule);
+      doc.logicals.set(logical.name, logical);
+
+      exporter.exportStructDef(logical);
+      const sd = pkg.logicals[0];
+      const element = sd.findElement('FutureResource.era');
+      expect(element.binding.valueSet).toBe('http://hl7.org/fhir/us/minimal/ValueSet/ErasVS');
+      expect(element.binding.strength).toBe('extensible');
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+    });
+
+    it('should apply a value set rule on an element that has the #can-bind type characteristic extension', () => {
+      // Logical: BindableLM
+      // * ^extension[0].url = "http://hl7.org/fhir/StructureDefinition/structuredefinition-type-characteristics"
+      // * ^extension[0].valueCode = #can-bind
+      const bindableLM = new Logical('BindableLM');
+      const typeCharacteristicsUrl = new CaretValueRule('');
+      typeCharacteristicsUrl.caretPath = 'extension[0].url';
+      typeCharacteristicsUrl.value =
+        'http://hl7.org/fhir/StructureDefinition/structuredefinition-type-characteristics';
+      const typeCharacteristicsValue = new CaretValueRule('');
+      typeCharacteristicsValue.caretPath = 'extension[0].valueCode';
+      typeCharacteristicsValue.value = new FshCode('can-bind');
+      bindableLM.rules.push(typeCharacteristicsUrl, typeCharacteristicsValue);
+      doc.logicals.set(bindableLM.name, bindableLM);
+
+      const erasVS = new FshValueSet('ErasVS');
+      doc.valueSets.set(erasVS.name, erasVS);
+
+      // Logical: FutureResource
+      // * era  0..1 BindableLM "Future era"
+      // * era from Eras (extensible)
+      const logical = new Logical('FutureResource');
+      const eraElement = new AddElementRule('era');
+      eraElement.min = 0;
+      eraElement.max = '1';
+      eraElement.types = [{ type: 'BindableLM' }];
+      eraElement.short = 'Future era';
+      const eraBindingRule = new BindingRule('era');
+      eraBindingRule.valueSet = 'ErasVS';
+      eraBindingRule.strength = 'extensible';
+      logical.rules.push(eraElement, eraBindingRule);
+      doc.logicals.set(logical.name, logical);
+
+      exporter.exportStructDef(logical);
+      const sd = pkg.logicals[0];
+      const element = sd.findElement('FutureResource.era');
+      expect(element.binding.valueSet).toBe('http://hl7.org/fhir/us/minimal/ValueSet/ErasVS');
+      expect(element.binding.strength).toBe('extensible');
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+    });
+
+    it('should log a warning and apply a value set rule on an element that is missing the #can-bind characteristic and extension', () => {
+      // Logical: NotBindableLM
+      const notBindableLM = new Logical('NotBindableLM');
+      doc.logicals.set(notBindableLM.name, notBindableLM);
+
+      const erasVS = new FshValueSet('ErasVS');
+      doc.valueSets.set(erasVS.name, erasVS);
+
+      // Logical: FutureResource
+      // * era  0..1 NotBindableLM "Future era"
+      // * era from Eras (extensible)
+      const logical = new Logical('FutureResource');
+      const eraElement = new AddElementRule('era');
+      eraElement.min = 0;
+      eraElement.max = '1';
+      eraElement.types = [{ type: 'NotBindableLM' }];
+      eraElement.short = 'Future era';
+      const eraBindingRule = new BindingRule('era')
+        .withFile('NoBinding.fsh')
+        .withLocation([5, 1, 5, 25]);
+      eraBindingRule.valueSet = 'ErasVS';
+      eraBindingRule.strength = 'extensible';
+      logical.rules.push(eraElement, eraBindingRule);
+      doc.logicals.set(logical.name, logical);
+
+      exporter.exportStructDef(logical);
+      const sd = pkg.logicals[0];
+      const element = sd.findElement('FutureResource.era');
+      expect(element.binding.valueSet).toBe('http://hl7.org/fhir/us/minimal/ValueSet/ErasVS');
+      expect(element.binding.strength).toBe('extensible');
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(1);
+      expect(loggerSpy.getLastMessage('warn')).toMatch(
+        /Bindings can only be applied to logical model types with the #can-bind characteristic\. .*remove the binding from FutureResource\.era\./
+      );
+      expect(loggerSpy.getLastMessage('warn')).toMatch(/File: NoBinding\.fsh.*Line: 5\D*/s);
+    });
+
     it('should not apply a value set rule on an element that cannot support it', () => {
       const profile = new Profile('Foo');
       profile.parent = 'Observation';
diff --git a/test/fhirdefs/FHIRDefinitions.test.ts b/test/fhirdefs/FHIRDefinitions.test.ts
index 7728b88..0ea0e4c 100644
--- a/test/fhirdefs/FHIRDefinitions.test.ts
+++ b/test/fhirdefs/FHIRDefinitions.test.ts
@@ -511,7 +511,8 @@ describe('FHIRDefinitions', () => {
         version: '0.1.0',
         parent: 'http://hl7.org/fhir/StructureDefinition/Element',
         resourceType: 'StructureDefinition',
-        canBeTarget: false
+        canBeTarget: false,
+        canBind: false
       });
       expect(
         defs.fishForMetadata(
@@ -1007,13 +1008,32 @@ describe('FHIRDefinitions', () => {
         url: 'http://hl7.org/fhir/us/eltss/StructureDefinition/eLTSSServiceModel',
         version: '0.1.0',
         resourceType: 'StructureDefinition',
-        canBeTarget: false
+        canBeTarget: false,
+        canBind: false
       });
       expect(defs.fishForMetadata('ELTSSServiceModel')).toEqual(eLTSSServiceModelByID);
       expect(
         defs.fishForMetadata('http://hl7.org/fhir/us/eltss/StructureDefinition/eLTSSServiceModel')
       ).toEqual(eLTSSServiceModelByID);
     });
+
+    it('should find logical models with the can-bind type characteristic extension', () => {
+      const bindableLMById = defs.fishForMetadata('BindableLM', Type.Logical);
+      expect(bindableLMById).toEqual({
+        abstract: false,
+        id: 'BindableLM',
+        name: 'BindableLM',
+        sdType: 'http://example.org/StructureDefinition/BindableLM',
+        url: 'http://example.org/StructureDefinition/BindableLM',
+        parent: 'http://hl7.org/fhir/StructureDefinition/Base',
+        resourceType: 'StructureDefinition',
+        canBeTarget: false,
+        canBind: true // BindableLM has can-bind type-characteristics extension
+      });
+      expect(
+        defs.fishForMetadata('http://example.org/StructureDefinition/BindableLM', Type.Logical)
+      ).toEqual(bindableLMById);
+    });
   });
 
   describe('#fishForPredefinedResource', () => {
diff --git a/test/import/FSHTank.test.ts b/test/import/FSHTank.test.ts
index 6906f7b..be414c6 100644
--- a/test/import/FSHTank.test.ts
+++ b/test/import/FSHTank.test.ts
@@ -834,7 +834,8 @@ describe('FSHTank', () => {
       sdType: 'http://hl7.org/fhir/us/minimal/StructureDefinition/log1',
       parent: 'Element',
       resourceType: 'StructureDefinition',
-      canBeTarget: false
+      canBeTarget: false,
+      canBind: false
     };
     const res1MD: Metadata = {
       id: 'res1',
@@ -1188,7 +1189,8 @@ describe('FSHTank for HL7', () => {
       sdType: 'hl7-log',
       parent: 'Element',
       resourceType: 'StructureDefinition',
-      canBeTarget: false
+      canBeTarget: false,
+      canBind: false
     };
 
     it('should find valid HL7 fish metadata when fishing by id for logical models', () => {
diff --git a/test/testhelpers/testdefs/r4-definitions/package/StructureDefinition-BindableLM.json b/test/testhelpers/testdefs/r4-definitions/package/StructureDefinition-BindableLM.json
new file mode 100644
index 0000000..69d57e8
--- /dev/null
+++ b/test/testhelpers/testdefs/r4-definitions/package/StructureDefinition-BindableLM.json
@@ -0,0 +1,47 @@
+{
+  "resourceType": "StructureDefinition",
+  "id": "BindableLM",
+  "extension": [
+    {
+      "url": "http://hl7.org/fhir/StructureDefinition/structuredefinition-type-characteristics",
+      "valueCode": "can-bind"
+    }
+  ],
+  "url": "http://example.org/StructureDefinition/BindableLM",
+  "name": "BindableLM",
+  "status": "draft",
+  "fhirVersion": "4.0.1",
+  "mapping": [
+    {
+      "identity": "rim",
+      "uri": "http://hl7.org/v3",
+      "name": "RIM Mapping"
+    }
+  ],
+  "kind": "logical",
+  "abstract": false,
+  "type": "http://example.org/StructureDefinition/BindableLM",
+  "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Base",
+  "derivation": "specialization",
+  "differential": {
+    "element": [
+      {
+        "id": "BindableLM",
+        "path": "BindableLM"
+      },
+      {
+        "id": "BindableLM.drinks",
+        "path": "BindableLM.drinks",
+        "short": "there are many types of drinks",
+        "definition": "there are many types of drinks",
+        "min": 1,
+        "max": "1",
+        "type": [
+          {
+            "code": "code"
+          }
+        ]
+      }
+    ]
+  }
+}