Skip to main content
Version: v0.0.7

The envelope & loop model

Every X12 interchange is a set of nested envelopes. Understanding the four levels (and the loop structure inside a transaction) is the whole mental model; once you have it, every reader in this library reads the same way.

The four envelope levels

ISA ─ interchange header (the outermost envelope; fixed-width, 106 bytes)
│ GS ─ functional group header (groups same-type transactions)
│ │ ST ─ transaction set header (one business document: an 835, an 837, …)
│ │ │ … body segments (BPR, CLP, SVC, NM1, …)
│ │ SE ─ transaction set trailer (segment count + control number)
│ GE ─ functional group trailer (transaction count + control number)
IEA ─ interchange trailer (group count + control number)

parseX12 decodes this whole tree into an immutable X12Interchange:

  • ix.isa / ix.iea: the interchange envelope (IsaSegment / IeaSegment).
  • ix.groups[]: one X12FunctionalGroup per GS..GE, each with .gs / .ge.
  • ix.groups[i].transactions[]: one X12TransactionSet per ST..SE, each with .st / .se and the decoded body .segments.
import { parseX12 } from "@cosyte/x12";

const raw =
"ISA*00* *00* *ZZ*MEDPAY *ZZ*CLINIC001 " +
"*260601*1200*^*00501*000000001*0*P*:~" +
"GS*HP*MEDPAY*CLINIC001*20260601*1200*1*X*005010X221A1~" +
"ST*835*0001~" +
"BPR*I*450.00*C*ACH*CCP*01*021000021*DA*1234567*1512345678**01*021000021*DA*98765*20260601~" +
"SE*3*0001~GE*1*1~IEA*1*000000001~";

const ix = parseX12(raw);

ix.isa.elements[6].trim(); // => "MEDPAY"
ix.groups[0]?.gs.elements[1]; // => "HP"
ix.groups[0]?.transactions[0]?.st.elements[1]; // => "835"

Delimiters are detected, never assumed

X12 does not fix its delimiters. The sender declares them, and clearinghouses vary. @cosyte/x12 detects all four from fixed byte positions in the ISA, so you never configure them:

  • Element separator: ISA byte 3 (classically *).
  • Repetition separator: ISA-11 (position 82; classically ^ in 005010).
  • Component (sub-element) separator: ISA-16 (the byte before the segment terminator; classically :, but Medicare and some BCBS plans use others).
  • Segment terminator: the byte immediately after ISA-16 (classically ~).

ix.delimiters carries the detected set; every reader and the getSegmentValue dot-path resolver use it, so a partner who ships | elements and \ components parses with no special handling.

Line endings between segments

X12 is a single-line format, but most senders write a line break after every segment terminator so the file is readable. parseX12 absorbs it: any run of CR / LF bytes between segments, so ~\r\n, ~\r, ~\n, a double-spaced ~\n\n, and a doubled-in-transit ~\r\r\n all parse to the same interchange. All 15 CR/LF sequences of length 0 to 3 frame identically. You never configure this, and it never warns.

A run of CR / LF between segments can never be structural, which is what makes absorbing an unbounded one safe: parseX12 refuses any ASCII control character (CR and LF among them) at all four delimiter positions, the segment terminator included, as the Tier-3 fatal X12_INVALID_DELIMITERS. So a line break is never itself a delimiter. Note the terminator is the byte immediately after ISA-16, as stated above; ISA-16 is the component separator. (This describes what this library does. It takes no position on what ASC X12 permits between segments.)

The break is discarded rather than recorded, and that has one consequence worth knowing before you diff an emit against its input:

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

const prettyPrinted =
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260601*1200*^*00501*000000001*0*P*:~\n" +
"GS*HC*SENDER*RECEIVER*20260601*1200*1*X*005010X222A2~\n" +
"ST*837*0001~\nSE*1*0001~\nGE*1*1~\nIEA*1*000000001~\n";

const emitted = serializeX12(parseX12(prettyPrinted));

// The line breaks are gone, so the emit is the compact form:
emitted === prettyPrinted; // => false
emitted.includes("\n"); // => false

// What DOES hold, and is what the round trip is for. The emit is a fixed
// point, so serializing it again is a byte-level no-op:
emitted === serializeX12(parseX12(emitted)); // => true

serializeX12 is byte-faithful for the segments on the model: each of those comes back verbatim, including element padding, composite structure, and ?-release escapes. Anything the parser did not record does not come back, and line breaks are only the most common of several such things.

