@boarteam/fix
v0.7.0
Published
Dictionary-driven FIX protocol toolkit: parse, validate, and encode FIX messages. Zero runtime dependencies; runs in the browser and Node.
Readme
@boarteam/fix
A dictionary-driven FIX protocol toolkit for TypeScript — parse, validate, and encode
FIX messages with zero runtime dependencies, in the browser
or Node. This is the engine; pair it with a dictionary such as
@boarteam/fix-dict-fix44.
📖 Documentation · 🧭 API reference · ▶️ Playground · 💻 GitHub
On a 0.x line: the foundation is solid and well-tested, and the API may still refine ahead of 1.0. See the project README for coverage, testing, and the roadmap — feedback welcome.
import { createFixEngine } from '@boarteam/fix';
import { dictionary } from '@boarteam/fix-dict-fix44';
const fix = createFixEngine(dictionary);
const { message, issues } = fix.parse(raw); // never throws; issues is FixIssue[]
const problems = fix.validate(message); // presence, enums, datatypes, conditional rules
const wire = fix.encode({
msgType: 'D',
fields: {/* ... */},
}); // ordered + framed- Stateless analyzer, not a session/transport engine: no sockets, sequence numbers, or heartbeats — just the protocol.
- Pure & deterministic; never throws on the analyze path (diagnostics are returned
data); browser + Node via
TextEncoder/TextDecoder.
Typed, self-rendering messages
encode(EncodeMessage) is the low-level primitive — an untyped, tag-keyed bag. For a
statically-typed encode side, the dictionary packages ship a message factory generated
from the same dictionary: creating a message for a MsgType yields a builder that knows only
that message's fields/groups and their value types, and renders byte-identical to encode.
import { message, MsgType } from '@boarteam/fix-dict-fix44';
const wire = message(MsgType.MarketDataSnapshotFullRefresh) // typed to this message's body
.set('MDReqID', 'req-1')
.set('Symbol', 'EURUSD')
.set('NoMDEntries', [
{ MDEntryType: '0', MDEntryPx: '1.1050' }, // group entries are typed too
{ MDEntryType: '1', MDEntryPx: '1.1052' },
])
.render({
SenderCompID: 'ME',
TargetCompID: 'YOU',
MsgSeqNum: 1,
SendingTime: '20260716-12:00:00',
});- Value types come from the field datatype: enumerated → the generated value union
(
MDEntryType);Boolean→boolean; numeric (int/float/Price/Qty/…) →number | string(the string is the exact-formatting escape hatch — anumberfloat is rendered byString(), so pass a string when you need an exact form like1.10); everything else →string. Illegal fields, wrong value types, and malformed group entries are compile errors. - Transport/session-agnostic: envelope fields (
MsgSeqNum/SenderCompID/SendingTime/TargetCompID; framing8/9/10are computed) are supplied torender(envelope)— the library never holds a sequence counter, clock, or comp-IDs. The body type excludes them. - Mutable or immutable:
message(...)is a fast, fluent mutable builder for hot loops;message.immutable(...)(and.toImmutable()) is copy-on-write. Both accept a complete bulk init (message('A', { EncryptMethod: 0, HeartBtInt: 30 })— required keys must be named;.assign({...})stays partial) and double as a typed read model (msg.get('Symbol'),msg.get('NoMDEntries')?.[0]?.MDEntryPx) for deriving log metadata. - Absent values pass through: an init/
setvalue ofundefined,null, or''is skipped at render and reads as unset —message('A', { …, Username: username })needs noif (username)guard, in the body, in group entries, and in the envelope alike. A required key can be deliberately left off the wire by naming it withundefined(dialect quirks); to force a literal empty value onto the wire, drop to theencodeprimitive. - The engine façade mirrors it:
createFixEngine<MessageBodies>(dictionary).create(msgType).
Narrowing an unknown message. At a generic boundary — a send(message: MessageView<any>),
a log-metadata helper — the concrete MsgType is erased, so message.msgType === 'W' cannot
narrow the body. A Bodies-bound type guard keyed on msgType restores it: the runtime is a
plain string compare, the typing comes from the Bodies registry. The engine binds one as
engine.is; the dict packages re-export a ready-made isMessageType (and a MessageOf<M> alias
for annotations), so no engine instance is needed. messageTypeGuard<Bodies>() builds a standalone
guard.
import { isMessageType, MsgType } from '@boarteam/fix-dict-fix44';
import type { MessageView } from '@boarteam/fix';
function logMeta(message: MessageView<any>) {
if (isMessageType(message, MsgType.MarketDataSnapshotFullRefresh)) {
// message: MessageView<MarketDataSnapshotFullRefreshBody> — get() is typed, no casts
const securityID = message.get('SecurityID'); // string | undefined
for (const entry of message.get('NoMDEntries') ?? []) {
entry.MDEntryType; // MDFullGrp_NoMDEntriesEntry — entry fields typed
}
}
}Both flavours narrow to the read surface MessageView (the interface the mutable and immutable
message share), not to the mutable/immutable kind — the guard's purpose is typed reads, not
re-obtaining a builder.
Venue extensions (e.g. a broker's custom tags in an existing group) need no regenerated
dictionary: extendDictionary(dictionary, ext) adds them at runtime, and the generated,
per-container group/component interfaces (SecListGrp_NoRelatedSymEntry, …) are augmentable —
patch the shared interface via declaration merging (single venue) or an override-interface
intersection (multiple venues), then rebind messageFactory<MessageBodies>(extended). The
augmented type and the extended dictionary ship as a pair.
Reading an inbound message
The same per-message types work on the way in. parse returns a faithful but tag-keyed
ParsedMessage (message.fields[262].value, groups under message.groups[268]), which is the
right shape for a codec and the wrong one for application code — it leaves you writing a
switch (msgType) and a tag-to-field mapper per message, re-deriving what the dictionary
already knows. toInbound re-keys it by name, so a received message reads the way a built one
does.
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.
const dictionary = loadDictionary(fix44);
const isKnownInbound = inboundKnownGuard<MessageBodies>(dictionary);
const { message, issues } = parse(raw, dictionary); // gate on `issues` first
const inbound = toInbound(message, dictionary);
inbound.envelope.MsgSeqNum; // the session envelope, before narrowing
if (isKnownInbound(inbound)) {
switch (
inbound.msgType // narrows per case
) {
case MsgType.Logon:
inbound.get('HeartBtInt'); // typed to LogonBody
break;
case MsgType.MarketDataSnapshotFullRefresh:
inbound.get('NoMDEntries')?.[0]?.MDEntryPx; // the typed entry array
break;
default:
break; // known, but not handled here
}
}- Body by name, envelope on the side. Repeating groups are arrays of entry objects under
their counter's name (
NoMDEntries,NoRelatedSym) — the declared counter is not a property, because the entry array is the count. Header/trailer fields land onenvelope, matching the body types, which exclude them by construction. Which tags count as envelope is decided by the dictionary's header/trailer components (Dictionary.envelopeTags()), not by a fixed list. switch (inbound.msgType)narrows onceisKnownInboundhas run. The guard is not ceremony: an unrecognisedMsgTypecannot be a member of the message union — amsgType: stringmember overlaps every literal, so nocaseeliminates it and its loosegetmakes every branch uncallable — and it does not parse like one either, being read flat with no groups reconstructed.inboundTypeGuardis the single-MsgTypeifform. Handle every message anddefaultnarrows tonever; handle a subset and it stays live and typed.- It is a
MessageView, so a received message also renders:inbound.render(envelope)re-emits the body under a fresh envelope — the useful shape for a proxy that re-signs what it forwards. For a byte-exact echo, go throughtoEncodeMessage(inbound.parsed)instead:ParsedField.rawis the round-trip source of truth, and a body carries the coerced value. - Nothing is re-parsed or re-validated.
toInboundis a pure re-keying of whatparseproduced; every diagnostic was already reported there, andvalidate()still runs against theParsedMessage, which stays reachable asinbound.parsed. - The engine façade mirrors it:
createFixEngine<MessageBodies>(dictionary)binds.inbound(),.isInboundand.isKnown.
One deliberate sharp edge: the body type of an un-narrowed inbound message is any, not a loose
index-signature type. A generated body is an interface, and TypeScript never gives an interface
an implicit index signature — so no loose type is ever its supertype, and a guard narrowing to
one would silently produce an intersection whose loose get overload wins at every call site.
InboundBody is exported for callers who mean not to narrow; pass it explicitly.
Examples and the contribution guide live in the monorepo.
FIX 5.0 SP2 / FIXT.1.1
FIX 5.0 splits the wire into a session protocol (tag 8 carries FIXT.1.1) and an
application version negotiated via DefaultApplVerID(1137) / per-message ApplVerID(1128).
The engine models that split first-class — every codec entry point accepts either a single
dictionary or a transport/application pair:
import { createFixEngine } from '@boarteam/fix';
import { dictionary as fixt11 } from '@boarteam/fix-dict-fixt11';
import { dictionary as fix50sp2 } from '@boarteam/fix-dict-fix50sp2';
const fix = createFixEngine({ transport: fixt11, app: fix50sp2 });
const { message } = fix.parse(raw); // 8=FIXT.1.1 frames, SP2 bodies
for (const issue of fix.validate(message)) {
// issue.layer: 'session' findings → answer with a Reject(3);
// 'application' findings → a BusinessMessageReject(j).
}Session (admin) messages are transport-owned — an application-only field on one is flagged
validate/field-outside-layer — and multi-version sessions route per message through an
optional resolveApp(applVerID) hook with a caller-supplied defaultApplVerID (the engine
holds no session state). Prefer a single dictionary? @boarteam/fix-dict-fix50sp2 is the
pair pre-merged (a drop-in for createFixEngine(dictionary)), and
mergeFixtDictionaries(transport, app) builds the same shape from your own app-layer
dialect.
Documentation
The full documentation is at boar.team/fix/docs — guides for
parsing, validation,
encoding,
typed messages,
dictionary extensions and
choosing a dictionary. Every sample on those pages is
compiled and executed against the released package at build time, so they cannot drift from what
you install. Alongside them: a catalogue of every issue code
parse and validate can emit, a browser
playground running this engine client-side, and a browsable
FIX dictionary reference.
API reference & dist/api.json
Every export is documented, signature by signature, in the generated
API reference, and the tarball ships the model it is
rendered from: dist/api.json — export names, verbatim signatures, doc comments, source
positions, and the version each export first shipped in. The emitter gates this package's
own CI (doc coverage, {@link} integrity, SemVer-checked API diffs), so the model is held
to the code, not the other way round. Schema and guarantees:
docs/api-json.md.
License
Apache-2.0 © Boar Team. See NOTICE. "FIX" is a trademark of FIX
Protocol Limited; this is an independent project, not affiliated with or endorsed by it.
