Skip to main content
Version: v0.1.16

Troubleshooting and known limitations

@cosyte/x12 is built to be correct and honest about its edges rather than to claim more than it delivers. Mis-reading a payer's remittance, a claim's diagnosis, or a member's coverage can cause real financial or clinical harm, so this page is the deliberate "do not over-trust" list: the error model, the common symptoms, and the intentional boundaries. Everything here is a documented boundary, not a bug. The lenient parser never silently drops or garbles a decoded value; where a limitation applies, the raw value is preserved (often with a warning), it is simply not further decoded. Five constructs the emit does not reproduce are worth knowing before you rely on a round trip, and four of them are silent: line breaks between segments, a doubled segment terminator outside a transaction, a missing final segment terminator (the emit supplies one) and a TA1 that followed a functional group (emitted right after the ISA, so reordered, though nothing is lost). The fifth, bytes after the IEA, raises X12_TRAILING_GARBAGE and comes back re-joined from segment slices rather than verbatim. All five are in the round-trip entry of the known-limitations list. A segment outside a transaction is not on that list: it is warned, kept on the model at ix.orphanSegments, and re-emitted at the structural anchor recorded with it, so both the bytes and the warning survive a round trip.

When does it throw vs warn?​

Only four unrecoverable structural conditions throw; everything else is a warning on the model.

import { parseX12 } from "@cosyte/x12";

parseX12(""); // throws X12ParseError (X12_EMPTY_INPUT)

The four fatal codes are X12_EMPTY_INPUT, X12_NO_ISA_HEADER, X12_ISA_TOO_SHORT and X12_INVALID_DELIMITERS; Warning and error codes says what each means, and Handle warnings and fatal errors shows how to catch them.

Everything a real payer or clearinghouse does short of that (miscounts, dangling release characters, unknown CARC, RARC or HI codes, HL parent mismatches, balance mismatches, pre-005010 versions) is a warning you triage, not an exception you catch. See Tolerance tiers and warning codes.

Common symptoms​

