@cosyte/ccda
v0.1.5
Published
C-CDA parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.
Maintainers
Readme
@cosyte/ccda
Read the C-CDA a vendor actually sent, and never guess at what it meant.
C-CDA parser, serializer, and builder for Node.js and TypeScript, lenient on parse, spec-clean on emit.
- Why this exists
- Status
- Install
- Usage
- PHI and safety
- Conformance
- Documentation
- Support
- Security
- Contributing
- License
Why this exists
Every sending system bends C-CDA somewhere, so a team that has to read what an EHR actually produced
is usually choosing between an XML library that hands back a DOM and leaves every clinical judgment
to the caller, and a conformance validator that rejects the document and returns nothing. This
package is neither: it reads a real, vendor-quirky document into typed clinical models and turns each
deviation into a stable coded warning, so a document a validator would reject still yields the
problems, medications, and allergies it does carry. The nearest alternative most teams reach for is a
generic XML parser plus hand-written XPath, which works until a vendor moves a templateId and never
tells you when a reading was unsafe to make. This package will not make that reading: where a
document contradicts itself, the derived value is withheld and flagged rather than resolved in
silence.
Status
The 0.1 line. From 0.1 the public API is settled and safe to depend on:
the behavior documented on this page is implemented and covered by tests, the exported names,
options, return shapes, and warning codes are the surface we keep stable, and below 1.0 a breaking
change bumps the minor version and is called out in the changelog with its migration. On a 0.0.x
version, pin an exact version rather than a range. Some surfaces are still partial, and they are
named rather than implied:
- Building is partial.
buildCcdaemits three of the twelve US Realm document types (CCD, Referral Note, and inpatient Discharge Summary); the other nine throw rather than emitting something that merely resembles the type you asked for. - Editing is whole-section.
editCcdacovers twelve section kinds. Functional Status, Mental Status, and the Referral Note's narrative-only sections are buildable but not editable, and there is no entry-level append and no section removal. - Required-section validation under-warns, and every document type reports how much of its obligation has been read off the normative source. A quiet parse is not a conformance result.
- A terminology adapter is consulted at five coded slots only, so a clean run means those five slots passed, not that the document was terminology-verified. The same five carry a declared value-set binding, and a value-set source you supply is what checks it: this package ships value set identifiers, never their member codes.
Known limitations carries the boundaries in full, and each is stated under-warning rather than talked around.
Install
npm install @cosyte/ccdapnpm add @cosyte/ccda and yarn add @cosyte/ccda install the same package.
Node.js 22 or later, tested on 22, 24, and 26. package.json declares >=22.0.0 in engines.node. The package
ships a dual ESM and CJS build with per-condition type declarations, so both import and
require resolve to the right types. There is one runtime dependency,
@xmldom/xmldom (exact-pinned), the W3C-DOM substrate
for C-CDA's XML.
Usage
One example, end to end: build a complete, spec-clean CCD, read it back, and print what came out.
The // => comments are the values this code really produces, and the output below is what it
prints. Tests in this repository execute this block against the published entry point and fail if
either side drifts, so a reader (or an agent) lifting it verbatim gets code that runs.
import { buildCcda, parseCcda } from "@cosyte/ccda";
// A synthetic CCD, built here so the example stands on its own. In your integration this is the
// document a sending system handed you.
const xml = buildCcda({
patient: { mrn: "MRN001", given: ["Jane"], family: "Doe", gender: "F", birthTime: "19800101" },
problems: [{ problem: { code: "59621000", displayName: "Essential hypertension" } }],
medications: [
{
drug: { code: "314076", displayName: "Lisinopril 10 MG Oral Tablet" }, // RxNorm
dose: { value: 1, unit: "{tablet}" },
route: { code: "C38288", displayName: "Oral" }, // NCI Thesaurus
},
],
allergies: [
{
allergen: { code: "7980", displayName: "Penicillin G" }, // RxNorm
reaction: { code: "247472004", displayName: "Hives" }, // SNOMED CT
},
],
}).toString();
const doc = parseCcda(xml);
const problem = doc.getProblems()[0];
const allergen = doc.getAllergies()[0]?.allergies[0]?.allergen;
console.log(doc.documentType, doc.getMrn(), doc.getPatient()?.name?.family);
console.log("problem", problem?.problems[0]?.value?.code, problem?.status);
console.log("medication", doc.getMedications()[0]?.drug?.code);
console.log("allergy", allergen?.code, allergen?.displayName);
console.log("warnings", doc.warnings.length);
// The emit half is a fixed point: re-parsing and re-serializing changes nothing.
console.log("round trip unchanged", parseCcda(doc.toString()).toString() === xml);
// The values printed above, which the test suite asserts on every run:
doc.documentType; // => "ccd"
doc.getMrn(); // => "MRN001"
problem?.problems[0]?.value?.code; // => "59621000"
problem?.status; // => "active"
doc.getMedications()[0]?.drug?.code; // => "314076"
allergen?.code; // => "7980"
doc.warnings; // => []
parseCcda(doc.toString()).toString() === xml; // => trueIt prints:
ccd MRN001 Doe
problem 59621000 active
medication 314076
allergy 7980 Penicillin G
warnings 0
round trip unchanged trueThe parser is lenient by default: recoverable vendor quirks become stable-coded CcdaWarnings on
doc.warnings (also forwarded to options.onWarning), not failures. { strict: true } escalates the
first tolerated deviation to a thrown CcdaParseError. Unrecoverable or hostile input (DTD/XXE,
billion-laughs entity expansion, oversized/over-deep/over-wide documents, XML that does not
parse, a non-ClinicalDocument root) is always a thrown CcdaParseError.
The full read surface
Every accessor a parsed document exposes, in one place. Each is described in detail on What it extracts.
import { parseCcda } from "@cosyte/ccda";
const doc = parseCcda(xml);
doc.documentType; // e.g. "ccd", one of the 12 US Realm document types (or undefined)
doc.getPatient()?.name?.family; // patient demographics from the recordTarget
doc.getMrn(); // the patient's medical record number
doc.findSection("allergies")?.narrativeText; // framed section narrative
doc.getProblems()[0]?.problems[0]?.value?.code; // coded condition (SNOMED CT / ICD-10-CM)
doc.getMedications()[0]?.drug?.code; // RxNorm drug
doc.getAllergies()[0]?.allergies[0]?.allergen?.code; // offending substance
doc.getResults()[0]?.results[0]?.value; // polymorphic ObservationValue (UCUM-checked PQ, coded, …)
doc.getVitals()[0]?.vitals[0]?.value; // e.g. systolic BP, units intact
doc.getImmunizations()[0]?.vaccine?.code; // CVX vaccine code
doc.getProcedures()[0]?.disposition; // "performed" vs "planned" (moodCode, never guessed)
doc.getEncounters()[0]?.code?.code; // encounter type (CPT / SNOMED / ActEncounterCode)
doc.getSmokingStatus()[0]?.value?.code; // SNOMED smoking-status concept
doc.getPlannedItems()[0]?.disposition; // planned only, never read as performed
doc.getFunctionalStatus()[0]?.value; // functional finding (domain-tagged, never mental)
doc.getFamilyHistory()[0]?.relative?.relationship?.code; // relative + their conditions
doc.getPastMedicalHistory()[0]?.value?.code; // historical problem (bare, not a concern)
doc.warnings; // stable, positional tolerance warnings (never throws on quirks)PHI and safety
A C-CDA document is a patient record by construction, so treat everything this library hands back as protected health information. What the library itself does with it is deliberately small, and each half is stated rather than left to be inferred.
- Logging: it does not. There is no
consolecall in library code, and no logger, sink, telemetry hook, or debug flag to configure or forget to turn off. Warning and fatal messages come whole from a frozen registry and interpolate nothing, so no value from your document can reach a message; a diagnostic'spositioncarries bounded structural identifiers only (element path, section LOINC code, template OID) plus line and column. - In-memory retention: for exactly as long as you hold the object. A parsed
CcdaDocumentholds the typed model and a snapshot of the source XML, which is what letsserializeCcda()/toString()re-emit the document byte for byte. Nothing is cached in module scope, nothing is shared between calls, and there is no registry to clear: drop the reference and the document is garbage like any other object. - Writing to disk: it does not. No file is opened, no socket is dialed, no database is touched,
and no terminology or reference data is bundled or fetched. A string goes in and a model or a
string comes back. A
TerminologyAdapteris code you supply and this library calls, so any network egress there is yours and visible in your own code.
What your application still owns, because nothing here can own it for you: where the document came from and where the output goes, transport security, authentication, access control and audit, retention and deletion, and whether anything you log about a parse (a document id, a warning's element path, even a count) is safe to write where you write it. Redaction and de-identification are out of scope for this package. Every example in this file, and every fixture in this repository, uses an invented patient and invented identifiers; keep it that way in anything you paste into an issue.
Conformance
Conformance, measured rather than expected. pnpm conformance fetches the normative C-CDA R2.1
Schematron, its vocabulary file, and the CDA R2 XML schema at run time from pinned immutable
references, validates every document buildCcda emits against the schema and then the
error-severity phase of the Schematron, and re-runs the Schematron over a public sample corpus
parsed and re-serialized through this library. The result is tracked at
documentation/conformance-report.md, it is rewritten by
every run, and a run fails if the committed bytes disagree with what it just produced.
Measured against Schematron revision 6d3ed96160b45a111895da4df5510c7fad9de01f, the document
types validated are
with 0 error-severity results on the built side
and 0 round-trip documents whose Schematron error set differed.
This is an assessment against a published artifact, not a certification, and no accredited body
has reviewed this software or this result.
Limits, and they sit here beside the capability rather than in a footer. The measurement covers
the three document types buildCcda emits and says nothing about the other nine, which it does not
emit. It covers the error-severity phase; the Schematron's warning phase is not run and a clean
result is not a claim about it. Value-set membership is checked only where the Schematron's own
vocabulary file checks it, so a clean result is not a terminology verification. The round-trip half
is differential: a public sample may carry Schematron errors of its own, and what is measured is
that parsing and re-serializing it introduces none and removes none, not that the sample was clean.
The run needs network egress and fails explicitly without it, by design; there is no offline pass.
Documentation
The full reference is on docs.cosyte.com/ccda:
- Installation and the quickstart.
- Core concepts: the document model, what it extracts, tolerance and warnings, clinical entries, datatypes, dates and times, code systems and provenance, validation against C-CDA R2.1, and the parser archetype.
- Guides: the cookbook, build, edit, and serialize, and conformance and profiles.
- Troubleshooting and the known limitations. Read the limitations before you point this at real data.
CHANGELOG.md: every release.
Support
Questions, bug reports, and quirks from the systems you receive documents from go in GitHub issues. Use synthetic data only, never a real patient's. For help with a C-CDA integration, see our integration services.
Security
Report a vulnerability privately, never in a public issue, and with no real patient data in it. Our security policy says how.
Contributing
Where to ask: open an issue at https://github.com/cosyte/ccda/issues. A parsing report is most useful with the smallest document shape that reproduces it and the warning codes you got back; never attach a real patient document, reduce it to a synthetic one first.
Unsolicited pull requests are accepted. For anything that changes what a document parses to, adds or renames a warning code, or moves the public API, open an issue first so the behavior can be agreed before you write it: a warning code is public surface, so renaming one is a breaking change. Typo, link, and test fixes can go straight to a pull request.
What a contribution has to clear, every one of them a script this package declares:
| check | what it is |
| ----------------------------- | ------------------------------------------------------- |
| pnpm lint | ESLint, at --max-warnings=0 |
| pnpm typecheck | tsc --noEmit, strict |
| pnpm test | Vitest, including the example on this page |
| pnpm format:check | Prettier |
| pnpm build | the dual ESM + CJS bundle |
| pnpm attw | per-condition type resolution for consumers |
| pnpm phi-scan | no patient-shaped identifier anywhere in a tracked file |
| pnpm check:no-emdash | the house punctuation rule |
| pnpm check:no-internal-refs | no internal bookkeeping on the public surface |
New behavior needs a test beside it, and a change a consumer would notice needs a changeset
(pnpm changeset), whose summary becomes the changelog entry.
License
MIT (SPDX identifier MIT), copyright Cosyte LLC.
HL7 is a registered trademark of Health Level Seven International, and its use does not constitute endorsement by HL7. Cosyte is not affiliated with, endorsed by, or sponsored by HL7 or any other organization these documents name. See TRADEMARKS.md.
