Inbound messages

parse returns the wire faithfully, but tag-keyed: message.fields[262].value, groups under message.groups[268]. That is the right shape for a codec and the wrong one for application code, which ends up hand-writing a switch (msgType) and a tag-to-field mapper per message — re-deriving what the dictionary already describes. toInbound re-keys it by name, so a received message reads the way a built one does.

Body by name, envelope on the side

Body fields are read through get(), keyed as the dictionary names them, and a repeating group is an array of entry objects under its counter’s name — the declared count is not a property, because the entry array is the count. Header and trailer fields land on envelopeinstead, matching the generated body types, which exclude them by construction. Which tags count as envelope is decided by the dictionary’s own header/trailer components, not by a fixed list.

inbound-read.mtsts
import { createFixEngine } from '@boarteam/fix';import { dictionary, MsgType, type MessageBodies } from '@boarteam/fix-dict-fix44';const fix = createFixEngine<MessageBodies>(dictionary);// The same market-data snapshot as the parse guide: two NoMDEntries (268)// entries, 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';// Parse first and read the diagnostics: they decide whether the message is// worth reading at all. toInbound re-keys what parse produced — it never// re-parses, and never throws.const { message, issues } = fix.parse(raw, { soh: '|' });console.log(issues.length); // → 0const inbound = fix.inbound(message);// The session envelope is split off from the body, because the generated body// types describe a message's own fields and nothing else. It reads the same on// every message, so it needs no narrowing.console.log(inbound.envelope.MsgSeqNum, inbound.envelope.SenderCompID); // → 2 SENDER// Body fields are read by their dictionary NAME instead of their tag —// message.fields[55].value becomes get('Symbol').if (fix.isInbound(inbound, MsgType.MarketDataSnapshotFullRefresh)) {  console.log(inbound.get('Symbol')); // → EUR/USD  // Repeating groups are arrays of entry objects under the counter's name. The  // declared count is not a property: the entry array IS the count, so an entry  // that omits an optional field cannot shift its neighbours' values.  for (const entry of inbound.get('NoMDEntries') ?? []) {    console.log(entry.MDEntryType, entry.MDEntryPx, entry.MDEntrySize);  }  // → 0 1.0921 1000000  // → 1 1.0923 1000000}// The tag-keyed original stays reachable — it is the source of truth for// re-encoding, since ParsedField.raw preserves the exact wire form.console.log(inbound.parsed.groups[268]?.[0]?.fields[270]?.raw); // → 1.0921

Nothing is re-parsed or re-validated: toInbound is a pure re-keying of what parse produced, every diagnostic was already reported there, and validate still runs against the ParsedMessage — which stays reachable as inbound.parsed.

One switch, narrowed per case

msgType becomes a usable discriminant. inboundKnownGuard narrows a received message to the union of every message the dictionary defines, so each case types get() to that message’s own body. Handle every message and default narrows to never, giving compile-time exhaustiveness; handle a subset and it stays live and typed — a message the dialect defines that this session does not serve.