serialize(parse(s)) === s is not guaranteed in general, and having no line breaks is not enough to make it hold. Six constructs are known not to survive:

  1. Line breaks between segments, as above. Silent.
  2. A doubled segment terminator outside a transaction. It delimits a zero-length segment carrying no elements, so there is nothing to retain. Silent.
  3. A missing final terminator, which the emit supplies. Silent.
  4. Post-IEA trailingBytes, re-joined from segment slices rather than preserved verbatim.
  5. TA1 position. A TA1 that appeared after a functional group is collected onto ix.ta1Segments and emitted immediately after the ISA, so the emit reorders it. Silent, and unlike the others nothing is lost: the model and the warning stream both round-trip identically. It is also the only construct that moves something else - a segment outside a transaction is placed correctly relative to the groups but not relative to a TA1 hoisted past it. This library takes no position on where ASC X12 requires a TA1 to sit.
  6. A segment whose first element is empty (*A*B~), outside a transaction. It has no id for the envelope walker to dispatch on, so it is skipped entirely: absent from the model, absent from the emit, and it does not even raise X12_UNEXPECTED_SEGMENT. Silent, and the only case here that loses a value with no diagnostic at all. Inside an open transaction the same segment is kept and re-emitted normally.

A segment outside a transaction is not on that list. It is kept on ix.orphanSegments and re-emitted at its structural anchor, so the segment, its value and its X12_UNEXPECTED_SEGMENT warning all survive the round trip. See Segments outside a transaction below.

Cases 2 to 6 all break the round trip on inputs containing no line breaks at all, and five of the six (1, 2, 3, 5, 6) produce no warning, so a clean ix.warnings does not tell you a round trip will be byte-exact; only case 4 warns.

