Extending a dictionary
Real venues extend FIX past the spec. Unknown tags inside a repeating group break group reconstruction, and encode only emits fields the dictionary places — so “just ignore the extra tags” is not an option in either direction. extendDictionary fixes both from one declaration, without forking or regenerating anything.
The worked example: cTrader’s 1007 and 1008
cTrader sends SymbolName(1007) and SymbolDigits(1008) inside the NoRelatedSym group of SecurityList (35=y). The sample first shows the failure with the stock FIX 4.4 dictionary, then the one declaration that makes the same bytes parse into named, typed, correctly-nested fields — and encode round-trip them byte-for-byte.
import { createFixEngine, defineExtension, extendDictionary, toEncodeMessage } from '@boarteam/fix';import { dictionary as fix44 } from '@boarteam/fix-dict-fix44';// cTrader sends SymbolName(1007) and SymbolDigits(1008) INSIDE the// NoRelatedSym repeating group of SecurityList (35=y) — tags FIX 4.4 has// never heard of:const raw = '8=FIX.4.4|9=149|35=y|49=CSERVER|56=BUYSIDE|34=3|52=20240101-12:00:00.000|' + '320=list-1|322=resp-1|560=0|146=2|55=EURUSD|1007=EURUSD|1008=5|' + '55=XAUUSD|1007=XAUUSD|1008=2|10=175|';// Unknown tags inside a group break group reconstruction:const plain = createFixEngine(fix44);const plainCodes = plain.parse(raw, { soh: '|' }).issues.map((i) => i.code);console.log(plainCodes.includes('parse/group-count-mismatch')); // → trueconsole.log(plainCodes.includes('parse/unknown-tag')); // → true// One declaration fixes parsing AND encoding — no forked dictionary:const ctrader = defineExtension({ id: 'ctrader', fields: { SymbolName: { tag: 1007, type: 'String' }, SymbolDigits: { tag: 1008, type: 'int' }, }, messages: { SecurityList: { groups: { NoRelatedSym: { append: ['SymbolName', 'SymbolDigits'] } } }, },});// Never throws: anything it did, skipped or reverted is reported as data// through stable extend/* issue codes. Here every note is informational —// cTrader's tags really do sit outside the FIX user-defined ranges:const { dictionary, issues } = extendDictionary(fix44, ctrader);console.log(issues.every((i) => i.severity === 'info')); // → trueconsole.log(issues[0].code); // → extend/tag-outside-user-rangeconst fix = createFixEngine(dictionary);const { message, issues: parseIssues } = fix.parse(raw, { soh: '|' });console.log(parseIssues.length); // → 0// The venue tags now parse as named, typed fields, nested per instrument:for (const instrument of message.groups[146] ?? []) { console.log(instrument.fields[1007]?.value, instrument.fields[1008]?.value);}// → EURUSD 5// → XAUUSD 2// And encode round-trips them byte-for-byte:console.log(fix.encode(toEncodeMessage(message), { soh: '|' }) === raw); // → trueAppend-only by design, reported as data
An extension can add fields, enum values on existing fields, components, whole messages (the standard header and trailer are injected for you), and place members into message bodies or repeating groups — after: 'Instrument' anchors a wire position, dotted paths like 'NoRelatedSym.NoUnderlyings' reach nested groups. Placements are append-only on purpose: a group’s first field is its entry delimiter, so extendDictionary reverts any operation that would shift one (extend/group-delimiter-shift), skips duplicates, and rejects placements that could re-parse into the wrong group (extend/ambiguous-boundary).
Like the rest of the engine, it never throws: everything it did, skipped or reverted is reported through stable extend/* issue codes, where error severity means the operation was not applied. So if the base dictionary passed validateDictionary and the extension result carries no error-severity issues, the result is safe to load — a check worth failing fast on at module init.
Typed maps from the same declaration
The declaration that extended the dictionary also drives the compile-time surface — no second tag list to maintain. tagsOf extracts a literal name → tag map, extendTags merges it into the package’s Tags, and invertTags gives the reverse lookup, all with literal types preserved (TS ≥ 5.0). msgTypesOf / extendMsgTypes / invertMsgTypes do the same for extension messages.
import { defineExtension, extendTags, invertTags, tagsOf } from '@boarteam/fix';import { Tags as Fix44Tags } from '@boarteam/fix-dict-fix44';const ctrader = defineExtension({ id: 'ctrader', fields: { SymbolName: { tag: 1007, type: 'String' }, SymbolDigits: { tag: 1008, type: 'int' }, },});// The SAME declaration that extended the dictionary drives the typed maps —// no duplicated tag list, and the literal typing survives: Tags.SymbolName// hovers as 1007, not as `number` (TS ≥ 5.0).export const Tags = extendTags(Fix44Tags, tagsOf(ctrader));export type TagName = keyof typeof Tags; // includes 'SymbolName'export const TagNames = invertTags(Tags);console.log(Tags.SymbolName); // → 1007console.log(Tags.Symbol); // → 55console.log(TagNames[1007]); // → SymbolNameFor a reusable dialect, put the declaration and the derived exports in one module mirroring a dict package’s surface (dictionary, Tags, TagNames, MsgType, MsgTypeNames) — the repository’s runnable example shows the pattern, including the alias types that keep emitted declarations small if the module ships as a package.