inbound-dispatch.mtsts
import { inboundKnownGuard, loadDictionary, parse, toInbound } from '@boarteam/fix';import { dictionary as fix44, MsgType, type MessageBodies } from '@boarteam/fix-dict-fix44';// The dict packages ship the dictionary as DATA. parse() wants it indexed, so// load it once and reuse — loadDictionary resolves datatypes and group// delimiters, which is not free. (toInbound and the guards accept either form.)const dictionary = loadDictionary(fix44);// Bind the guard once, to the dictionary these messages are parsed against.const isKnownInbound = inboundKnownGuard<MessageBodies>(dictionary);// The frames below are written with `|` for legibility and parsed with `soh: '|'`,// so their CheckSum(10) covers those bytes — a real session passes SOH-delimited// bytes and the default separator./** What a session's message loop looks like: guard once, then switch. */function handle(raw: string): string {  const { message, issues } = parse(raw, dictionary, { soh: '|' });  // parse/unknown-msgtype is an ERROR, so a session that drops every message  // carrying one never reaches the guard below. Which is right depends on the  // direction: an acceptor answers an unknown MsgType with a session Reject,  // while a client reading a venue's stream logs it and stays connected. This  // one keeps reading, so the last frame reaches the guard.  const fatal = issues.filter((i) => i.severity === 'error' && i.code !== 'parse/unknown-msgtype');  if (fatal.length) return `rejected: ${fatal[0]!.code}`;  const inbound = toInbound(message, dictionary);  const seq = inbound.envelope.MsgSeqNum;  // A MsgType the dictionary does not define cannot be a member of the typed  // union: a `msgType: string` member would overlap every literal below, so no  // case could eliminate it and its loose get() would make every branch  // uncallable. It does not parse like one either — an unknown message is read  // flat, with no repeating groups reconstructed.  if (!isKnownInbound(inbound)) return `#${seq} unknown MsgType ${inbound.msgType}`;  switch (inbound.msgType) {    case MsgType.Logon:      // Narrowed to LogonBody: get() accepts this message's fields, and nothing else.      return `#${seq} logon, heartbeat ${inbound.get('HeartBtInt')}s`;    case MsgType.MarketDataSnapshotFullRefresh:      return `#${seq} ${inbound.get('Symbol')} × ${inbound.get('NoMDEntries')?.length ?? 0}`;    default:      // Handle every message and this narrows to `never`, giving compile-time      // exhaustiveness. Handle a subset — as here — and it stays live and typed:      // a message FIX 4.4 defines that this session does not serve.      return `#${seq} unhandled ${inbound.msgType}`;  }}console.log(handle('8=FIX.4.4|9=62|35=A|49=VENUE|56=ME|34=1|52=20260901-09:30:00.000|98=0|108=30|10=086|'));// → #1 logon, heartbeat 30sconsole.log(  handle(    '8=FIX.4.4|9=159|35=W|49=VENUE|56=ME|34=2|52=20260901-09:30:01.250|262=req-1|55=EURUSD|' +      '48=sec-eurusd-001|22=8|268=2|269=0|270=1.10545|271=2000000|269=1|270=1.10549|271=1500000|10=027|',  ),);// → #2 EURUSD × 2console.log(handle('8=FIX.4.4|9=60|35=0|49=VENUE|56=ME|34=3|52=20260901-09:30:02.000|112=probe|10=157|'));// → #3 unhandled 0console.log(handle('8=FIX.4.4|9=51|35=ZZ|49=VENUE|56=ME|34=4|52=20260901-09:30:03.000|10=190|'));// → #4 unknown MsgType ZZ

The guard is not ceremony. An unrecognised MsgType cannot be a member of that union: a msgType: string member overlaps every literal, so no case eliminates it and its loose get makes every branch uncallable. It does not parse like one either — an unknown message is read flat, with no repeating groups reconstructed. Use inboundTypeGuard for the single-MsgType if form.

Why the un-narrowed body is any

toInbound defaults its body type to any rather than a loose index-signature type, and that is deliberate. A generated body is an interface, and TypeScript never gives an interface an implicit index signature — so no loose type is ever its supertype. A narrowing predicate whose target is not assignable to the declared type yields an intersection instead of a replacement, and the loose get overload then wins at every call site, silently undoing the narrowing while still compiling. InboundBody is exported for callers who mean not to narrow; pass it explicitly.

A received message is still a MessageView

InboundMessage extends the same read surface the builders share, so it also renders: inbound.render(envelope) re-emits the received body under a fresh envelope, which is the useful shape for a proxy that re-signs what it forwards. For a byte-exact echo of what arrived, go through toEncodeMessage(inbound.parsed) instead — ParsedField.raw is the round-trip source of truth, and a body carries the coerced value.

Venue-custom tags read back the same way, provided the extension ships both halves — the runtime placement and the augmented entry interface. See extending a dictionary.

API reference

Everything this guide uses — the conversion, the message and envelope shapes, and the two narrowing guards — is documented in full, signature by signature, in the generated API reference.