Tolerance tiers & warning codes
@cosyte/x12 follows Postel's Law: liberal on input, conservative on output. Real payer and
clearinghouse traffic is full of deviations that a strict validator would reject and a production
integration cannot afford to drop. The parser tolerates them, records each one as a stable-coded
warning with positional context, and preserves the raw value, so you decide what to do, instead of
the library throwing your pipeline off the rails on a vendor quirk.
The three tiers
- Tier 1: spec-clean. Parses with no warnings.
- Tier 2: tolerated deviation. A miscount, a dangling release character, an unknown CARC/RARC/HI code, an HL parent mismatch, a balance mismatch, a pre-005010 version. The parser keeps going, preserves the verbatim value, and emits a warning. This is the overwhelming majority of real-world quirks.
- Tier 3: unrecoverable structural corruption. The bytes are not X12 at all. There are exactly four of these, and they always throw (see below).
Only Tier 3 throws by default. Everything in Tier 2 is a warning you triage, not an exception you catch.
One thing sits outside the tiers: line breaks between segments are silently normalized. Any run of
CR / LF bytes after a segment terminator is absorbed with no warning, so a pretty-printed file is
Tier 1, and so is a double-spaced one. That is the footnote "the parser never silently drops anything"
needs: it drops inter-segment whitespace, never a decoded element value. The practical consequence is
that serializeX12(parseX12(file)) returns the compact form rather than your original bytes.
It is not, however, the only thing the round trip does not reproduce. A segment outside a
transaction is a Tier-2 deviation (X12_UNEXPECTED_SEGMENT): it is warned about, kept verbatim on
ix.orphanSegments, and re-emitted at the structural anchor recorded with it, so both the value and
the warning do survive the round trip. What it is not is decoded - no get* reader will see it,
because retention and placement are not promotion into the typed tree. See
Line endings between segments for the full list of what the emit does not
reproduce, and
KNOWN-LIMITATIONS.md for the canonical
statement of the boundary.
Warnings collect on the model, and stream
Every warning lands on the returned model's .warnings array (ix.warnings, remit.warnings,
sub.warnings, …), each carrying a stable code (from WARNING_CODES), a message that is a
lookup into a frozen registry rather than anything built from your document, and a position.
No warning factory takes a value parameter, so no element can reach a message; the values stay on
the model. See Keeping PHI out of logs. You can also stream them live via
the onWarning callback:
import { parseX12, type X12ParseWarning } from "@cosyte/x12";
const seen: X12ParseWarning[] = [];
// ISA-12 declares an old version family (004010): tolerated, flagged, not fatal.
const raw =
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260601*1200*^*00401*000000001*0*P*:~" +
"IEA*0*000000001~";
const ix = parseX12(raw, { onWarning: (w) => seen.push(w) });
seen.length > 0; // => true
ix.warnings.length > 0; // => true
The four fatal codes
These are the only conditions that throw an X12ParseError, regardless of strict. They are
unrecoverable structural corruption, not tolerable quirks:
import { FATAL_CODES } from "@cosyte/x12";
FATAL_CODES.X12_EMPTY_INPUT; // => "X12_EMPTY_INPUT"
FATAL_CODES.X12_NO_ISA_HEADER; // => "X12_NO_ISA_HEADER"
FATAL_CODES.X12_ISA_TOO_SHORT; // => "X12_ISA_TOO_SHORT"
FATAL_CODES.X12_INVALID_DELIMITERS; // => "X12_INVALID_DELIMITERS"
X12_EMPTY_INPUT: nothing to parse.X12_NO_ISA_HEADER: the input does not begin with an ISA; it is not an X12 interchange.X12_ISA_TOO_SHORT: the ISA is truncated below its fixed 106 bytes, so delimiters can't be read.X12_INVALID_DELIMITERS: the delimiters can't be recovered from the ISA.
import { parseX12 } from "@cosyte/x12";
parseX12(""); // throws X12ParseError (X12_EMPTY_INPUT)
Escalate to strict when you want a conformance gate
For a trusted trading partner where any deviation should be a hard failure, pass { strict: true }.
Every Tier-2 deviation then throws an X12ParseError carrying the same warning code it would
otherwise have recorded, so a strict run is a spec-conformance gate, and a lenient run is production
tolerance, with one code vocabulary across both:
import { parseX12 } from "@cosyte/x12";
const raw =
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260601*1200*^*00401*000000001*0*P*:~" +
"IEA*0*000000001~";
// The pre-005010 version is a warning by default; strict turns it into a throw.
parseX12(raw, { strict: true }); // throws X12ParseError (X12_PRE_005010)
Integrity checks warn: they never rebalance or renumber
The 835 balance invariants, the 837 HL parent-pointer integrity, and the serializer's envelope-count reconciliation all follow the same rule: on a mismatch they surface a warning and preserve the inbound values verbatim. The library will not "fix" a payer artifact for you. Gate your own posting/adjudication on the warning. See Decimal-exact money for the 835 balance equations, and Troubleshooting for the full symptom → cause table.