Parsing FIX messages

parse accepts a string or Uint8Array and returns { message, issues } — it never throws, hangs or crashes, whatever you feed it. The dictionary tells it every field’s name and datatype, so values come back typed, and repeating groups come back as real nested structure instead of a flat tag soup.

Raw vs typed values

Every parsed field keeps two representations. raw is the verbatim wire text — the source of truth for re-encoding, untouched by any coercion. value is what the dictionary’s datatype says the field means: a number for the float and int families, a string otherwise. Envelope facts (beginString, msgType, the message name) are lifted onto the message itself.

parse-typed-values.mtsts
import { createFixEngine } from '@boarteam/fix';import { dictionary } from '@boarteam/fix-dict-fix44';const fix = createFixEngine(dictionary);// An ExecutionReport (35=8) for a filled EUR/USD order.const raw =  '8=FIX.4.4|9=160|35=8|49=BROKER|56=BUYSIDE|34=7|52=20240101-12:00:00.000|' +  '37=ORD-1|17=EXEC-1|150=F|39=2|55=EUR/USD|54=1|38=1000000|32=1000000|31=1.0921|' +  '151=0|14=1000000|6=1.0921|10=205|';const { message, issues } = fix.parse(raw, { soh: '|' });console.log(issues.length); // → 0// Each field carries BOTH the verbatim wire text (`raw`) and the value decoded// per the dictionary's datatype (`value`):console.log(JSON.stringify(message.fields[38])); // → {"tag":38,"name":"OrderQty","raw":"1000000","value":1000000}// AvgPx (6) is a Price, so `value` is a number and `raw` stays a string:console.log(JSON.stringify(message.fields[6].value)); // → 1.0921console.log(JSON.stringify(message.fields[6].raw)); // → "1.0921"// String-typed fields decode as strings, and the envelope is lifted out:console.log(message.fields[55].value); // → EUR/USDconsole.log(message.beginString); // → FIX.4.4console.log(message.msgType, message.name); // → 8 ExecutionReport

Repeating groups are nested objects

A FIX repeating group is announced by its counter tag (NoMDEntries, 268, below) and delimited by its first member — which is exactly the structure that gets lost when a parser hands you parallel arrays. message.groups is keyed by the counter tag, each entry is an object with its own fields and groups, and nesting recurses as deep as the dictionary nests — FIX 4.4 reaches depth 4, FIX 5.0 SP2 depth 5.

parse-groups.mtsts
import { createFixEngine } from '@boarteam/fix';import { dictionary } from '@boarteam/fix-dict-fix44';const fix = createFixEngine(dictionary);// A market-data snapshot whose NoMDEntries (268) group repeats twice:// a bid and an offer.const raw =  '8=FIX.4.4|9=130|35=W|49=SENDER|56=TARGET|34=2|52=20240101-12:00:00.000|' +  '55=EUR/USD|268=2|269=0|270=1.0921|271=1000000|269=1|270=1.0923|271=1000000|10=248';const { message } = fix.parse(raw, { soh: '|' });// Groups are ARRAYS OF NESTED OBJECTS, addressed by their counter tag —// not parallel arrays you have to zip back together:for (const entry of message.groups[268] ?? []) {  console.log(entry.fields[269]?.value, entry.fields[270]?.value);}// → 0 1.0921// → 1 1.0923// Each entry is a ParsedGroupEntry: its own fields, and its own nested// groups when the dictionary nests them (they recurse to any depth).console.log(JSON.stringify(message.groups[268]?.[0], null, 2));/* →{  "fields": {    "269": {      "tag": 269,      "name": "MDEntryType",      "raw": "0",      "value": "0"    },    "270": {      "tag": 270,      "name": "MDEntryPx",      "raw": "1.0921",      "value": 1.0921    },    "271": {      "tag": 271,      "name": "MDEntrySize",      "raw": "1000000",      "value": 1000000    }  },  "groups": {}}*/

Logs: pipe separators and parseAll

The wire separator is the SOH control byte (0x01), which renders as nothing — so most captured logs show | instead. Pass { soh: '|' } and the engine reads the log form as-is, checksums included. And because parse deliberately reads only the first frame, a multi-message capture wants parseAll.

parse-logs.mtsts
import { createFixEngine } from '@boarteam/fix';import { dictionary } from '@boarteam/fix-dict-fix44';const fix = createFixEngine(dictionary);// Two messages captured back-to-back, the way they sit in a session log.// Most log viewers render the SOH separator as "|" — pass { soh: '|' } and// the engine reads the log as-is. (The default separator is the real SOH// byte, 0x01.)const log =  '8=FIX.4.4|9=68|35=A|49=BUYSIDE|56=BROKER|34=1|52=20240101-12:00:00.000|98=0|108=30|10=014|' +  '8=FIX.4.4|9=56|35=0|49=BUYSIDE|56=BROKER|34=2|52=20240101-12:00:01.000|10=237|';// parse() reads the FIRST frame only:console.log(fix.parse(log, { soh: '|' }).message.name); // → Logon// parseAll() frames and parses the whole capture:const results = fix.parseAll(log, { soh: '|' });console.log(results.length); // → 2for (const { message } of results) {  console.log(message.msgType, message.name);}// → A Logon// → 0 Heartbeat

parse options: soh (default the real SOH byte) and checkFraming (default true; framing findings arrive as issues, never a rejection).

Framed is not valid — and nothing throws

framed: true means one thing: the envelope was located. Whether the message is right is a different question, answered by issues (and by validate() for dictionary rules). Logs are full of truncated, hand-edited and corrupted messages; the contract is that every one of them decodes as far as the bytes allow, with the damage described as data.

parse-broken-input.mtsts
import { createFixEngine } from '@boarteam/fix';import { dictionary } from '@boarteam/fix-dict-fix44';const fix = createFixEngine(dictionary);// A damaged ExecutionReport: BodyLength is wrong, CheckSum is wrong, and// AvgPx (6) holds text. Real logs are full of these.const corrupt =  '8=FIX.4.4|9=000|35=8|49=SENDER|56=TARGET|34=7|52=20240101-12:00:00.000|37=ORD-1|17=EXEC-1|' +  '150=F|39=Z|55=EUR/USD|54=1|14=1000000|151=0|6=not-a-number|10=000';// parse never throws — the message decodes AND the damage comes back as data:const { message, issues } = fix.parse(corrupt, { soh: '|' });// `framed` only means the envelope was located. It is NOT "valid":console.log(message.framed); // → truefor (const issue of issues) {  console.log(issue.severity, issue.code, issue.path ?? '');}// → error parse/invalid-float AvgPx// → warning parse/body-length-mismatch// → error parse/checksum-mismatch// The offending bytes are preserved on the field, not discarded:console.log(message.fields[6].raw); // → not-a-number// Even input that is not FIX at all returns a result, never an exception:const garbage = fix.parse('this is not FIX');console.log(garbage.message.framed); // → falseconsole.log(garbage.issues.some((i) => i.code === 'parse/missing-msgtype')); // → true

Every code you can meet here is catalogued with its usual cause in decode diagnostics.