SymptomLikely causeWhat to do
get835 / get837Claims returns undefinedThe transaction set isn't the one that reader decodes (wrong ST-01, or get277CADisposition on a non-X214 message)Route on tx.st.elements[1] (and GS-01) first; hand each transaction to the matching reader.
An adjustment's reasonDescription is undefinedThe CARC/RARC code is outside the bundled snapshotThe verbatim code is still on the model; an X12_UNKNOWN_CARC / X12_UNKNOWN_RARC warning is raised. A stale snapshot yields a missing description, never a wrong code.
A X12_835_REMIT_BALANCE_MISMATCH warningThe payer's numbers don't add up under the 835 balance equationsDo not auto-post; the library preserves the inbound values and will not rebalance. Route to a human.
A X12_835_BALANCE_NOT_EVALUABLE warningA term of that balance equation is undefined on the model, so the equation was not run. Nothing was measured out of balance and nothing was measured to balance either. Through 0.0.12 the term collapsed to X12Decimal.ZERO and this document raised X12_835_REMIT_BALANCE_MISMATCH insteadDo not auto-post, on the same footing as the row above, and gate on both codes: a posting gate written against the mismatch alone stops firing on these documents when you upgrade. Read the model to find which term is undefined; the message names the equation and no amount. Do not substitute 0 for it.
A X12_HL_PARENT_MISMATCH warning on an 837/271/277A broken HL parent pointer in the hierarchyThe pointer is preserved verbatim, never re-numbered; decide whether to trust the loop nesting.
An amount or quantity reads undefined where you expected a numberEither the sender omitted the element, or an X12_UNPARSEABLE_DECIMAL warning at that element says the sender put bytes there that are not an X12 R-type decimal (a thousands separator, a currency symbol, N/A). No reader substitutes X12Decimal.ZERO for a value it did not decode any more: every slot that used to get one is X12Decimal | undefined, and undefined means this library decoded no value. Read that as a rule about the substitution and not as a census of the model: a row whose amount does not decode is sometimes dropped whole instead, which X12_AMOUNT_ROW_DROPPED reports (two rows down). Through 0.0.12 those slots fell back to X12Decimal.ZERO, which read as a confident zeroDo not substitute 0. The warning's position.elementIndex names the element and separates the two causes; the verbatim bytes are on tx.segments[…].raw. An absent element does not raise X12_UNPARSEABLE_DECIMAL, so an unwarned value at an element a reader decoded is what the sender sent. Do not widen that into "an absent element is silent": where it takes a whole AMT / ADX row off the model, X12_AMOUNT_ROW_DROPPED reports the drop. That is not a guarantee about every slot on the model: a slot a reader never read cannot warn. The known instance of that in this library, an 837 service line whose SVx never decoded, has a warning of its own (the next row); no census of never-read slots is published. See Decimal-exact money.
An optional amount or quantity is undefinedEither the sender omitted the element, or it was present and did not decodeLook for an X12_UNPARSEABLE_DECIMAL at that position.elementIndex. One present means the second; none means the first. That reading is about a slot that is still on the model, and an AMT / ADX whose amount decoded nothing has no slot at all, and is reported by X12_AMOUNT_ROW_DROPPED at its own segment instead (the next row). readElementDecimal gives the same distinction in-band if you walk segments yourself.
An AMT or ADX you can see in the file is on no model row at allAn X12_AMOUNT_ROW_DROPPED warning at that segment: its amount element (AMT-02, ADX-01) decoded no value, and these segments carry an amount plus the thing the amount is about, so there was no row to build and the qualifier or adjustment reason code went with it. Through 0.0.12 this was silent on every channel: AMT*B6~ gave claim.amounts: [] and warnings: []. Raised by the 835's AMT, the 837's AMT, the 834's coverage AMT (on that member's own warnings) and the 820's ADX. On the 835 and the 837 an AMT attaches to the open service line first and to the claim only when there is none, so the row lost may be a line-level one and an unchanged claim.amounts is not evidence the warning is staleRead the segment off tx.segments[…].raw; nothing was fabricated to stand in. The warning carries no position.elementIndex, because one of its two routes is an absent element and an absent element has no index to name. To tell the routes apart, look for an X12_UNPARSEABLE_DECIMAL at the same position.segmentIndex: present means the sender put unreadable bytes there, absent means the sender stated no amount. This code is additive: the unparseable route still raises X12_UNPARSEABLE_DECIMAL exactly where it always did, now alongside this one, so a gate you wrote against that code is not narrowed by it. What such a gate never caught, on any release, is the absent-amount row, so gate on both. Read the bound as a property of the READ, not of the walker's control flow: what is reported is a row whose amount was read and decoded no value. A segment discarded before its amount is read is not on this channel (an AMT with no HD open in the 834, an ADX with no remittance open in the 820), and neither is one whose amount decoded and then found nothing open to attach the row to. Do not shorten that to "nothing open means silent": the 835 and the 837 decode first, so an AMT with an absent amount and no claim open does raise this code. An 820 RMR is not on this channel either, and not because its row survives: decodeRmr drops on open-item identity (RMR-01 and RMR-02 both empty) before RMR-04 is read, so an RMR stating an open item and no amount keeps its row with amountPaid undefined, while one stating an amount and no open item is dropped whole. That second case is a separate loss, reported by X12_STATED_AMOUNT_DISCARDED (the next row) alongside the 837's Loop 2430 AMT, which an open SVD discards outright. The two codes are disjoint and never name the same segment
An RMR or AMT you can see in the file is on no model row, and nothing says its amount failed to decodeAn X12_STATED_AMOUNT_DISCARDED warning at that segment: the sender populated the amount element and this reader built no row anyway, for a reason that is not a failure to decode it. Two routes. An 820 RMR under an open remittance loop with RMR-01 and RMR-02 both empty: decodeRmr refuses the open item on identity before RMR-04 or RMR-05 is read, so a stated payment, a stated amount due, and RMR-03's payment action code leave together. Or an 837 AMT arriving while a Loop 2430 line adjudication (SVD) is open, whose AMT-02 decoded: the v1 adjudication model carries no amount row, and attaching one to this submission's own service line would put another payer's figure on it. Through 0.0.12 both were silent on every channel, and under an open SVD the report read backwards, present on the row that decoded nothing and absent on the row that decoded a real amountRead the segment off tx.segments[…].raw and decode the amount yourself. This code does not promise it will decode: on the RMR route the row is refused before the decode is attempted, so it is raised on RMR****1,234.56~ exactly as on RMR****150.00*150.00~, and an unaccompanied instance is not evidence the bytes are postable. Only the AMT route guarantees a decodable amount, because there AMT-02 decoded before the row was skipped. The warning carries no position.elementIndex (on the RMR route the loss spans RMR-04 and RMR-05). This code is additive and disjoint from X12_AMOUNT_ROW_DROPPED: the two can never name the same segment, because this one needs an amount element the sender populated and that one needs one that decoded no value, so the code you get is the discriminant. Gate on both. Read the bound literally: this reports a segment that arrived while the loop that would carry its row was open. One reaching a reader with no such loop open is a different loss and is still silent (the 834's AMT with no HD, the 820's ADX with no remittance, the 835's and 837's AMT before any claim). And it says nothing about whether the amount would have decoded: the RMR route refuses the row before attempting the decode, so no X12_UNPARSEABLE_DECIMAL accompanies it even on unreadable bytes
An 837 service line's charge and units both read undefinedAn X12_837_SERVICE_LINE_NOT_DECODED warning: the Loop 2400 line carries no SV1 / SV2 / SV3 matching the variant the submission resolved to, either because it carries none at all or because it carries one for a different 837 variant than ST-03 (or your type option) named. Nothing on the service segment was read.Treat charge, units, the procedure code, the modifiers, the unit of measure, and the place of service on that line as unread, not as zero or empty. undefined on its own does not distinguish this row from a service segment that WAS read and carried no charge element; this warning is what does. The warning's position.segmentIndex names the LX that opened the line; the segments are verbatim on tx.segments[…].raw. If ST-03 and the SVx disagree, decide which one the sender meant before re-reading with the matching type.
A claim's serviceLines is empty but the file has LX segmentsAn X12_837_SERVICE_LINE_DROPPED warning at each such LX: it opened no Loop 2400, so the line reached no claim at all. Either no CLM was open at that point in the transaction, or the submission's variant is not one of P / I / D.Do not read the empty serviceLines as "the claim had no service lines". Nothing was fabricated to stand in and no claim was synthesized; the segments are verbatim on tx.segments[…].raw. Read submission.variant to tell the two causes apart: this code does not travel with X12_837_UNKNOWN_VARIANT, because a caller-supplied type outside "P" | "I" | "D" reaches the same route without it. If the cause is the variant, re-read with a valid type. Two bounds: an SVx with no LX at all is reported by X12_837_SERVICE_SEGMENT_WITHOUT_LX instead (the next row), because this code is anchored at the LX, and a DTP / AMT / NTE / REF / N3 / N4 / PER after a dropped LX is not simply absent, and which of the two routes above fired decides it: with a CLM open the date, amount, note, and reference land among the claim-level ones, and with no CLM open all seven are discarded, including where the LX was stray inside an entity loop and they were that entity's own. Where that LX landed inside an entity loop, each N3 / N4 / PER / REF it discards raises X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX at itself; read that code's own row below for its bound, which is narrower than this row's route, because a discarded entity segment is not always on that channel. This code reports the service line, not an entity's address, id, or contact. Read the segments off tx.segments[…].raw rather than inferring either. This is a different loss from the row above, where the line is on the model and only its service segment went unread.
An SV1/SV2/SV3 in the file has its charge and units on no service line at allAn X12_837_SERVICE_SEGMENT_WITHOUT_LX warning at that service segment: no Loop 2400 was open when it arrived, so there was no line to decode it into and nothing it carries was read.Treat the charge, units, procedure code, modifiers, unit of measure, and place of service on that segment as unread. Nothing was fabricated to stand in and no line or claim was synthesized; the warning's position.segmentIndex names the service segment itself, and its bytes are verbatim on tx.segments[…].raw. It reports once per service segment, not once per loop. Read the condition literally: "no line open", not "the file contains no LX". An LX in an earlier claim is still an LX, so other claims in the same file can have perfectly good service lines. It never reports the same segment as the two rows above, which are both raised at an LX, though a file with several claims can carry all three codes. It also says nothing about how submission.variant resolved: a type option you pass wins first, and absent one, where ST-03 names no known implementation convention, the reader falls back to the first SVx in the transaction, and a segment reported here is eligible for that fallback like any other.
An 837 service line's charge or procedureCode does not match the SV1 / SV2 / SV3 you can see in the fileAn X12_837_SERVICE_SEGMENT_REPEATED warning at a service segment: a SECOND SV1 / SV2 / SV3 arrived inside a Loop 2400 that was already open. The line carries one service segment's worth of slots, so it holds what the last one matching the submission's resolved variant wrote, and every decoder writes all of the slots its kind writes. SV1*HC:99213*8500*… then SV1*HC:99999*12*… leaves one line reading charge 12 and procedureCode 99999; through 0.0.13 that happened with warnings: [].Treat the line as carrying one of the service segments the loop contained, not a reconciliation of them, and read the rest off tx.segments[…].raw before you post an amount. Nothing of the losing occurrence is on the model in any form, and the corner that costs most is a repeat whose own charge element is absent: it writes undefined over the amount the first stated, and X12_837_SERVICE_LINE_NOT_DECODED does not fire there, because a service segment did decode. A repeat of a kind that does not match the resolved variant is read into nothing and overwrites nothing, and is reported the same way. Which occurrence the sender meant is not decided: this reader cannot tell a stray service segment from a conformant one, and picking a winner would be inventing. The decode is byte-for-byte what it was at 0.0.13, so re-read the raw loop rather than expecting a later release to choose differently. It fires once per repeat and the count resets with the line, so a first service segment under a later LX is a first. It never names the same segment as X12_837_SERVICE_SEGMENT_WITHOUT_LX (the row above), which requires that no Loop 2400 be open where this one requires that one is. Read that as disjointness and not as coverage: a service segment following an LX that opened no line is named by neither, because X12_837_SERVICE_LINE_DROPPED at that LX already reports the loss.
submission.variant names a flavor you did not expect, on a file whose ST-03 you do not controlAn X12_837_AMBIGUOUS_VARIANT warning at the ST: no type option was passed and ST-03 named no implementation convention this reader recognizes, so the variant came from the first SV1 / SV2 / SV3 in the transaction body, and another one in that same body names a different variant.Treat submission.variant as a guess between contradictory evidence and do not route on it. Re-read with get837Claims(delimiters, tx, { type }) to decode the document against a variant you trust: a type you pass wins ahead of the fallback. Which service segment is the stray one is not decided here, because this reader cannot tell a stray service segment from a conformant one, and the fallback takes the first in the body whether or not a Loop 2400 was open at it. Read the bound as a property of the resolution, not of the document: where a type was passed, or ST-03 named a known convention, no guess was made and this code is not raised however mixed the body is. It is additive and moves nothing of its own: every warning this reader raised on such a document through 0.0.13 it still raises, at the same position, and this one is added beside them. Read that as a claim about this code and not about the release, because the release it shipped in also changed which documents reach the fallback at all (next paragraph). Read that as the invariance claim it is and not as a list of what else you will see on a contested document: it does not promise that any particular loss on one is reported at all, and one that is not was not reported before this code existed either. It never travels with X12_837_UNKNOWN_VARIANT, which reports the opposite outcome of the same resolution: nothing to fall back on at all. Which references it recognizes changed in this release: the set through 0.0.13 held none of the identifiers HIPAA adopts and was missing the ones production 837P and 837I traffic actually carries, so this code fired on conformant files as the normal path. On a file whose ST-03 is now recognized it does not fire at all, submission.variant can differ from what 0.0.13 read, and a service line whose SVx kind disagrees with the declaration is no longer decoded: its charge and units read undefined, the rest of the service segment is undecoded, and X12_837_SERVICE_LINE_NOT_DECODED is raised at that line's LX, on a document that may have carried warnings: [] before. Gate on the warning and never on a slot: an undecoded line SEEDS its identity fields, so procedureCode is "" on a P or D line and revenueCode is "" on an I one. The bytes stay verbatim on tx.segments[…].raw.
An entity's address, contacts, or references is empty and the file has those segmentsAn X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX warning at each N3 / N4 / PER / REF that reached no party: an LX earlier in the transaction opened no Loop 2400 (no CLM was open at it) and closed the entity loop those segments belonged to, so nothing they carry is on the model.Read the segments off tx.segments[…].raw: the bytes are verbatim there, and nothing was fabricated to stand in. The LX itself is reported separately by X12_837_SERVICE_LINE_DROPPED, which names the service line's loss and never an entity segment, so the two codes report different things about the same stretch of the document. Read this code's bound literally: it is not a general "this segment reached no party" report. It fires only after such an LX, and only until the next NM1 / HL / CLM opens a loop; a party named after that LX is outside this code's scope again, and its trailing segments are silent whether or not this reader surfaces them on it. An N3 / N4 / PER / REF that reaches no party by any other route is silent, as are a DTP / AMT / NTE on this one, which never attach to a party on any route. Which party a segment following a stray LX belongs to is not derivable from the TR3s in either direction, so it is discarded rather than attributed; releases through 0.0.10 attached it to whichever party the last NM1 left active, wherever this reader surfaces that segment kind on that party at all: a PER on a patient or a pay-to address reached the model on no release, so this code reports that a segment reached no party and not that it would otherwise have reached one. KNOWN-LIMITATIONS.md records the trade.
A claim's payToAddress names a street the sender never paired with that cityAn X12_837_PAY_TO_ADDRESS_REPEATED warning at each NM1*87 after the first in one Loop 2000A: the document names Loop 2010AB more than once, and the TR3s allow it at most once there.The model has one pay-to address slot, so it carries one of them, and this code is the only thing that says there were more. The rule: occurrences are never merged, the last occurrence that states an address of its own wins, and an occurrence that states none (no N3 or N4 at all, or only a valueless one) does not blank one that did. Read the losing occurrence's bytes off tx.segments[...].raw: it is not on the model in any form, and which occurrence the sender meant is not derivable from the TR3s. Through 0.0.12 the two were FUSED, so a payToAddress read on that release or earlier can carry a street line from one address and a city or country code from another, with warnings: []; treat it as unreliable rather than as either sender's address. One consequence worth expecting rather than reporting: a repeat that states only part of an address (an N4 and no N3) puts only that part on the model and re-emits a Loop 2010AB with no N3, since keeping the earlier street lines there is the fusion itself. Two bounds: this is scoped to the Loop 2000A pay-to route, so an NM1*87 arriving while a CLM is open never reaches it and is neither resolved nor warned by this code, landing instead on that service line's serviceLine.providers where a Loop 2400 is open and on claim.providers where a claim but no line is (both pre-existing, and this row deliberately names no single destination); and a document with at most one NM1*87 per Loop 2000A is unaffected in every respect.
build277 throws X12_277_BUILD_INVALID_SPEC naming SVC-07A Loop 2220 service line in the spec has no unitsOfService. SVC-07, the units of service count, is a required element in 005010X212, so emitting the line without it would put a non-conformant 277 on the wire.Supply the count the submitter sent. The builder will not default it: a quantity nobody sent is invented data, and a units figure is one a payer reprices against. The same spec is accepted by build277CA, where TR3 005010X214 makes SVC-07 situational, and that asymmetry is deliberate, not a gap.
Fields parse but parseFloat gives odd totalsYou called parseFloat on an EDI amountRead the X12Decimal and do exact arithmetic on it; never parseFloat. See Decimal-exact money.
An X12_ISA_EXTRA_ELEMENT_SEPARATOR warning, or an ISA whose control number, usage indicator, or version reads as some other element's valueAn ISA element value carries the element separator, so the header split into more than ISA + 16 elements: that element came back a prefix and everything after it is displaced. How far is NOT derivable from isa.elements: more than one element can carry an extra separator, and one sitting between two of them is displaced less than one sitting after both, so isa.raw plus the fixed widths is the only route back. The parser reports it and re-frames nothing, because the byte is both content under the ISA's fixed widths and the separator the segment declares in-band, and nothing anyone here has read settles which reading to take.All 106 bytes are still on isa.raw, which is the route back. Treat every other ISA-derived warning in ix.warnings as provisional: parseX12 raises this one ahead of them for that reason. serializeX12 runs its own reconciliation off isa.elements[13] and never raises this code, so its absence there is not evidence the header framed.
A X12_PRE_005010 warningThe twelfth element of the ISA split does not read 00501; where X12_ISA_EXTRA_ELEMENT_SEPARATOR is also present it need not be ISA-12Tolerated and flagged, not decoded against older field maps. Pass { strict: true } to make it a hard failure for a trusted partner.
serializeX12(parseX12(file)) !== fileUsually pretty-printing: the parser absorbs the line break after each terminator and the model does not record it, so the emit is compact. But it is not the only cause, and the others fire on files with no line breaks at all.If the file is merely pretty-printed the difference is line breaks only, and diffing your emit against serializeX12(parseX12(source)) ignores that noise. Otherwise see Line endings between segments for everything the emit does not reproduce.
An X12_UNEXPECTED_SEGMENT warningA segment arrived where the envelope grammar has no place for it: outside any open ST..SE transaction set, such as a stray segment between GE and IEA or a TA1 inside an open group.The segment is kept, verbatim, on ix.orphanSegments, and its segmentIndex matches the warning's position.segmentIndex. No get* reader will see it, so read it there. serializeX12 does re-emit it, at the structural anchor recorded with it, so the segment and this warning both survive a round trip. See Segments outside a transaction.
A segment is missing from ix.groupsIt fell outside every transaction set, so the typed tree has nowhere for itCheck ix.orphanSegments before concluding the sender omitted it. That array is empty for a well-formed interchange, so a non-empty one tells you the framing did not match the envelope grammar.

