Typed messages

Tag-keyed encode checks nothing until runtime. The typed layer moves those mistakes to the compiler: each dictionary package generates a message() factory from the same data, so a builder for a given MsgType exposes only that message’s fields and groups, each typed by its datatype — enumerated fields take their value union, Boolean takes boolean, numeric families take number | string.

Build, then render

message(MsgType.X) is a fluent mutable builder (it also accepts a bulk init object). render(envelope) produces the framed wire — and it is byte-identical to encode of the same content, proven below. The body type excludes the envelope on purpose: comp-IDs, MsgSeqNum and SendingTime arrive at render time, because the library holds no sequence counter and no clock.

typed-message-build.mtsts
import { createFixEngine } from '@boarteam/fix';import { dictionary, message, MsgType, Tags, MDEntryType } from '@boarteam/fix-dict-fix44';// message(MsgType.X) returns a builder that knows ONLY that message's fields// and groups, each typed by its datatype. A wrong name or a wrong value type// is a compile error, not a runtime surprise.const wire = message(MsgType.MarketDataSnapshotFullRefresh)  .set('MDReqID', 'req-1')  .set('Symbol', 'EUR/USD')  .set('NoMDEntries', [    { MDEntryType: MDEntryType.BID, MDEntryPx: '1.1050' },    { MDEntryType: MDEntryType.OFFER, MDEntryPx: '1.1052' },  ])  // The body type EXCLUDES the envelope: comp-IDs, MsgSeqNum and SendingTime  // are supplied at render time (8, 9 and 10 are computed). The library holds  // no sequence counter and no clock — those stay yours.  .render(    { SenderCompID: 'BUYSIDE', TargetCompID: 'SELLSIDE', MsgSeqNum: 42, SendingTime: '20240101-12:00:00.000' },    { soh: '|' },  );console.log(wire);/* →8=FIX.4.4|9=120|35=W|49=BUYSIDE|56=SELLSIDE|34=42|52=20240101-12:00:00.000|262=req-1|55=EUR/USD|268=2|269=0|270=1.1050|269=1|270=1.1052|10=178|*/// render() is byte-identical to the tag-keyed encode of the same content —// the typed layer adds safety, never a different wire:const tagKeyed = createFixEngine(dictionary).encode(  {    msgType: MsgType.MarketDataSnapshotFullRefresh,    fields: {      [Tags.SenderCompID]: 'BUYSIDE',      [Tags.TargetCompID]: 'SELLSIDE',      [Tags.MsgSeqNum]: 42,      [Tags.SendingTime]: '20240101-12:00:00.000',      [Tags.MDReqID]: 'req-1',      [Tags.Symbol]: 'EUR/USD',    },    groups: {      [Tags.NoMDEntries]: [        { fields: { [Tags.MDEntryType]: '0', [Tags.MDEntryPx]: '1.1050' } },        { fields: { [Tags.MDEntryType]: '1', [Tags.MDEntryPx]: '1.1052' } },      ],    },  },  { soh: '|' },);console.log(wire === tagKeyed); // → true

Illegal messages do not compile

Wrong field names, wrong value types and malformed group entries fail at compile time. This sample is the one file on these pages that is never executed — it exists to be compiled: each @ts-expect-error line asserts that the line under it stays a type error, and the build fails if a release ever makes one legal.

typed-message-safety.types.mtsts
/* Compile-only sample: the type gate compiles it, nothing executes it. Each * @ts-expect-error below is an assertion — if a release ever made that line * legal, the unused directive would fail the build. */import { message, MsgType, MDEntryType } from '@boarteam/fix-dict-fix44';const snapshot = message(MsgType.MarketDataSnapshotFullRefresh)  .set('MDReqID', 'req-1')  .set('Symbol', 'EUR/USD');// @ts-expect-error — ClOrdID is an order field; a market-data snapshot has no such membersnapshot.set('ClOrdID', 'ORDER-1');// @ts-expect-error — MDEntryPx is a Price (number | string), never a booleansnapshot.set('NoMDEntries', [{ MDEntryType: MDEntryType.BID, MDEntryPx: true }]);// @ts-expect-error — the envelope is not part of the body; MsgSeqNum belongs to render()snapshot.set('MsgSeqNum', 42);

