Developer-focused FHIR toolkit for Node.js and TypeScript: an R4-first resource model, a JSON and XML codec, and validation, with the same one-line ergonomics as the rest of the
@cosyte/*parser suite.
Status: pre-alpha, unpublished. What is built: the no-data-loss core (a
precision-preserving JSON codec and typed primitive model), the first three validation layers
(structure, cardinality, and primitive/enumerated-code value-domain) with value-free
OperationOutcome output, the safety-critical status & negation model (readSafety,
fail-closed on unknown modifierExtension, the ait/con/obs invariants), Quantity / UCUM
fidelity (the 11-way Observation.value[x] discrimination, UCUM-code unit fidelity, vital-signs
required-unit conformance, dose quantities), strength-aware, content-free terminology binding
validation (a frozen known-systems registry, binding-strength severity, the multi-system allergy /
medication bindings, and a pluggable terminology-service interface, none bundled),
StructureDefinition-driven profile validation (snapshot generation, slicing, fixed[x] /
pattern[x], and must-support as a system obligation, against caller-supplied US Core / vendor
profiles, none bundled), and profile-invariant validation through a bounded, vendored FHIRPath
subset (an in-repo lexer → parser → evaluator that evaluates a profile's constraint[], reporting
anything outside the subset INVARIANT_UNCHECKED rather than passing it), and a zero-dependency XML
codec (parseResourceXml / serializeResourceXml) that reads and writes the same schema-free model
as the JSON codec, with a reader that is XXE- and billion-laughs-proof by refusal (any DTD or
non-predefined entity is refused loudly, never resolved or expanded) and a nodesEquivalent oracle for
JSON↔XML model equivalence, and Bundles + references + Bulk NDJSON streaming (readBundle with
transaction = all-or-nothing vs batch = independent semantics: modeled, never executed; reference
resolution for relative / absolute / logical / #fragment with a DoS-safe cycle guard; and a
streamNdjson reader with per-line error isolation and no whole-file load), and a
programmatic profile-authoring API (defineProfile() builds a StructureDefinition in code: the
same model loadStructureDefinition reads from JSON, one path with no privileged internal shape, plus
a spec-grounded starter kit of example profiles that dogfood it), and conformance hardening:
JSON + XML + NDJSON fuzz targets proving adversarial input never
crashes / hangs / OOMs (only a typed error or a bounded rejection; the JSON reader now bounds nesting
with a MAX_DEPTH_EXCEEDED fatal, matching the XML reader), a PHI-leak test tier gating the
value-free-diagnostics contract, and type-level (expect-type) tests on the public surface. See
What works today. It reads, round-trips,
structurally validates, never drops a modifier / status / negation, surfaces measured values by their
true type with the UCUM code (never the display string, never converted), validates code systems
and binding strength without vendoring any SNOMED / CPT / LOINC content, validates against US Core
profiles you supply, and evaluates their FHIRPath invariants (failing safe to INVARIANT_UNCHECKED
on any unsupported expression); it does not yet do type·profile slicing discriminator or
reslicing validation (still PROFILE_SLICE_UNCHECKED), and it bundles no US Core
IG corpus. The validator_cli.jar differential is authored but CI-only (a JVM oracle job: there is
no Java in the dev container, so it has not been observed green there) and now runs over both the
synthetic spec-clean corpus and the real-world quirk corpus. The built-in structural schema set is the base-resource elements plus
Patient as a worked demonstrator; other resource types validate only against a caller-supplied schema
or profile. Without a supplied terminology service there is no code-validity / value-set-membership
guarantee beyond system + strength (no terminology content is bundled: licensing). Its XML codec is
schema-free like the JSON one, so an XML-sourced primitive is kept as its lexical string and typed
cross-format transcoding (emitting spec-clean JSON booleans/numbers from an XML model) needs the
datatype schema and is not yet done; the XHTML structure inside Narrative.div is not modeled or
validated (carried opaquely as a string (the JSON codec's fidelity), never dropped), and RDF/Turtle is
out of scope. It has no typed per-resource models
yet, and it never converts a unit or evaluates a reference range. Do not depend on this package.
The no-data-loss core: read FHIR R4 JSON into an immutable model and serialize it back, without ever losing a decimal, a primitive extension, or an exact 64-bit value.
import { parseResource, serializeResource } from "@cosyte/fhir";
const { resource, issues } = parseResource(
'{"resourceType":"Observation","valueQuantity":{"value":0.010,"unit":"mg"}}',
);
// The trailing zero survives: a naive JSON.parse would have made this 0.01.
serializeResource(resource); // → {"resourceType":"Observation","valueQuantity":{"value":0.010,"unit":"mg"}}
// Diagnostics are value-free: a code + a FHIRPath location, never the value. The location is
// built from the names the document supplies, and those are bounded to their published form.
issues; // → [{ code: "DECIMAL_PRECISION_AT_RISK", severity: "information", expression: "Observation.valueQuantity.value" }]decimal/integer64are string-backed (FhirDecimal,FhirInteger64) and never routed through the JSnumbertype.FhirDecimal.equalsis precision-sensitive (0.010 ≠ 0.01);.equalsValuecompares quantity only.- Primitive extensions (the
_elementsibling) are modeled first-class with null-padded array alignment; a misaligned value/_-array fails closed rather than mis-attaching an extension. A_-sibling written beside an element that is not a primitive has no defined meaning (a complex element carries itsid/extensioninline), and the reader does not model what was in it, so that position raisesMISPLACED_PRIMITIVE_EXTENSION: its own code, because unlike an unexpected property it says content is not readable there rather than merely tolerated. - A diagnostic's
expressionis a location and nothing else. R4 definesOperationOutcome.issue.expressionas a FHIRPath subset that resolves to a node, so an issue says where in the path and why in thecode, and never explains itself in prose inside the path. Two forms are deliberately not resolvable and are the only two: a<withheld>segment, which is what a name that fails the bounded-echo shape test prints as, and the XML reader's.@name, which is an XML attribute FHIR gives no element to address. - Lenient read, conservative write (Postel's Law),
resourceTyperesolvable in any position, and aparseReferenceclassifier (relative / absolute / logical / fragment). The writer authors no value of its own, and it emits spec-clean FHIR for every model FHIR can express; the shapes it cannot express (an array inside an array, a scalar ornullwhere FHIR JSON has an object (at a complex element's own position, or in a primitive's_-sibling), anullin a primitive's value channel that padded nothing, and a non-stringresourceType) are handed back as written rather than repaired, because repairing them means inventing or dropping content. See the no-data-loss notes below. The twonullentries are different branches, and for a long time only the first was listed while the second was silently deleted on emit. - A repeated property name is read, not resolved. FHIR requires unique property names and JSON
leaves the winner undefined, so the first value wins everywhere and a
DUPLICATE_PROPERTYissue says where. On an object element both values are kept (getAllPropertiesreads them,getPropertystill returns the first), the element is treated as genuinely ambiguous, and nothing downstream pretends otherwise: it validates as an error, the safety readout declines to summarize it rather than answering from one arbitrary half of the document, and both writers refuse rather than emit the surviving member alone. Inside a primitive's_elementmetadata (idandextension, which no safety verdict reads) the issue is raised but the shadowed member is not kept, and validation and the safety readout are unaffected.
And the first three validation layers (structure, cardinality, and primitive/enumerated-code
value-domain) with a value-free OperationOutcome:
import { parseResource, validateResource, serializeResource } from "@cosyte/fhir";
const { resource } = parseResource('{"resourceType":"Patient","gender":"masculine","wibble":1}');
const { issues, valid } = validateResource(resource); // lenient (read) mode by default
valid; // → false
issues;
// → [
// { code: "UNKNOWN_ELEMENT", severity: "warning", type: "structure", expression: "Patient.wibble" },
// { code: "CODE_INVALID", severity: "error", type: "code-invalid", expression: "Patient.gender" },
// ]
// Render an OperationOutcome: the diagnostics are value-free (a coded reason + a location, never
// the offending value "masculine"), the PHI redaction chokepoint.
serializeResource(validateResource(resource).toOperationOutcome());- Layered, severity-tagged (validation.html): structure (
UNKNOWN_ELEMENT,TYPE_MISMATCH,CHOICE_AMBIGUOUS), cardinality (CARDINALITY_MIN/_MAX), value-domain (PRIMITIVE_INVALIDwith the R4 datatype regexes,CODE_INVALIDfor required-strength enumerations). - Lenient vs strict: an unknown element is a
warningon read and anerrorundermode: "strict". - Fail-safe: never a false error: a resource type with no schema degrades to one informational
RESOURCE_NOT_MODELED, not a wall of false unknowns. Built-in schemas: base-resource elements +Patient; supply your own viavalidateResource(resource, { schemas: [...] }).
And the safety spine: FHIR's modifier (?!) elements, surfaced so they can never be silently dropped
or inverted, and the invariants that harm a patient when read wrong:
import { parseResource, readSafety, validateResource } from "@cosyte/fhir";
const { resource } = parseResource(
'{"resourceType":"AllergyIntolerance",' +
'"clinicalStatus":{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical","code":"active"}]},' +
'"code":{"coding":[{"system":"http://snomed.info/sct","code":"716186003"}]}}',
);
readSafety(resource).negations; // → ["no-known-allergy"] (a recorded "no allergy", not an allergy TO it)
// An unknown modifierExtension fails closed: the resource cannot be safely processed.
const { resource: quirky } = parseResource(
'{"resourceType":"Observation","status":"final","modifierExtension":[{"url":"http://vendor.example/x"}]}',
);
validateResource(quirky).issues.map((i) => i.code); // → ["UNHANDLED_MODIFIER_EXTENSION"]- Never-droppable status/negation:
readSafetycarriesstatus/clinicalStatus/verificationStatus/doNotPerform/ retraction and a classifiednegationslist (refuted,no-known-allergy,do-not-perform,not-taken,not-done,entered-in-error). The type-scoped slots are filled for the typesSAFETY_RESOURCE_TYPESnames; the negation reads that can only add a finding are not type-scoped at all: a retraction, a refutation, an instruction not to perform, and astatusofnot-done/not-takenare surfaced whatever the resource type, because a gate does not merely fail to read the types it omits, it never looks, so nothing is reported for them either. Those same reads run at every resource root, so a retractedObservation, aProcedurerecorded as not performed or an order marked "do not perform" inside aBundleentry orcontainedreachesnegationstoo. The readout's location channels (unhandledModifierExtensions,shadowedProperties,arrayWrappedScalars,nestedArrays,droppedText,unreadableBooleans,nearMissNegationCodes,unreadableNegationCodes) andsafeToSummarizeare document-wide. The single-valued fields (status,retracted,doNotPerform,noKnownAllergyand the rest) answer about the resource you handed in, because one value cannot say which resource it came from, so branch onnegationswhenever a resource may carry others.no-known-allergyis the exception and stays a root, type-scoped read: it is a positive clinical assertion, read off an element R4 does not flag?!, and surfacing one from somewhere inside a document could make a caller less careful where leaving it unsurfaced reads as unknown.assertSafeToSummarizerefuses (throws) rather than flatten past an unhandled modifier. - A safety verdict is never asserted over a value the document left ambiguous. Each negation read
runs over every coding on a
CodeableConceptand every value written for the element it reads (resourceType,status,verificationStatus,code,doNotPerform), including through an array wrapper around the element, so a retraction or a refutation cannot hide in the one a single-value lookup skipped. Where a repeated property name leaves an element with two values,safeToSummarizeisfalsewith the locations inshadowedPropertiesinstead of an affirmative answer. - A single-valued element wrapped in an array is read, and reported. FHIR JSON writes a
0..1element as a name/value pair and uses an array only for a repeating element, so{"resourceType":"Observation","status":["entered-in-error"]}is non-conformant, and a plain single-value read finds no code in it at all. It is realistic input, because a generic XML-to-JSON converter array-wraps every element it emits. The negation reads see through the wrapper, anARRAY_WRAPPED_SCALARissue (error) says where it was, andsafeToSummarizeisfalsewith the locations inarrayWrappedScalars. The check coversresourceTypeand the single-valued safety elements on a resource root; deciding cardinality elsewhere would need a per-resource model, and R4 genuinely does define repeating elements under some of the same names (Questionnaire.code), so a name-only rule would report a conformant document as broken. - That extends one level down, to a
Coding.system/Coding.codeinside aCodeableConcept, which are0..1too and which the same converter wraps the same way, so a refuted allergy, a recorded "no known allergy" and a retracted Condition all hinge on it. Here the wrapper is read only where it holds exactly one array position, and the restriction is the safety property, not caution: those two values are paired with each other, so a rule yielding more than one value on either side would pair asystemwritten in one position with acodewritten in another and assert a coding the sender never wrote, including a recorded absence of allergy, which is a positive clinical claim about a patient. Positions, not values: a JSONnullinside a primitive array is a real position whose_-sibling may carry an extension, so["716186003", null]is two positions and is not read. A wrapper that is not read is still reported, so a negation is never quietly skipped: either way theCodingdraws anARRAY_WRAPPED_SCALARerror andsafeToSummarizeisfalse. Scope: this covers the codings ofclinicalStatus,verificationStatusandcode, which are the elements a safety verdict is read out of. ACodinganywhere else (category,interpretation,referenceRange.type, acomponent's owncode, and anythingcodingsOfis pointed at directly) is read exactly as it was before, without the wrapper. Reading a wrapper the library does not also report would resolve a clinical code out of an encoding FHIR JSON does not define and hand it back with no diagnostic anywhere. The type scoping stops at the element name, and it does not reach theCodinginside it. The negation reads are not type-scoped at all, so averificationStatus.coding.system/.codewrapper is reported at every resource root of any type, including insidecontainedand aBundle.entry-- read through where it holds one position, left unread where it holds more, and reported either way. What makes that safe without a per-resource model is thatCodingis a datatype: itssystemandcodeare0..1wherever aCodingappears, so no question about the enclosing resource arises. Acodecarrying SNOMED716186003is read only on the types the cardinality table knows, so its wrapper is reported wherever it is read. One read still runs ahead of its report, and it is named rather than smoothed over: theclinicalStatusconvenience field is filled off any resource root, so on a type the cardinality table does not know, that value is unwrapped (or, for a multi-position wrapper, declined) with no location reported, sosafeToSummarizestaystrueover a value the library declined to read. Otherwise it reaches that one field and nothing else: nevernegations, nevervalid, nevernoKnownAllergy. A declared residual, pinned in both states, not a design. - An array inside an array is reported, and its contents are kept but never interpreted. FHIR
JSON uses an array for a repeating element and for nothing else, so a list of lists has no meaning
at any position and there is no element for the reader to make of it. Left alone the model then
looks exactly like an element the sender legitimately left out: whole resources have gone missing
this way inside a
Bundle.entry, and a refuted allergy has read back as an ordinary active one. So the position is named on every channel.NESTED_ARRAYon the read (warning) and invalidateResource(error), the locations innestedArrays,safeToSummarizeisfalse, andassertSafeToSummarizethrows.isNestedArraymarks the node for a consumer walking the model directly. Because the shape is meaningless everywhere, this needs no cardinality rule and cannot fire on a conformant document, so unlike the two above it the check runs at every position the model has a node for, at every depth, including a primitive'sextensionmetadata. The array itself is not lost. Its exact JSON text is preserved on the node and handed back bynestedArrayContent, so you can inspect or re-parse what the sender wrote (readRawJsonwill parse it with the same precision guarantees as the rest of the codec). A repeating primitive can nest in its value array, in its_-sibling array, or in both at one position, so the two channels come back separately rather than merged.serializeResourcewrites the array back, which is the one place the writer emits something it would not author: the alternatives are to emit the empty element the model holds, which fabricates an object the sender never wrote, or to omit the position, which drops content. Writing it back is also what makes the finding survive a round trip rather than laundering away. The preserved text is the array re-rendered compactly, so member order, every member of a repeated key and every number's exact source survive, but insignificant whitespace does not and strings are re-escaped canonically, exactly as everywhere else this library emits JSON. Such output is deliberately not spec-clean. - A scalar where FHIR JSON has an object is handed back too, and that one stops the writer authoring
a value. One position over from the shape above: a string, number, boolean or
nullwritten where a complex element belongs is content the reader has no element to make of it either, so it reportsUNKNOWN_PROPERTYand the model holds an empty element there. Emitting that element writes{}, which is a conformant empty element, so the warning was gone the moment the output was read back and the writer had presented an object as read at a position nothing was read at.serializeResourcenow writes the value the sender wrote instead, so the finding survives the round trip. The scalar is not modeled as a primitive, deliberately: putting it in the tree would make it visible to every walker at a position walkers read as a complex element. It hangs off the node (FhirComplex.nonObjectSource), where only the writer reads it. Such output is deliberately not spec-clean, andserializeResourceXmldoes not carry it (that writer emits the empty element, the same as it does for an array inside an array). The same rule reaches a primitive's_-sibling, which is the other place FHIR JSON has an object. §2.6.2.3 gives that channel theidand/orextension, so{"_status":null}and{"_status":"x"}carry no metadata to model; the writer emits a_-sibling only for metadata it has, so both used to come back as{}with no diagnostic at all: the member gone, andvalidandsafeToSummarizeboth affirming. They now draw the sameUNKNOWN_PROPERTYand the text is handed back (FhirPrimitive.nonObjectMetaSource), so the finding survives the round trip. Anullpadding a repeating primitive's_-array is the one shape §2.6.2.3 defines and draws nothing; anullat a singleton_slot is never padding and does. Gaps, stated rather than implied: the rule is bounded by what the reader modeled. A_-sibling the reader discards whole because it is misplaced or unrecognised (one sitting on an object or a non-primitive array, or a member of a_-sibling object that is neither anidstring nor anextensionarray) leaves no node behind, so an array inside one is reported against the discarded sibling and draws no refusal. Which warning it draws is per member, not one code for all three. An unrecognised member of a_-sibling object drawsUNKNOWN_PROPERTY:{"birthDate":"1980-01-01","_birthDate":{"foo":[["x"]]}}reports it atPatient.birthDate.foo. A_-sibling on an object and one on a non-primitive array instead drawMISPLACED_PRIMITIVE_EXTENSIONfor the misplaced sibling and nothing besides:{"name":{"family":"Roe"},"_name":[[{"id":"x"}]]}reports that one code atPatient.name, and no unexpected-property warning. Reaching it would mean reading raw JSON the codec does not model, which is the same problem as making the value readable. An empty_-sibling object or array is a different clause of the spec (§2.6.2.1's "never empty") and is still deleted silently; a_-sibling object's own unreadable member is reported but that report still does not survive emit. Both are open and declared, not closed here. What it deliberately does not do is put the array in the tree. The preserved content is text, not an element: it is not reachable through a node's properties, items or extensions, so a list holds exactly the items it held before, of the same kinds, with the same contents, and nothing that walks a repeating element sees anything new. That boundary is not a matter of taste. This library has checks that flatten a repeating element into its items and then skip whatever is not the kind they expect, so a list holding a list would reach them as an absent value: a profile invariant, a vital-signs unit check or a negation would go unevaluated and the resource would read as valid. Preserving the text costs none of that. One further limitation, stated rather than hidden: a scalar written beside a nested array in the same array ("given":[["Peter"],"James"]) lands where an object was expected, and that scalar is still dropped. It is reported as an unexpected property and the resource is still refused, but unlike the array itself its content is not kept. - A
nullthe reader reads into a primitive slot is reported and written back, which is the other half of the rule above and used to be missing. The bullet above covers thenullthe reader takes to the complex branch. Every othernullis a different branch, and it read with no diagnostic at all and was then deleted on emit, so a non-conformant document came back as a clean conformant one with the member simply gone and every layer affirmed it.{"identifier":[{"system":"…","value":null}]}lost the identifier value;{"value":null,"unit":"mg"}lost the magnitude and kept the unit, so a quantity read back as a bare unit rather than as missing;"status":nulllost the status. Nothing was lost in the sense the shapes above lose things (anullcarries no content), which is precisely why it was invisible: the output was indistinguishable from a document whose sender had legitimately omitted the element. The rule is what §2.6.2.3 actually defines, not "the document wrotenull". FHIR JSON forbidsnull(§2.6.2.1: "properties never have null values (except for a special case documented below)") and carves out one exception, which is about a repeating primitive: the value array and the_-sibling array are padded withnullso they align index-by-index. So two conditions are required for anullto be that exception, and both are checked: it sat inside a repeating primitive's value array, and the slot it produced carries anidor a non-emptyextensionfor it to align with. One that is padding draws nothing and round-trips byte-for-byte exactly as before. One that is not leaves an element with neither a value nor children, which R4ele-1requires one of: it drawsUNDEFINED_JSON_NULL(warning) at that position,isUndefinedNullmarks the node, andserializeResourcewrites thenullback so re-reading the output reproduces the finding instead of laundering it away. Such output is deliberately not spec-clean, on the same reasoning as the two shapes above. A singleton slot is never padding, whatever sits beside it. §2.6.2.3 states the singleton encoding positively ("If the primitive has an id attribute or extension, but no value, only the property with the_is rendered"), so a value-absent singleton is{"_status":{…}}and both{"status":null}and{"status":null,"_status":{…}}are reported. And the set this walks is what the reader read as a primitive, not what FHIR types as one: the model is schema-free, so a barenullat any singleton property reaches the primitive branch whatever the element's FHIR type would be, and{"subject":null}on anObservationis reported here rather than as an unexpected property. Only an array item, and an item of a_-sibling'sextensionarray, reach the complex branch. What it deliberately does not do, stated rather than implied. It does not refuse. Anullis a non-conformant encoding of an absent value, not content the reader could not read, so unlike an array inside an array or dropped element text there is nothing unreadable at the position, andvalidateResourceandsafeToSummarizeare unchanged; refusing would also withdraw round trips that work today, and nothing that round-trips today stops (a value-absent primitive written the conformant way carries nonull, so it is never marked). It is scoped to the value channel: a_-sibling that is itself not an object ("_status":null) is the neighbouring position and drawsUNKNOWN_PROPERTYinstead, on the reasoning above. No case has ever moved between the two codes, because that channel drew nothing at all until it was closed. AndserializeResourceXmldoes not carry it: XML has nonull, so it refuses rather than emit the empty element (seeUNSERIALIZABLE_JSON_ONLY_SHAPEbelow). - A primitive whose value is written as XML element text is reported, not silently read as an
absent value. FHIR XML carries a primitive's value in the
valueattribute, so<status>entered-in-error</status>puts a code where the model has no slot for one: the character data is dropped and the element is left holding nothing. That is the same harm as an array inside an array, reached through the other wire format, and it is the sharper one, because the shape a retraction takes is an affirmation. Measured: a<status>entered-in-error</status>read back as a live record, anAllergyIntolerancethat lost itsrefuted, and adoseQuantitythat lost the dose number while itsmgunit and UCUM code survived, all undervalid: true. So the position is named:DROPPED_ELEMENT_TEXTinvalidateResource(error), the locations indroppedText,safeToSummarizeisfalse, andassertSafeToSummarizethrows.isDroppedTextmarks the node for a consumer walking the model directly, and the reader's existingUNEXPECTED_XML_CONTENTwarning is kept alongside it rather than replaced. Like the rule above it needs no cardinality table and cannot fire on a conformant document, so it runs at every position the model has a node for, at every depth, and never on a document read from JSON. The text is not read back as the value, deliberately. Recovering it would be a tolerance for a non-conformant encoding rather than a report of one, and this library encodes a tolerance only when a real document shows the shape in the wild. So the value stays unread and the verdict is a refusal rather than a repair. Two limitations, stated rather than implied. Whitespace between elements is not character data in this sense and is untouched, so ordinary indented XML is unaffected; but text written beside a value that did arrive (<status value="final">entered-in-error</status>) is dropped too and draws the same refusal. The rule is keyed on the reader dropping character data, not on the text differing from the value: the reader never compares the two, so<status value="final">final</status>refuses as well, even though nothing is missing there. That is deliberate. Deciding the document meant no harm would mean reading the text, which is the tolerance this half does not take. - A boolean value outside the datatype's lexical space is reported, not read as an absent
instruction. R4 spells a
booleanastrueorfalseand nothing else, so<doNotPerform value="1"/>andvalue="Y"(ordinary v2 and C-CDA converter output, which is how a great deal of data reaches a FHIR surface) carry no boolean this library may read. They are notfalse; they are unreadable, and the difference is the whole point: measured, avalue="1"read back exactly likevalue="0", so a prescriber's "yes, do not administer" and another's "no" produced the same answer, with nothing on any channel to say a value had been written and dropped. So the position is named: the locations inunreadableBooleans,safeToSummarizeisfalse, andassertSafeToSummarizethrows. The value is still not read, deliberately: coercing"1"or"Y"would invent a reading the spec does not license, and it would turnvalue="0"into a JSfalsethatserializeResourcethen emits, laundering an authored value across a format change. The channel coversdoNotPerform, the onlybooleanreadSafetytakes off a document, on any resource type. Its window is every resource root (so acontainedorBundle.entryorder counts), which is the negation read's own window: the value that is read and the value that cannot be read are decided together, in one pass, so neither can cover a document the other does not. It is not the whole ofarrayWrappedScalars' window, and the gap is a declared residual rather than a nicety -- that report's element-level half stays on the resource types this library models a cardinality for, so{"resourceType":"ServiceRequest","doNotPerform":[true]}is read here and onnegations, while the array wrapper it arrived in draws noARRAY_WRAPPED_SCALAR. A primitive carrying onlyid/extensionand no value is untouched, since nothing was written there to be unread, so this cannot fire on a conformant document in either wire format. Unlike the five findings above it, this one raises noValidationIssueof its own, for a narrow and measured reason rather than a general one: the request resources that definedoNotPerformhave no built-in schema, so the validator is silent about this element's datatype unless a caller supplies one, and the readout has to hold either way. The safety layer knows the datatype unconditionally, which is why the report lives there. - A code that spells a negation bar its case or its surrounding whitespace is reported, not read as
one. A negation is matched by its exact
code, because FHIRcodeis case-sensitive and the datatype's lexical space has no room for surrounding whitespace ([^\s]+(\s[^\s]+)*). So{"resourceType":"Procedure","status":"NOT-DONE"}(an upper-casing source system) and astatusof" not-done"(a padded fixed-width or CSV extract) assert no negation, and that reading is correct and unchanged: folding them in would accept a non-conformant document as though it were conformant and hand you a negation the sender never spelled. What was wrong was that the refusal was silent. Measured: every such document returnednegations: []undersafeToSummarize: true, with nothing on any channel to say a value had been looked at and declined, so a caller doing exactly what this readout says to do (branch onnegations, not on the raw status string) read a procedure recorded as"NOT-DONE"as a procedure with nothing to say about it. So the position is named: the locations innearMissNegationCodes,safeToSummarizeisfalse, andassertSafeToSummarizethrows. The value is still not coerced, trimmed or case-folded. Unlike the location channels above it nothing is dropped at parse time either: the value is in the model, at the element the location names, and what the library declines is the classification. That is not a promise the value reaches a convenience field, and the difference bites in two ordinary shapes:status/verificationStatusare root-scoped and single-valued while this channel is document-wide, so a near miss insidecontainedor aBundle.entryleaves them holding the root's value orundefined; andverificationStatussurfaces the preferred-system coding, so a near miss in a second coding is not the code it shows. Walk the model at the location. A near miss is suppressed where the same element also spells that code exactly, since the negation is then classified: R4 permits translation codings beside the one from a required binding's value set, so averificationStatuscarryingrefutedfrom the standard system andREFUTEDfrom a local one is conformant and draws nothing. Suppressed per code, so a near miss of a different code at that element still reports. The elements are thecode-valued ones the negation read looks at,statusandverificationStatus, at every resource root, which is the negation read's own window. The pairs come from the same table the matches are made from, so the report cannot cover a pair the read does not.AllergyIntolerance.codeis deliberately outside it: SNOMED716186003"no known allergy" is a positive assertion whose read is root- and type-scoped, and disclosing a near miss at every root would report the miss where an exact hit is read by nothing. Like the boolean channel above, it raises noValidationIssueof its own, so it cannot movevalidin either direction. Empty on every conformant document read from JSON, bar one shape admitted rather than claimed away: aCodeableConceptmay carry translation codings beside the one a required binding's value set supplies, and R4 asks only that one coding come from that set, so a translation whose code differs from a negation code only by case is conformant under that reading and is disclosed. Only the case half can be: a surrounding-whitespace value is outsidecode's lexical space whatever coding carries it. Over-disclosure is the fail-safe direction. In XML the whitespace half is a declared limit rather than a claim: R4 derivescodefromxs:token, whosewhiteSpace=collapsefacet strips surrounding whitespace before validation, so<status value=" not-done"/>is schema-valid and a schema-validating consumer reads it as the code. This reader is schema-free and does not collapse, so it discloses rather than reads, which is the fail-safe direction. - Content written where a
codebelongs is reported, not read as an absent element. FHIR JSON spells acodeas a JSON string, so{"resourceType":"Procedure","status":{"value":"not-done"}}(a generic converter carrying FHIR XML'svalueattribute across as a member) and astatuswritten as a number or a boolean hold no code this library may read. Measured, every one of them returnednegations: []undersafeToSummarize: true, so a procedure recorded as not done was indistinguishable from one that was carried out. This is a shape, which is why the two channels above it did not catch it: both ask about a written value, and an object holds no value at all, so both answered "no" about it truthfully and the element read exactly like one the sender left out. So the position is named: the locations inunreadableNegationCodes,safeToSummarizeisfalse, andassertSafeToSummarizethrows. Nothing is read through the position, deliberately:{"value":"…"}is FHIR XML's spelling of a primitive, so descending into it would resolve a negation out of an encoding no version of FHIR defines for JSON. The element isstatus, at every resource root, which is the negation read's own window; the complement is carried in the same table the matches are made from and applied in the same loop, so it cannot cover an element the read does not. Two datatypes reach a rootstatus, and the question asked is about the shape rather than about which read succeeded, so that both are cleared: R4 spellsstatusacodeon the overwhelming majority of types and aCodeableConceptonMedicinalProductAuthorizationandSubstanceSpecification, R5 adds several more including a mandatoryDeviceAssociation.status, and DSTU2 spells every one acode. So a complex all of whose members FHIR spells here (coding,text,id,extension) is left alone, whether or not a code came out of it, while any member outside that set is reported:{"status":{"id":"s1","value":"not-done"}}is the same converter output and is reported too. An object with no member at all is reported as well,ele-1requiring an element present in a resource to carry a value, children, or an extension. Keyed instead on "no string was read", this would refuse the published R4MedicinalProductAuthorizationexample. The converse is a declared limit: a code buried under{"coding":{…}}at a type whosestatusis acodestays silent.verificationStatusis deliberately outside it for a related reason: its shape complement is a primitive at the element, andCondition.verificationStatusis acodein DSTU2, so the same rule would report a conformant DSTU2 document.AllergyIntolerance.codeis outside it for the reason that keeps "no known allergy" root- and type-scoped. Like the two channels above it this one raises noValidationIssue, so it cannot movevalidin either direction. Empty on every conformant document this library has been measured against, in either wire format: the XML reader models avalueattribute besideidandextensionchildren as a primitive, so a conformant<status value="not-done"><extension …/></status>is read; and a primitive whose value is absent is untouched, that being the conformantdata-absent-reasonshape and content the read never stepped over. - Neither writer will re-emit a document the reader MARKED (
FhirSerializeError, codeDROPPED_ELEMENT_TEXT). Say "marked", not "whose text was dropped": character data that isString.trim()-empty is dropped with no flag, no marker and no finding, so a<status>holding only whitespace still emits<status/>and still re-reads clean. That gap is real, unchanged here, and noted below. This is the other half of the refusal, and it exists because the finding used to disappear across a round trip.serializeResourceXmlemitted<status/>and a re-read of that output came back clean;serializeResourcewas worse, dropping the member outright, so a retractedObservationre-read as one that had never named a status. The error is value-free and carries the bounded FHIRPathlocationsit refused over, never the text it could not encode. Be precise about why<status/>is not a neutral fallback: xml.html §2.6.1 says "FHIR elements are never empty. If an element is present in the resource, it SHALL have either a value attribute, child elements as defined for its type, or 1 or more extensions", so emitting it violates that SHALL. The refusal is scoped to a model the reader MARKED, and to nothing else. A document read from JSON has no character-data channel and is never affected; a conformant XML document round-trips byte-for-byte exactly as before. In particular, writing a value-absent primitive that carries no extension is still permitted and still emits<status/>: §2.6.1's third arm ("or 1 or more extensions") is satisfied by<status><extension url="..."/></status>, which is what adata-absent-reasonemits, but anid-only primitive (<status id="s1"/>) has none of the three permitted contents and remains a pre-existing violation this change does not address. Keep the original document if you need the text itself; the library will not invent it for you. - Fail-closed on an unknown
modifierExtension(UNHANDLED_MODIFIER_EXTENSION, error): FHIR's?!rule; andentered-in-errorsurfaced asRETRACTED_RESOURCE(retracted, not data). - Invariants
ait-1/ait-2,con-3/con-4/con-5,obs-6/obs-7, hand-evaluated from their exact R4 FHIRPath by the always-on safety layer. This layer surfaces and enforces. It never reconciles contradictions or infers clinical meaning. Every other profileconstraint[]invariant is evaluated by the FHIRPath engine (below).
And Quantity / UCUM fidelity: read a measured value by the type it actually is, and its unit by the
UCUM code a machine may act on (never the display string, and never converted):
import { parseResource, readObservationValue, validateResource } from "@cosyte/fhir";
// value[x] is an 11-way choice: a non-numeric result is never read as a number.
const { resource: titer } = parseResource(
'{"resourceType":"Observation","status":"final","valueString":"POSITIVE"}',
);
const v = readObservationValue(titer);
v?.type; // → "String" (NOT "Quantity")
v?.quantity; // → undefined (no number is fabricated)
// A vital sign's unit is checked on the UCUM code, case- and bracket-exact: "mmHg" is not "mm[Hg]".
const { resource: bp } = parseResource(
'{"resourceType":"Observation","status":"final",' +
'"category":[{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/observation-category","code":"vital-signs"}]}],' +
'"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]},' +
'"valueQuantity":{"value":120,"unit":"mmHg","system":"http://unitsofmeasure.org","code":"mmHg"}}',
);
validateResource(bp).issues.map((i) => i.code); // → ["VITAL_SIGN_UNIT_NONCONFORMANT"] (should be "mm[Hg]")readObservationValuediscriminates the 11value[x]variants (Quantity,CodeableConcept,String,Boolean,Integer,Range,Ratio,SampledData,Time,DateTime,Period) by the one present.quantityis populated only for aQuantity.readQuantitykeeps the coded unit (code/system) distinct from the humanunit;validateUcumShapechecks a code's shape.- Vital-signs required-unit conformance (
VITAL_SIGN_UNIT_NONCONFORMANT, error) against the FHIR profile's closed table, compared on the UCUMcode; a UCUM-declared unit that is absent or malformed isUCUM_UNIT_UNRECOGNIZED(warning, preserved verbatim); a vital sign whose value is not a Quantity isVALUE_TYPE_UNEXPECTED(warning). - Dose
Quantity(readMedicationDoses) for MedicationRequest/Statement, andinterpretation/referenceRangesurfaced (readInterpretations/readReferenceRanges): never used to auto-convert a unit or compute an abnormal flag.
And terminology binding validation, strength-aware and content-free: validate a coding's code
system and its binding strength without bundling any SNOMED / CPT / LOINC concept tables, and
never raise a false error when no terminology service is configured:
import { parseResource, validateResource, type TerminologyService } from "@cosyte/fhir";
// AllergyIntolerance.code binds extensibly to a multi-system value set (RxNorm + SNOMED). An
// ICD-10-CM code is a KNOWN but unexpected system for this binding → a warning, never an error.
const { resource: allergy } = parseResource(
'{"resourceType":"AllergyIntolerance",' +
'"clinicalStatus":{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical","code":"active"}]},' +
'"code":{"coding":[{"system":"http://hl7.org/fhir/sid/icd-10-cm","code":"T78.40XA"}]}}',
);
validateResource(allergy).issues.map((i) => `${i.code}/${i.severity}`);
// → ["RESOURCE_NOT_MODELED/information", "CODE_SYSTEM_UNEXPECTED/warning"] (valid stays true)
// Value-set membership needs content the library does not bundle. Supply a terminology service.
const svc: TerminologyService = {
validateCode: ({ code }) => ({ membership: code === "7980" ? "in" : "not-in" }),
};
validateResource(allergy, { terminology: svc }); // now membership is checked against your service- Frozen known-systems registry (
KNOWN_SYSTEMS,isKnownSystem): the verifiedsystemURIs (LOINC, SNOMED, RxNorm, ICD-10-CM/9-CM, CPT, UCUM, NDC, CVX) as identities, not content. An unrecognized system isCODE_SYSTEM_UNKNOWN(information): not a defect, just unvalidatable. - Binding-strength severity:
required→ error,extensible→ error-unless,preferred→ warning,example→ information (an example binding never errors). A known system outside a binding's value set isCODE_SYSTEM_UNEXPECTED(strength-scaled); a service's definitivenot-inisCODE_NOT_IN_VALUESET. Built-in multi-system bindings: allergy substance (RxNorm + SNOMED), medication (RxNorm). - Pluggable terminology service (
TerminologyService): the one seam for value-set content, and none is bundled (licensing). With none supplied, checks degrade to the content-free system level and never false-error; the service receives only identities, never a resource value.
And StructureDefinition-driven profile validation (US Core the target): snapshot generation,
slicing, fixed[x] / pattern[x], and must-support as a system obligation. Like the terminology
layer it ships the engine, not the content: you supply the profiles (the published US Core /
vendor StructureDefinitions), and nothing is bundled.
import { loadStructureDefinition, parseResource, validateResource } from "@cosyte/fhir";
// Load a US Core profile (its published JSON) into a StructureDefinition.
const profile = loadStructureDefinition(parseResource(usCoreAllergyProfileJson).resource);
const { resource: allergy } = parseResource(
'{"resourceType":"AllergyIntolerance",' +
'"clinicalStatus":{"coding":[{"code":"active"}]},' +
'"code":{"coding":[{"code":"227493005"}]},"patient":{"reference":"Patient/1"}}',
);
// verificationStatus is must-support and absent → information, NEVER an error (the resource stays valid).
const { issues, valid } = validateResource(allergy, { profiles: profile ? [profile] : [] });
valid; // → true
issues.map((i) => `${i.code}/${i.severity}`); // → ["MUST_SUPPORT_ABSENT/information", …]- Snapshot generation (
generateSnapshot/snapshotElements) walksbaseDefinitionand merges the differential onto the base snapshot: tightening matched elements by id, inserting slices, and failing closed (FhirProfileError) on an unresolvable base or abaseDefinitioncycle. A caller supplies the base via a resolver; a profile that already ships a snapshot is used as-is. - Slicing matches each occurrence of a sliced element to a slice by its discriminators. The R4
set is
value | exists | pattern | type | profile(positionis R5-only and excluded). What needs a FHIRPath engine (type/profilediscriminators, reslicing) is reportedPROFILE_SLICE_UNCHECKED(information): never silently passed. An unmatched occurrence underclosedslicing isPROFILE_SLICE_UNMATCHED(error); a missing required slice isCARDINALITY_MIN. fixed[x]vspattern[x](matchesFixed/matchesPattern):fixedis exact equality (nothing extra),patternis a subset (extras allowed); decimals compared precision-exactly, never via a float. A mismatch isPROFILE_FIXED_MISMATCH/PROFILE_PATTERN_MISMATCH(error).- Must-support is a system obligation, not instance-presence: an absent must-support element is
MUST_SUPPORT_ABSENTatinformation, never an error. A strict client that rejects an absent must-support element is the classic interop bug this rule exists to prevent. - Multi-version: a
meta.profilecanonical|versionpin the supplied set carries at a different version isPROFILE_VERSION_MISMATCH(warning) rather than a silent best-effort validation. - Invariants: the profile's
constraint[](FHIRPath) are evaluated by a bounded, vendored FHIRPath engine (tokenize/parseFhirPath/evaluateInvariant; no runtime dependency). A violated constraint isINVARIANT_VIOLATED(severity mirroring itserror|warning); an expression outside the subset raisesUnsupportedFhirPathErrorand is reportedINVARIANT_UNCHECKED(information): surfaced, never assumed to pass. The seven named safety invariants stay owned by the always-on safety layer; the engine covers every other constraint. - Deferred: the bundled multi-version US Core IG corpus and the
validator_cli.jardifferential (a JVM dev/CI job); thetype/profileslicing discriminators and reslicing (stillPROFILE_SLICE_UNCHECKED: a genuine fail-safe deferral, they need per-occurrence type carriage / recursive profile resolution). Every finding is value-free (a code + a FHIRPath location, never the value).
import { evaluateInvariant, parseResource } from "@cosyte/fhir";
// The bounded FHIRPath engine, judged by the reference validator's boolean coercion.
const { resource } = parseResource(
'{"resourceType":"Observation","valueString":"x","dataAbsentReason":{"text":"n"}}',
);
evaluateInvariant("dataAbsentReason.empty() or value.empty()", resource, resource);
// → { unchecked: false, satisfied: false } (obs-6 violated: value AND dataAbsentReason both present)
evaluateInvariant("descendants().count() > 0", resource, resource);
// → { unchecked: true, satisfied: false } (descendants() is outside the subset, never a false pass)Authoring a profile in code: defineProfile(). You don't have to hand-write
StructureDefinition JSON. defineProfile(spec) builds one from an ergonomic spec and returns the
same model loadStructureDefinition produces, so it flows straight into
validateResource({ profiles }). There is one authoring path, no privileged internal shape: the
built-in starter profiles are defineProfile() calls, exactly what you write. As a conservative
writer it throws a value-free InvalidProfileError on an author mistake (a missing url / type /
element path, a bad cardinality, a max below min).
import { defineProfile, parseResource, primitive, validateResource } from "@cosyte/fhir";
const finalOnly = defineProfile({
url: "https://example.org/StructureDefinition/final-observation",
type: "Observation",
differential: [
{ path: "Observation.status", fixed: { type: "Code", value: primitive("final") } },
],
});
const { resource } = parseResource('{"resourceType":"Observation","status":"preliminary"}');
validateResource(resource, { profiles: [finalOnly] }).issues.map((i) => i.code);
// → ["PROFILE_FIXED_MISMATCH", …]A publishable profile starter kit ships as worked examples / templates you extend:
VITAL_SIGN_OBSERVATION_STARTER (required status, must-support code, and a sliced category:
a required VSCat slice pins the vital-signs coding while the open slicing still allows other
categories, the way the real profile does) and PATIENT_IDENTIFIER_STARTER (identifier / .system
/ .value required + must-support, deliberately no MRN slice), plus STARTER_PROFILES,
starterProfile(url), and STARTER_PROFILE_BASE_URL. Each is grounded in a public FHIR / US Core
spec page, self-contained (differential-only, no bundled base), and clearly a template, not an
authoritative vendor conformance statement.
import { STARTER_PROFILES, parseResource, validateResource } from "@cosyte/fhir";
const { resource } = parseResource(vitalSignObservationJson);
validateResource(resource, { profiles: [...STARTER_PROFILES] });- Real-world quirk corpus + differential. Five quirk fixtures
(
test/__fixtures__/quirk-*.json), each grounded in a public artifact and cited intest/quirk-corpus.test.ts: a non-firstresourceType(json.html), a scientific-notation decimal preserved byte-exact (Synthea #675), a primitive-extension_-sibling misalignment that fails closed (HAPI #5738), a searchset Bundlelink[next]that survives the round-trip (bundle-example.json), and US Core race + birthsex extensions preserved on a base Patient. Thevalidator_cli.jardifferential (CI-only) runs over this corpus too. Values are synthetic; a genuinely vendor-proprietary deviation absent from every public sample stays grounded-only. It is never invented. Missing-must-support and version-drift quirks are covered by the profile suite.
A zero-dependency FHIR XML codec that reads and writes the same schema-free model as the JSON
codec, so a resource is equivalent whichever wire format it arrived in. The hand-written reader is
XXE- and billion-laughs-proof by refusal: it refuses any <!DOCTYPE (a DTD is the only place XML
can declare an entity) and any entity reference beyond the five predefined names and numeric character
references, performs no I/O, resolves no URI, and bounds nesting depth. Adversarial input is a typed
FhirXmlError, never a hang, OOM, fetch, or crash.
parseResourceXmlreturns the sameReadResult({ resource, issues }) asparseResource, mapping the FHIR XML conventions (element name →resourceType,valueattribute → primitive value kept as its lexical string,id/extensionco-located, repeated elements → a list, resource-valued elements unwrapped, narrativeNarrative.divcarried opaquely as its full XHTML string, the FHIR JSON representation, so it round-trips as<div>…</div>, never dropped). Lenient: an unexpected namespace or stray text drawsUNEXPECTED_XML_CONTENTand the document is never rejected. Lenient is not lossless there: an element in another namespace is modeled and flagged, but character data written directly on a FHIR element is dropped and flagged, because a FHIR element carries its value in thevalueattribute and the model has no slot for text. Names are namespace-resolved, so a prefix is a spelling and not part of the name. FHIR XML is defined in thehttp://hl7.org/fhirnamespace, and a document may bind that namespace to a prefix instead of making it the default, so<f:Patient xmlns:f="http://hl7.org/fhir">and<Patient xmlns="http://hl7.org/fhir">are the same resource and read to the same model. The in-scope declarations are tracked as the reader descends, including a prefix rebound partway down. A prefix nothing in scope binds is not resolvable, so the tag is kept exactly as written and flagged rather than guessed at. The narrative<div>is the one element FHIR requires in a namespace other than its parent's, so it is recognised by its expanded name ({http://www.w3.org/1999/xhtml}div) under every spelling, and is not flagged for being there. It is carried as an opaque string together with the namespace declarations it inherited from its ancestors and uses, so the fragment stands on its own; the document's own spelling is preserved rather than rewritten, which makes a prefixed narrative namespace-equivalent to the default spelling and not byte-identical to it. Adivin another namespace is kept out ofNarrative.divonly where its tag carries a prefix: an unprefixed<div xmlns="urn:vendor">is spelled exactly like the FHIR one, so it reaches that slot and is reported, not separated. The narrative is recognised before a resource-valued element is unwrapped, because the content ofNarrative.divis XHTML and the unwrap's UpperCamelCase test is a way of spelling a FHIR resource type: applied inside a narrative it read<div xmlns="…xhtml">Take 5 mg<BR/></div>as a containedBRresource and destroyed the prose, and HTML-4-era generators do emit<BR>,<TABLE>,<P>. Nothing is shadowed by that order:divnames exactly one element in R4. Reading the narrative means its contents are no longer modeled as FHIR, so a narrative spelled with a prefix, or holding a capitalized child, reads as the same document written the other way reads, including where that is quieter. The one way it reads differently is louder: a document holding the narrative under both spellings at once is one element written twice, so it drawsMIXED_XML_SPELLING, which the all-default twin does not. Every element the reader models is tested once for being in a namespace other than its parent's, and reported when it is. A prefixed one additionally keeps its tag, and since no FHIR element is spelledv:code, that is what keeps it out of the FHIR element beside it. Content reached by a default declaration (<extension xmlns="urn:vendor">) is spelled exactly like the FHIR element, so it is modeled as one and reported rather than separated. A child element written beside avalueattribute is not modeled at all: it is discarded and reportedUNKNOWN_PROPERTY, so a foreign one there draws no namespace report. Two prefixes bound to the same namespace are two spellings of one name, so an element written twice that way reads as the repeat it is. The model matches the same document spelled one way; only the number of occurrences differs, so that element carriesMIXED_XML_SPELLING. Nothing is lost, but a check that reads a0..1element as a single value gets nothing from a repeat, and that should never be silent. That report compares the expanded name, not the tag alone, so it also covers the merges where the tag is the same and the namespace is not. Two of those are worth naming because a document can reach them while otherwise reading as conformant: a prefix rebound between siblings (<p:x xmlns:p="urn:a"/>beside<p:x xmlns:p="urn:b"/>), and a<div/>in the FHIR namespace landing inNarrative.divbeside the real XHTML narrative, which is the one that costs the most becauseNarrative.divis0..1. A foreign element reached by a defaultxmlnsre-declaration groups with its FHIR namesake the same way, and there the group already carriedUNEXPECTED_XML_CONTENT. A FHIR-namespace one carries no such flag, which is exactly why this report is the one that covers the narrative case.serializeResourceXmlemits compact FHIR XML that round-trips a spec-clean document byte-for-byte (decimals byte-exact, never through anumber). Its output is not unconditionally spec-clean: a prefixed name is written with no declaration to bind it and a non-conformant name verbatim, so<v:x value="1"/>,<a&b/>and<1abc/>are all emitted, and the byte-for-byte claim is scoped to a spec-clean input (a<div>x</div>carrying no XHTML namespace comes back as<div xmlns="http://hl7.org/fhir">x</div>, the FHIR namespace rather than the XHTML one the conformant repair would use). It throwsFhirSerializeErrorrather than emit a model the reader marked as having lost character data, so that finding cannot vanish across a round trip;serializeResourcerefuses the same models for the same reason. Text the reader drops without marking (whitespace only) is not covered, because there is no marker.- A
divproperty is written back as raw markup, and that markup is checked at the branch that writes it (UNSERIALIZABLE_DIV_MARKUP).Narrative.divis carried as an opaque XHTML string and emitted verbatim, so whatever the string spells becomes markup in the output. It is written only when it parses as exactly one element whose local name isdiv; anything else is refused, because a string that closes its own element and opens siblings puts elements into the document that the sender never wrote. The shape that decided it: adivon anAllergyIntolerancespelled<div xmlns="…xhtml">ok</div></text><code><coding>…716186003…</coding></code><text>used to emit spec-clean FHIR XML that re-read withnoKnownAllergy: trueand ano-known-allergynegation over a record that had asserted nothing, with no diagnostic at either end. Well-formedness alone is not the line:<status value="final"/>is one well-formed element, and writing it for a property nameddivauthors a status.serializeResourcecarries the string as a string and is the route that stays open. Passing the check is not a claim that the round trip is lossless from there: a root whose prefix nothing binds is accepted and re-reads as a different property, the same unbound-prefix gap named above for element names. - A shape only FHIR JSON can spell is refused rather than emitted as an empty element
(
UNSERIALIZABLE_JSON_ONLY_SHAPE). The JSON reader marks four positions FHIR JSON gives no meaning to and keeps what the sender wrote there, soserializeResourcehands it back and re-reading the output reproduces the finding: an array inside an array, a scalar ornullwhere FHIR JSON has an object, that same shape in a primitive's_-sibling, and anullin a primitive's value channel that padded nothing. XML has none of those channels: no array of arrays, no_-sibling (a primitive's metadata is co-located as anidattribute and child<extension>elements), and nonullat all. So this writer used to emit the node the reader was left holding, and that output re-reads with an empty issue list, and three of the four alsovalid: true.{"value":null,"unit":"mg"}came back through XML as aQuantitycarrying a unit and no magnitude, with an empty issue list;{"name":[[{"family":"Roe"}]]}came back with the name gone andsafeToSummarizeflipped fromfalsetotrue. Refusing, because there is nothing to hand back into and inventing an XML spelling would author markup nobody wrote. Only a model read from JSON reaches it: XML cannot write any of those shapes, so a document read from XML carries no marker and nothing that round-trips today stops.serializeResourcewrites all four back at every position that writer walks, and the re-read reproduces the finding. It does not walk a member a repeated property name shadowed, and this refusal does reach one, so that is the refusal's own limit rather than a route the shape always survives;DUPLICATE_PROPERTYand the safety refusal are what carry such a document, which is refused outright now rather than narrowed. Value-exact, not byte-exact, which is the same limit the preserved text carries everywhere else in this README:{"performer":[{"reference":"Practitioner/1"},"Practitioner\/2"]}comes back with the second member spelled"Practitioner/2", the same string in different bytes. Only the value-channelnullfamily is byte-identical, because anullhas no escaping to lose. That the route stays open is a statement about these shapes, not about the whole model. Not closed by it, and pinned rather than implied: a JSON decimal comes back from XML as a string because XML carries no JSON type. - An array wrapper XML cannot spell back is refused rather than flattened away
(
UNSERIALIZABLE_ARRAY_WRAPPER). FHIR JSON writes a single-valued element as a name/value pair and reserves the array for a repeating one (json.html §2.6.2.2), so{"status":["entered-in-error"]}is a shape the spec does not define: it is reported as an error-severityARRAY_WRAPPED_SCALARandsafeToSummarizeisfalseover it, because a single-value read finds no string in it at all. FHIR XML spells a repeat by repeating the element and carries no other mark for one, so a wrapper of fewer than two items used to emit at most one element and re-read as an ordinary single-valued element: that document came back witharrayWrappedScalars: [],safeToSummarize: true,valid: trueand an empty issue list, and a wrappedresourceTypecame back as<Resource>, the type gate every type-scoped read stands behind gone with it. A writer cannot decide cardinality in general and this one does not try: there is no per-resource model here, and a name-only rule would emit a false error on a conformant document (Questionnaire.codeandElementDefinition.codeare0..*in R4). So this takes its cardinality from the one window that already has one, the locationsarrayWrappedScalarsreports, and inside it refuses the wrappers XML cannot write back as a wrapper: fewer than two items, plus any wrapper onresourceType, where the type is the tag and a tag cannot be repeated. A wrapper of two or more items elsewhere is deliberately left alone: it is written as repeated elements, the re-read groups them into a list and the location is reported again, so refusing it would withdraw a round trip that works today and keeps the finding. That is a statement about which wrappers this refuses, not a claim that every wrapper it lets through survives.serializeResourcewrites the wrapper back and is the route that stays open. Not closed by it: a wrapper that only a shadowed member carried is the repeated-property-name case rather than this one, and both writers now refuse it (on whichever of the two codes is raised first, which is this one where the wrapper is itself unspellable); and the window does not reachObservation.value[x], a0..1choice whose wrapper still launders. - A member a repeated property name shadowed is refused, by BOTH writers
(
UNSERIALIZABLE_SHADOWED_PROPERTY). The reader keeps it, validation raises an error over it andsafeToSummarizeisfalse: all three about the input. Each writer walks the surviving members only, so{"resourceType":"Observation","status":"final","status":"entered-in-error"}used to come back as{"resourceType":"Observation","status":"final"}and<Observation xmlns="http://hl7.org/fhir"><status value="final"/></Observation>: both re-read with an empty issue list,validandsafeToSummarizebothfalse → true, and the retraction in neither output. Which member is lost depends only on the order the sender wrote them in. Handing both back is not the alternative it looks like:JSON.parseresolves a repeated name last-wins where this library reads first-wins, so emitting both members hands every other consumer the member this one calls shadowed. XML can repeat an element, but two repeated elements re-read as a list, a repeating element nobody wrote. The window isshadowedProperties, the same call validation raises its error from, so a model refused here already readsvalid: false. Not closed by it, and measured rather than implied: a repeated name inside a primitive's_elementmetadata is not modeled at all, and one inside a complex in a primitive'sextensionis still dropped by both writers, and that document readsvalid: true, so refusing it would withdraw a round trip from a model this library reports as clean. - A
resourceTypewith no string in it is refused rather than deleted and the tag substituted (UNSERIALIZABLE_RESOURCE_TYPE). FHIR XML has noresourceTypeelement: the type IS the tag (xml.html). So this writer skips that property at every element it walks and takes the tag from the property's string value, and where there is no string to take the root used to fall back toResource.{"resourceType":{"modifierExtension":[{"url":"http://example.org/x"}]},"status":"final"}readsRESOURCE_TYPE_UNKNOWNat error severity withvalid: false, andsafeToSummarize: falsefor the unhandled modifier extension the type gate carries; it came back as<Resource xmlns="http://hl7.org/fhir"><status value="final"/></Resource>, which re-reads with an empty issue list,validandsafeToSummarizebothtrue, and no modifier extension anywhere. The property is gone from the output and the element claims a type nobody wrote. Neither repair is available: writing<Resource>is what launders, and it authors the type gate every type-scoped safety read runs behind; coercing the value to a string authors a different type out of content the sender wrote at another shape. The predicate reads the FIRSTresourceTypean element wrote, because that is the one the writer names the tag from. Two shapes are deliberately left: an element that wrote noresourceTypeis untouched, because a typeless complex is namedResourceby documented fallback and nothing is deleted there; and an element whose first one IS a string keeps its tag, so the substitution never happens and what drops there is the repeated-property-name case. The bound is structural rather than a verdict: at the root this costs a round trip only for a model alreadyvalid: false, but deeper no layer checks a nested element's type and a document read from XML reaches it, so what is withdrawn at every refused location is a deletion rather than a round trip.serializeResourceemits a non-stringresourceTypethrough its ordinary path and is the route that stays open. Not closed by it: a JSON decimal still comes back from XML as a string, andObservation.value[x]is still outside the array-wrapper window. nodesEquivalentis the JSON↔XML equivalence oracle, equal modulo the two irreducible schema-free ambiguities and only those: primitive lexical form (JSONtrue/number tokens ≡ XMLvalue-attribute strings) and singleton lists (an array-of-one ≡ a single repeated element).
import {
parseResource,
parseResourceXml,
serializeResource,
serializeResourceXml,
nodesEquivalent,
} from "@cosyte/fhir";
const xml =
'<Patient xmlns="http://hl7.org/fhir"><active value="true"/>' +
'<name><given value="Jane"/></name></Patient>';
const fromXml = parseResourceXml(xml).resource;
const fromJson = parseResource(
'{"resourceType":"Patient","active":true,"name":[{"given":["Jane"]}]}',
).resource;
nodesEquivalent(fromXml, fromJson); // true: equivalent, not identical (see the two moduli above)
serializeResourceXml(fromXml) === xml; // true: spec-clean round-trip
// Equivalent is not identical, and re-serializing to JSON shows both moduli at once: `active` is
// the string "true" (XML carries every primitive as attribute text and the reader is schema-free,
// so a decimal is likewise its exact text where JSON gives a FhirDecimal), and `name` / `given` are
// single nodes rather than arrays. Precision survives either way, and `readQuantity` reads a
// magnitude in either form, but the datatype validator reads a lexical boolean as a type mismatch.
serializeResource(fromXml); // → '{"resourceType":"Patient","active":"true","name":{"given":"Jane"}}'
// The reader refuses an XXE / entity-expansion attack loudly, never resolving or expanding it:
parseResourceXml('<!DOCTYPE x [ <!ENTITY e SYSTEM "file:///etc/passwd"> ]><Patient/>');
// throws FhirXmlError { code: "DTD_FORBIDDEN" }Read a Bundle into an explicit readout with the one semantic distinction a consumer must never blur
(transaction is all-or-nothing, batch is independent), resolve the references inside it with a
DoS-safe cycle guard, and stream a Bulk Data $export line by line with per-line error isolation
and no whole-file load. The Bundle artifact and its semantics are modeled; a transaction is never
executed (there is no server here).
readBundle/entryProcessing/isAtomicBundle: theBundle.type(BUNDLE_TYPES) and its entry-processing contract:transaction→"atomic"(all-or-nothing),batch→"independent", everything else →"none".Bundle.totalis a lexical string, never a JSnumber.resolveReference/buildBundleIndex/containedIndex: resolve relative / absolute / logical /#fragmentreferences against a Bundle +containedclosure. A local miss is"unresolved"(flagged, preserved); an external target is"external"(never false-flagged).hasContainedCycle/MAX_REFERENCE_DEPTH, a bounded, iterative (heap-based) cycle guard: acontainedreference cycle is detected and reported, never followed: no infinite loop, no stack blow-up, no false positive on a legitimate DAG.streamNdjson/parseNdjsonLine: a streamingapplication/fhir+ndjsonreader over any chunk iterable, one resource per line, each read through the precision-preserving codec (a decimal never through anumber). A malformed line is isolated (reported by line number, never content), the stream continues, and memory stays bounded (LINE_TOO_LONG).- New findings (in
validateResourcefor aBundle):REFERENCE_UNRESOLVED(warning, preserved),CONTAINED_CYCLE(error),FULLURL_ID_MISMATCH(error: aurn:uuidfullUrl is exempt). All value-free (a FHIRPath location, never a value, reference, or id).
import { parseResource, readBundle, validateResource, streamNdjson } from "@cosyte/fhir";
const { resource } = parseResource(
'{"resourceType":"Bundle","type":"transaction","entry":[' +
'{"fullUrl":"urn:uuid:1","resource":{"resourceType":"Patient","id":"1"},' +
'"request":{"method":"POST","url":"Patient"}}]}',
);
readBundle(resource).atomic; // true: a transaction is all-or-nothing (a batch would be false)
// A contained reference cycle is a bounded, typed finding, never an infinite loop:
const { issues } = validateResource(
parseResource(
'{"resourceType":"Bundle","type":"collection","entry":[{"resource":' +
'{"resourceType":"Observation","id":"o","contained":[' +
'{"resourceType":"Observation","id":"a","hasMember":[{"reference":"#b"}]},' +
'{"resourceType":"Observation","id":"b","hasMember":[{"reference":"#a"}]}]}}]}',
).resource,
);
issues.some((i) => i.code === "CONTAINED_CYCLE"); // true
// Stream a Bulk NDJSON export without loading the file; a bad line is isolated, not fatal:
for await (const record of streamNdjson(readableChunks)) {
if (record.error)
console.warn("bad line", record.error.line); // line number, never content
else handle(record.resource);
}FHIR is HL7's modern, resource-oriented interoperability standard, the format behind the US
regulatory push (ONC HTI-1 binds §170.315(g)(10) to FHIR R4 + US Core + SMART on FHIR).
@cosyte/fhir is the FHIR member of the cosyte parser family: a small, zero-runtime-dependency
TypeScript library that reads and writes FHIR, models its resources with correct primitive
semantics, and validates against structural rules and US Core profiles, mirroring the API shape of
@cosyte/hl7, the reference parser.
The decisions that shape everything downstream:
decimal/integer64are string-backed and preserve lexical precision.0.010is never silently normalized to0.01, and these primitives never round-trip through JSnumber.- FHIRPath: a bounded, vendored subset in-repo, no runtime dependency, no full third-party engine.
- R4-first (
4.0.1), the US regulatory anchor. R5 and DSTU2 are read-tolerance only.
Inherited from the shared @cosyte/* standard, by depending on the published @cosyte/* config
packages, not by copying files:
- TypeScript (strict) via
@cosyte/tsconfig, target ES2023,NodeNext. - Dual ESM + CJS +
.d.tsbuild viatsup(@cosyte/tsup-config);attwis a publish gate, run throughscripts/attw.mjs. The wrapper is there because theattwCLI prints "This package does not contain types." and then exits 0, so a tarball that lost its declarations passed the gate. It checks that every artifact pathpackage.jsonpromises exists before the run, and treats an untyped report afterwards as a failure. - Node >= 22; package manager pnpm 10.
- ESLint 10 (
@cosyte/eslint-config) + Prettier (@cosyte/prettier-config), lint at--max-warnings=0. - Vitest 4 + v8 coverage (
@cosyte/vitest-config). - Zero runtime dependencies.
- License: MIT.
pnpm install
pnpm build # dual ESM + CJS + .d.ts
pnpm typecheck
pnpm lint
pnpm testEvery meaningful change gets a Changeset (pnpm changeset, patch on the 0.0.x ladder) and a
CHANGELOG.md [Unreleased] entry. See CONTRIBUTING.md.
MIT © Cosyte