Keeping PHI out of logs​

Log w.code, w.position and w.message: a warning's message comes from a frozen registry and holds no value from the document. Treat everything on the model as PHI, orphan segments included, and drop err.snippet from a fatal X12ParseError, which carries up to 64 characters of the start of the input. A builder's refusal can name a value you passed in, bounded in length. Keeping PHI out of logs has the detail for every surface, and the type checks the builders run on a JSON-driven caller's values.

Known limitations and non-goals​

Every limit this package states on purpose is one line in the list in KNOWN-LIMITATIONS.md, grouped as data and decode boundaries, trading-partner profiles, what the differential corpus proves, scope and non-goals, and the bundled code lists, and each line links to its detail. This page does not restate them, so the two cannot disagree. One of them decides what a clean parse can tell you, so it is here as well:

We do not validate against implementation guides. We check the ASC X12 base standard (the envelope, delimiters, control numbers, segment counts and HL pointers), the few guide facts each typed reader needs to build its model (the declared guide, the loops it requires, the 835 balancing equations), and one CAQH CORE rule, the CORE 360 Code Combinations, through checkCoreCodeCombination with a table you supply. We also check codes against the lists we bundle: a reader keeps a code outside them verbatim and warns with X12_UNKNOWN_CARC (835 and 837 adjustments), X12_UNKNOWN_RARC (835 remarks), X12_UNKNOWN_CLAIM_STATUS and X12_UNKNOWN_CLAIM_STATUS_CATEGORY (277 statuses), X12_271_AAA_UNKNOWN_CODE (271 AAA codes), X12_UNKNOWN_HI_QUALIFIER (837 and 278 HI qualifiers) or X12_834_UNKNOWN_MAINTENANCE_TYPE (834 INS-03 and HD-01), and build834 refuses a maintenance type outside its list with X12_834_BUILD_UNKNOWN_MAINTENANCE_TYPE. Three bundled lists are code subsets of lists a TR3 prints, which we did not read: MAINTENANCE_TYPE_CODES (INS-03), CLP_STATUS (CLP-02, which the 835 reader carries but does not warn on) and HI_QUALIFIERS. We ship no TR3 usage tables, and we do not check TR3 usage: required, situational and not-used segments and elements, situational rules, repeat counts, or the code values a guide allows at an element beyond those bundled lists. Every TR3 is sold by X12, so a document that breaks its guide can parse with no warning. With the typed readers you can still check the declared guide (a reader returns no reading for a guide it does not implement, and X12_GUIDE_NOT_DECLARED marks a reading whose transaction set declares none), refuse a document whose required field reads undefined (an absent value is never defaulted), compare the verbatim qualifiers and codes no bundled list covers with your copy of the guide, date-check adjustment and remark codes with checkCarcValidity and checkRarcValidity, and read anything untyped with getSegmentValue.

For the per-transaction read and emit surface, see the how-to guides: start with Post an 835 remittance or Read and build 837 claims, and the generated API reference for every field.