Mutable for hot paths, immutable for shared values

message(...) mutates in place and returns this — the fast path for per-tick loops. message.immutable(...) is copy-on-write: with, merge and without each return a new message, so a cached or shared instance cannot be edited out from under you. Both share the read surface — get, has, render.

typed-message-immutable.mtsts
import { message, MsgType, OrdType, Side } from '@boarteam/fix-dict-fix44';// message(...) is a fluent MUTABLE builder — the fast path for hot loops.// message.immutable(...) is its copy-on-write twin: every edit returns a new// message and the original is untouched, so one can be shared or cached// safely. Both accept a bulk init object, which must name the message's// required fields — leaving one out is a compile error.const base = message.immutable(MsgType.NewOrderSingle, {  ClOrdID: 'ORDER-1',  Symbol: 'EUR/USD',  Side: Side.BUY,  TransactTime: '20240101-12:00:00.000',  OrdType: OrdType.LIMIT,  OrderQty: 1_000_000,  Price: 1.0921,});const bumped = base.with('OrderQty', 2_000_000);console.log(base.get('OrderQty')); // → 1000000console.log(bumped.get('OrderQty')); // → 2000000console.log(bumped === base); // → false// Both kinds share the same read surface and render the same wire:console.log(base.has('Price'), base.get('Symbol')); // → true EUR/USD

Narrowing at erased boundaries

A generic send(message) or a log-metadata helper sees the body type erased, and message.msgType === 'W' cannot restore it. isMessageType is the guard that can: keyed on the MsgType value, a plain string compare at runtime, full narrowing at compile time. The engine binds the same guard as createFixEngine<MessageBodies>(dict).is, and its create() mirrors message().

typed-message-read.mtsts
import { createFixEngine, type MessageView } from '@boarteam/fix';import {  dictionary,  isMessageType,  message,  MsgType,  MDEntryType,  OrdType,  Side,  type MessageBodies,} from '@boarteam/fix-dict-fix44';// At a generic boundary — a send(msg) queue, a log-metadata helper — the// concrete message type is erased, and message.msgType === 'W' cannot narrow// the body on its own. isMessageType is a guard keyed on the MsgType VALUE:// inside it, get() is typed to that message with no casts. The runtime is a// plain string compare; the typing comes from the generated body registry.function bestBid(msg: MessageView<any>): string | number | undefined {  if (isMessageType(msg, MsgType.MarketDataSnapshotFullRefresh)) {    const [first] = msg.get('NoMDEntries') ?? [];    return first?.MDEntryPx; // entry fields are typed too  }  return undefined;}const snapshot = message(MsgType.MarketDataSnapshotFullRefresh)  .set('Symbol', 'EUR/USD')  .set('NoMDEntries', [{ MDEntryType: MDEntryType.BID, MDEntryPx: '1.1050' }]);const order = message(MsgType.NewOrderSingle)  .set('ClOrdID', 'ORDER-1')  .set('Symbol', 'EUR/USD')  .set('Side', Side.BUY)  .set('OrdType', OrdType.LIMIT);console.log(bestBid(snapshot)); // → 1.1050console.log(bestBid(order)); // → undefined// Builders double as a typed read model:console.log(order.get('ClOrdID'), order.has('Price')); // → ORDER-1 false// The engine façade binds the same guard as `is`:const fix = createFixEngine<MessageBodies>(dictionary);console.log(fix.is(order, MsgType.NewOrderSingle)); // → true

Venue-custom tags do not require regenerating any of this — see extending a dictionary.