@cosyte/x12
Read the money out of an 835, an 837, or a 271 without a TR3 open on your desk.
Developer-focused ASC X12 healthcare EDI parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.
Why this exists
Reading an X12 healthcare transaction correctly normally means buying the TR3 implementation guide for it and then hand-mapping element positions that no compiler will ever check. This library removes that step for the engineer who has one 835 to post, one 271 to answer, or one 837 to send, and who needs the amounts to be exactly right rather than approximately right. The nearest alternatives are a general-purpose EDI translator, which frames segments correctly but knows nothing about what CLP-04 means, and a hand-rolled split on the segment terminator, which works until the first trading partner sends a repetition separator you did not plan for. This is neither: a zero-dependency TypeScript library that decodes the healthcare transaction sets into typed models, keeps every amount in exact decimal arithmetic, and reports each vendor deviation it tolerated with a stable code instead of failing or guessing.
Status
The 0.1 line, published on npm from a public repository.
On the 0.1 line the public API is settled and safe to depend on: the exported readers, builders, types and warning codes are the surface we keep stable, and the warning registry only ever gains codes. Below 1.0 a breaking change bumps the minor version and is called out in the changelog with its migration. On an older 0.0 release, pin an exact version rather than a range.
Typed read and typed emit both ship for every transaction set this package covers: 270, 271, 276,
277 and 277CA, 278 request and response, 820, 834, 835, 837P, 837I and 837D, 999 and TA1, each
with a reader and a matching domain builder, and the two claims attachments guides 45 CFR 162.2002
adopts for the period on and after May 26, 2028: the 277 request for additional information
(006020X313) and the 275 that answers it (006020X314). For those two the base X12 006020
structure is typed and no implementation-guide usage is checked, so a qualifier or segment the
guide requires is carried as sent and never validated; KNOWN-LIMITATIONS.md lists what else
they leave open. X12_TR3_CONFORMANCE is the machine-readable answer to which implementation
guide each one follows, and is a better source than this page.
Not covered, and deliberately so: a byte-exact round trip is not guaranteed in general (see Compatibility), and non-healthcare transaction sets, EDIFACT, transport such as AS2 and SFTP, pre-005010 revisions, 006020 guides other than the two attachments guides, and decoding what an attachment carries are all out of scope.
Install
pnpm add @cosyte/x12
npm install @cosyte/x12 and yarn add @cosyte/x12 work the same way.
Node.js 22 or later, tested on 22, 24, and 26: the engine floor is >=22.0.0, and there are zero
runtime dependencies. The package ships dual ESM and CJS builds with type declarations for both
conditions, so import and require each resolve to the artifact meant for them. The optional
@cosyte/x12/arbitraries entry point, fast-check generators for
property-testing your own code,
needs fast-check installed beside it; the main entry point never loads it.
Usage
Parse an 835 remittance advice and read the money: the payment total, the claim's charge/paid/patient-responsibility split, and the adjustment reason behind the difference. The interchange below is synthetic, with a fabricated payer, provider, patient, and amounts.
import { parseX12, get835 } from "@cosyte/x12";
const raw = `ISA*00* *00* *ZZ*MEDICARE *ZZ*SUBMITTER *260601*1200*^*00501*000000001*0*P*:~
GS*HP*MEDICARE*SUBMITTER*20260601*1200*1*X*005010X221A1~
ST*835*0001~
BPR*I*450.00*C*ACH*CCP*01*123456789*DA*987654321*1512345678**01*111111111*DA*222222222*20260601~
TRN*1*0012345*1512345678~
DTM*405*20260601~
N1*PR*MEDICARE PART A~
N3*123 PAYER WAY~
N4*BALTIMORE*MD*21244~
PER*BL*JANE COORDINATOR*TE*5551234567~
N1*PE*SAMPLE CLINIC INC~
N3*456 PROVIDER LN~
N4*CLEVELAND*OH*44113~
REF*TJ*123456789~
LX*1~
CLP*PT-ACCT-001*1*500.00*450.00*50.00*MC*PAYER-CLAIM-001*11*1~
NM1*QC*1*PATIENT*TEST*A***MI*MEMBER001~
NM1*82*2*RENDERING PROVIDER INC*****XX*1234567890~
DTM*232*20260501~
DTM*233*20260501~
SVC*HC:99213*500.00*450.00**1~
DTM*472*20260501~
CAS*PR*1*50.00~
REF*6R*LINE-CTRL-001~
SE*23*0001~
GE*1*1~
IEA*1*000000001~`;
const ix = parseX12(raw);
const tx = ix.groups[0]?.transactions.find((t) => t.st.elements[1] === "835");
if (tx === undefined) throw new Error("no 835 in this interchange");
const remit = get835(ix.delimiters, tx);
if (remit === undefined) throw new Error("not an 835");
// The payment, the claim's split, and who owes the difference and why.
const claim = remit.claims[0];
const adjustment = claim?.serviceLines[0]?.adjustments[0];
console.log("paid", remit.payment.totalActualPayment?.toString(), "by", remit.payment.method);
console.log("claim", claim?.patientControlNumber, "charged", claim?.totalChargeAmount?.toString());
console.log("patient owes", claim?.patientResponsibilityAmount?.toString());
console.log("reason", adjustment?.groupCode, adjustment?.reasonCode, adjustment?.reasonDescription);
console.log("warnings", ix.warnings.length);
// The values printed above, which the test suite asserts on every run:
remit.payment.totalActualPayment?.toString(); // => "450.00"
claim?.totalChargeAmount?.toString(); // => "500.00"
claim?.patientResponsibilityAmount?.toString(); // => "50.00"
adjustment?.groupCode; // => "PR"
adjustment?.reasonCode; // => "1"
adjustment?.reasonDescription; // => "Deductible Amount"
ix.warnings; // => []
It prints:
paid 450.00 by ACH
claim PT-ACCT-001 charged 500.00
patient owes 50.00
reason PR 1 Deductible Amount
warnings 0
The groupCode is what tells you who owes the money (PR patient responsibility, CO contractual
obligation, and so on). Read it; never infer it.
API
Everything ships from one entry point; there are no subpath imports.
- Read.
parseX12decodes an interchange into a model, and a per-transaction reader turns a transaction set into a typed one (get835,get837Claims,get270Inquiry,get271Eligibility,get276StatusInquiry,parse999,parseTA1, and the rest). - Emit.
serializeX12andbuildInterchangeare the general path, and every transaction set with a reader also has a domain builder (build835,build837P,build271, and the rest). - Errors and warnings. Parsing is lenient, and only four structural failures are ever fatal
(
X12_NO_ISA_HEADER,X12_ISA_TOO_SHORT,X12_INVALID_DELIMITERS,X12_EMPTY_INPUT). Everything else arrives onix.warningsas a stable code with positional context, so a tolerated deviation is visible rather than silent. - Money. Every monetary, percent, and quantity field decodes as
X12Decimal, which is string-backed with BigInt arithmetic, and this library neverparseFloats an EDI amount. A slot the sender left empty readsundefinedrather than zero, so "stated zero" and "stated nothing" stay different readings. - Conformance.
X12_TR3_CONFORMANCEstates which implementation guide each transaction set follows and how that identifier stands against the federal incorporation by reference. - CORE Code Combinations.
checkCoreCodeCombinationanswers whether an 835 adjustment's group, reason and remark codes are in a CORE Code Combinations table for a CORE-defined business scenario you name. You supply the table and name its version, no CORE table ships with this package, and anunevaluatedanswer is never permission to post.
Task-oriented how-to guides are in docs-content/, and
KNOWN-LIMITATIONS.md is the honest do-not-over-trust list.
Dates and times
Every typed reader surfaces its dates verbatim, as the qualifier the sender used plus the bytes it
carried. toObject, toISO, and toDate are the three ways to read one, and every @cosyte/*
parser exports the same three names with the same shapes and the same timezone rule.
import { toObject, toISO, toDate } from "@cosyte/x12";
// A DTP or DTM date off any typed reader: claim.dates[0], eligibility.dates[0], and so on.
const day = { formatQualifier: "D8", value: "20260601" };
toObject(day); // { year: 2026, month: 6, day: 1 }
toISO(day); // "2026-06-01"
// A calendar day is not an instant. Without a stated offset there is no answer.
toDate(day); // undefined
toDate(day, { assumeOffsetMinutes: 0 })?.toISOString(); // "2026-06-01T00:00:00.000Z"
toDate(day, { assumeOffsetMinutes: -300 })?.toISOString(); // "2026-06-01T05:00:00.000Z"
// A range converts to nothing, because an interval is not a point in time.
const span = { formatQualifier: "RD8", value: "20260601-20260605" };
toISO(span); // undefined
The decoded format qualifier set is D8 and RD8, and nothing else. Those are the two DTP-02
values this library already parses and builds: D8 is a single CCYYMMDD day, and RD8 is a
CCYYMMDD-CCYYMMDD range. A qualifier outside that pair, an absent one, a value whose digits do not
match the shape its qualifier declares, and a day that is not on the calendar all convert to
undefined. None of the three ever throws, so a lenient parse stays lenient all the way to the
value you read.
RD8 converts to undefined from all three, deliberately. An interval is not a point in time,
so there is no single day to hand back for a service period or an eligibility span. Handing back the
first endpoint would be a quiet wrong answer with a right-looking shape. Split the range yourself
and convert whichever end you meant.
toDate returns an instant only when you state the zone. No X12 date element this library
decodes carries a UTC offset, so assumeOffsetMinutes (signed minutes east of UTC) is the only way
one reaches the result. Without it the answer is undefined: the host machine's timezone is never
read and UTC is never assumed, because the same midnight is two different instants either side of a
border and a date of birth read in the wrong zone is a day out.
toObject returns only the components the value stated, with month spec-native 1 to 12 rather
than the JavaScript Date 0 to 11, so Object.keys() of the result tells you the precision. Delete
offsetMinutes and what is left is accepted by Temporal.PlainDateTime.from and by luxon's
DateTime.fromObject with no renaming. Neither library is a dependency here; this package still has
zero of those.
The date-aware code-list queries, checkCarcValidity and checkRarcValidity, keep their own
contract and still THROW an X12CodeListError on a document date they refuse. That divergence is
deliberate: they answer a caller who asked a validity question and could not state the day, while
these three read a document that was already parsed leniently.
Using two @cosyte parsers in one file
The three names are identical in every @cosyte/* parser, so importing two of them means aliasing:
import { toISO as x12ToISO } from "@cosyte/x12";
import { toISO as hl7ToISO } from "@cosyte/hl7";
x12ToISO({ formatQualifier: "D8", value: "20260601" });
hl7ToISO(someParsedHl7Timestamp);
A namespace import works too: import * as x12 from "@cosyte/x12", then x12.toISO(day).
Compatibility
- 005010 HIPAA transaction sets, with hooks for the errata revisions the industry actually exchanges. Non-healthcare sets such as 850, 856, 810, and 204, plus EDIFACT, AS2, and SFTP transport, and pre-005010 revisions, are out of scope.
- Vendor deviations become warnings, not exceptions. Built-in profiles record whose
companion-guide deviation they accommodate; selecting one never changes an otherwise correct
parse, and profiles are authored through the same public
defineProfile()API you have. - Round trips are not byte-exact in general.
serializeX12reproduces the segments on the model verbatim, including element padding, composites, and?-release escapes, butserialize(parse(s)) === sis not guaranteed in general. Line breaks between segments are the common case: any run of CR or LF between segments is absorbed at parse, so a pretty-printed file emits compact. Most of the constructs that break a round trip are silent, so "my file has no line breaks" is not sufficient either. KNOWN-LIMITATIONS.md is the canonical list.
Documentation
The full documentation lives at docs.cosyte.com/x12, built from this
repository's docs-content/. Start with
these pages:
- Quickstart: read an 835 and pull out the payment, each claim's money, and the adjustment reasons.
- The 80/20 transaction sets: every transaction set this package covers, with its reader and its builder.
- Decimal-exact money:
why every amount is an
X12Decimaland never a JavaScript number. - Build and serialize X12:
the emit side, from
serializeX12to the domain builders. - Post an 835 remittance: the first of the how-to guides, one task each, for the transactions you get handed.
- Troubleshooting: the error model, the common symptoms, and the boundaries this package states on purpose.
Support
Questions and ideas go to Discussions, which covers every Cosyte repository. Bug reports and trading-partner quirks go to the issue tracker, with a synthetic reproduction and never a real interchange. The organization's support guide has the rest.
Need an X12 integration built, or hosted and run for you? See our integration services.
Security
Report a vulnerability privately, as the security policy describes, never in a public issue.
PHI and safety
X12 healthcare transactions carry protected health information, so treat every interchange you hand this library as PHI.
What it does with your document. It decodes it in memory and nothing else. No socket is opened, nothing is written to disk, and nothing is fetched at run time: the bundled code lists are versioned data snapshots, so updating one is a release rather than a network call. The acknowledgment builders are pure functions and never auto-send.
What it keeps out of diagnostics. A warning message is a lookup into a frozen registry rather
than something built from your document, and no warning factory in this library takes a value
parameter, so no element of yours can reach a diagnostic. The code and the position say what and
where; the bytes stay on the model. ALL_WARNING_MESSAGES is exported so you can assert that.
The one deliberate exception. X12ParseError.snippet on the four structural fatals is a bounded
copy of the start of the input, so on real traffic it can carry PHI, and it is not redacted. Log
err.code and err.position instead, or redact at your call site.
An attachment refuses to print. The data of each BDS a 275 carries is read into
X12AttachmentData, whose string, JSON, and util.inspect forms state its octet count and nothing
else; readOctets() is the one route to the octets.
Builder refusals are a weaker surface, deliberately. A build* refusal names the control
number, count, or code you passed in, so that you can see what was refused. The rendered fragment is
bounded by an exported constant, but it is bounded rather than redacted. The two attachments
builders are the exception and name no value at all. Log err.code, not err.message, from a
builder.
What you still own. Transport, storage, retention, access control, audit logging, and every log line your own code writes. This library makes no HIPAA compliance claim on your behalf.
Contributing
Bug reports and trading-partner quirks belong in GitHub issues, as Support says above.
Pull requests are welcome. The organization's
contributing guide and
code of conduct apply here. A
contribution must clear pnpm typecheck, pnpm lint, and pnpm test, hold the per-directory
coverage floors, pass the PHI commit gate (pnpm phi-scan) and the prose gates
(pnpm check:no-emdash, pnpm check:no-internal-refs), and carry a changeset. Every fixture must
be synthetic: never open a pull request carrying a real patient's data.
Trademarks
Availity and Blue Cross Blue Shield are trademarks of their respective owners. Cosyte is not affiliated with, endorsed by, or sponsored by any of them. The names identify the trading partners whose companion-guide deviations the built-in profiles accommodate. See TRADEMARKS.md.
License
MIT, Copyright (c) 2026 Cosyte LLC. See LICENSE.
Built by Cosyte.