What is measured, across the 56 fixtures committed to this repository: every emit is a fixed point and re-parses to an identical model with an identical warning stream; the 14 fixtures carrying no line breaks return byte-identical; and the 42 pretty-printed ones differ from their source by line breaks and nothing else (no element value lost, altered, reordered, or re-escaped). Two caveats bound how far that sweep can be pushed: the corpus contains no instance of cases 2 to 6 and no orphan at all, and 13 of the 14 byte-identical fixtures are golden/*.edi, which are serializer output by construction, so envelope/no-trailing-crlf.edi is the only independent witness. That is why the six cases are enumerated here rather than left to the sweep.

So for a file whose only irregularity is pretty-printing, the round trip is safe to build on for data and not for a byte-level diff, and diffing your emit against serializeX12(parseX12(source)) is the way to ignore the line-break noise.

Segments outside a transaction

The envelope grammar binds body segments to an open ST..SE transaction set. A segment that arrives outside one has nowhere to go in ix.groups. The positions that do this are a stray segment between GE and IEA, a body segment between an SE and its group's GE, a body segment between GS and the first ST, an ST with no open group, an SE that closes nothing, a GE that closes nothing, and a TA1 inside an open group.

That last one is the exception to this section's title. TA1 is envelope-level by spec, so a TA1 inside an open group lands on ix.orphanSegments even when it arrived between an ST and its SE - and it is lifted out of that transaction's segments and rawSegments. For a document containing such a TA1, ix.groups is not the whole typed model. That is long-standing behaviour; what changed is that the segment is retained and re-emitted rather than discarded. It is also the one orphan that is anchored inside a transaction, so it is the case any re-emission design has to get right - see the second example below.

Each raises X12_UNEXPECTED_SEGMENT, is retained verbatim on ix.orphanSegments, and is re-emitted at the structural anchor recorded alongside it:

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

const withStray =
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260601*1200*^*00501*000000001*0*P*:~" +
"GS*HC*SENDER*RECEIVER*20260601*1200*1*X*005010X222A2~" +
"ST*837*0001~SE*2*0001~GE*1*1~" +
"REF*ZZ*VENDORTAG~" + // outside every transaction
"IEA*1*000000001~";

const ix = parseX12(withStray);

// Reported, and kept. The value is still reachable:
ix.orphanSegments.length; // => 1
ix.orphanSegments[0]?.segment.id; // => "REF"
ix.orphanSegments[0]?.context; // => "body-outside-transaction"

// `segmentIndex` is the join key back to the warning that describes it:
const warning = ix.warnings.find((w) => w.code === "X12_UNEXPECTED_SEGMENT");
warning?.position.segmentIndex === ix.orphanSegments[0]?.segmentIndex; // => true

// And it comes back in the emit, byte for byte, with its warning:
const round = serializeX12(ix);
round === withStray; // => true
parseX12(round).orphanSegments.length; // => 1

// `anchor` is the structural slot it goes back into - after the one group
// that closed, before the IEA.
ix.orphanSegments[0]?.anchor.kind; // => "interchange"

Two things this deliberately does not do. An orphan is not decoded by any get* reader. And a TA1 inside an open group is not added to ix.ta1Segments (that surface means "envelope-level TA1", and parseTA1 reads it). Retention and re-emission are not promotion: the segment is kept and put back where it was, not moved into a position the grammar says it does not occupy.

Placement is by anchor, never by segmentIndex, and the difference is the whole correctness argument. segmentIndex is an index into the input stream, and the emit is not in input order: it hoists ta1Segments ahead of the groups (case 5 above), and a doubled terminator's zero-length segment occupies an input index that is never emitted. Replaying an orphan by input index therefore splices it into whatever occupies that slot in the output. Measured on a two-group interchange with a TA1 after the first group, that put a stray segment inside an 835's ST..SE body between CLP and SE, where re-parsing raised no warning at all and get835 would have walked it as claim content; a stray SE closed the transaction early and corrupted SE-01. An anchor names a slot in the typed tree instead - which group, which transaction, which offset inside it - and a slot in the tree is invariant under both reorderings, because it does not mention bytes. Use segmentIndex to join an orphan to its warning; never use it to place one.

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

// A TA1 *between* an ST and its SE: the one orphan that is anchored inside an
// open transaction rather than beside one.
const inTx =
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260601*1200*^*00501*000000001*0*P*:~" +
"GS*HP*SENDER*RECEIVER*20260601*1200*1*X*005010X221A1~" +
// SE-01 is 4: four physical segments sit between the ST and the SE.
"ST*835*0001~CLP*ACCT1*1*100*0~TA1*000000001*260601*1200*A*000~SE*4*0001~" +
"GE*1*1~IEA*1*000000001~";

const lifted = parseX12(inTx);
lifted.orphanSegments[0]?.anchor.kind; // => "transaction"

// It is lifted OUT of the transaction body, and stays out on the round trip:
lifted.groups[0]?.transactions[0]?.rawSegments.length; // => 3
serializeX12(lifted) === inTx; // => true

Those last two numbers are both right, and the gap between them is the point. rawSegments is 3 because the walker lifted the TA1 off the transaction, while SE-01 is 4 because four segments are physically present between the ST and the SE, which is what X12.6 counts. { specClean: true } reconciles against the emitted bytes, so it counts the re-emitted orphan and agrees with the 4; reconciling against rawSegments alone would "correct" a right count into a wrong one.

orphanSegments is empty for a well-formed interchange, so a non-empty one is itself the signal that the sender's framing did not match the envelope grammar. Check it alongside ix.warnings.

Treat an orphan as PHI when you log it. A warning message is a lookup into a frozen registry and its metadata is positional, so the whole ix.warnings array is safe to log. An orphan is not the same kind of thing: it carries the sender's bytes verbatim, exactly as tx.rawSegments and isa.raw do, and nothing guarantees a segment outside a transaction is free of patient data. Log context and segmentIndex, which name the rule that broke and where to look, rather than the whole entry.

Segments, elements, composites, repetitions

Inside a transaction, every body segment is an immutable X12Segment: a segment id (BPR, CLP, NM1, …) plus 1-indexed elements. The ?-release-character escape is honored losslessly (?~ → a literal ~, ?**, ???).

The getSegmentValue(seg, path, delimiters) dot-path resolver walks the three axes:

  • Elements: "03" is the third element.
  • Composites: "03-1" is the first sub-element of element 3 (-N is 1-indexed).
  • Repetitions: "03[0]" is the first repetition of element 3 ([N] is 0-indexed).
import { parseX12, getSegmentValue } from "@cosyte/x12";

const raw =
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260601*1200*^*00501*000000001*0*P*:~" +
"GS*HC*SENDER*RECEIVER*20260601*1200*1*X*005010X222A2~" +
"ST*837*0001~" +
"HI*ABK:J45.50*ABF:E11.9~" +
"SE*2*0001~GE*1*1~IEA*1*000000001~";

const ix = parseX12(raw);
const hi = ix.groups[0]?.transactions[0]?.segments.find((s) => s.id === "HI");

getSegmentValue(hi!, "01-1", ix.delimiters); // => "ABK"
getSegmentValue(hi!, "01-2", ix.delimiters); // => "J45.50"
getSegmentValue(hi!, "02-1", ix.delimiters); // => "ABF"

Loops: the repeating sub-structures

Above the segment sits the loop: a repeating group of segments that models a business entity (a claim, a service line, a subscriber). The TR3 implementation guides define each transaction's loop hierarchy; the per-transaction readers (get835, get837Claims, …) walk those loops for you and hand back a typed tree, so you rarely touch raw segments.

When you do need to describe a loop yourself, or understand how the built-ins are authored, the public defineLoopSpec() API is the same one the library uses internally (a dogfooding gate: the built-in specs like REMIT_835_LOOP_2100 are authored through it):

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

const Loop2300 = defineLoopSpec({
id: "2300",
description: "837 Claim Information",
trigger: "CLM",
segments: [
{ id: "CLM", usage: "required", max: 1 },
{ id: "DTP", usage: "situational", max: ">1" },
{ id: "HI", usage: "situational", max: ">1" },
],
});

Loop2300.trigger; // => "CLM"
Loop2300.id; // => "2300"

Many claims transactions (837, 271, 277, 278) also nest an HL hierarchy: an explicit parent-pointer tree (HL segments) layered on top of the loops. The readers validate those pointers for integrity and never silently re-number a broken one; see The tolerance tiers.