TypeScript MARC21 library for Node.js and browsers
- Four formats — ISO2709 binary, MARCXML, MARC-in-JSON, MARCBreaker/marctxt
- Consistent API — every format uses
parse*(input) → MarcRecord[]andserialize*(records) → output - Functional-style — all operations return new objects; originals are never mutated
- Zero dependencies — no runtime deps, including no XML parser. MARCXML has only 5 element types with no arbitrary nesting, so it's parsed with a lightweight hand-rolled tokenizer instead of a full DOM/SAX library. Works in Node.js and modern browsers.
- Fully typed — strict TypeScript throughout
npm install marc-tsimport { parseMarcBinary, serializeMarcBinary, title, author } from 'marc-ts';
import { parseMarcXml, serializeMarcXml } from 'marc-ts/xml';
import { parseMarcJson, serializeMarcJsonString } from 'marc-ts/json';
import { parseMarcTxt, serializeMarcTxt } from 'marc-ts/txt';
const records = parseMarcBinary(buffer);
console.log(title(records[0]));
const xmlRecords = parseMarcXml(xmlString);
const xml = serializeMarcXml(xmlRecords);
const jsonRecords = parseMarcJson(jsonString);
const json = serializeMarcJsonString(jsonRecords);
const txtRecords = parseMarcTxt(txtString);
const txt = serializeMarcTxt(txtRecords);import { parseMarcBinary, serializeMarcBinary } from 'marc-ts';Splits on 0x1D record terminators and parses each record. Failed records are skipped in lenient mode; strict: true throws on the first error.
| Option | Type | Default | Description |
|---|---|---|---|
strict |
boolean |
false |
Throw on fatal parse errors instead of skipping |
maxWarnings |
number |
100 |
Stop collecting warnings after this many (per record) |
Leader byte 9 controls character decoding: 'a' = UTF-8, ' ' = MARC-8. MARC-8 handles ANSEL Latin, Greek, Hebrew, Cyrillic, Arabic, and sub/superscript scripts. EACC/CJK coverage is minimal (~33 of ~16k triples) — prefer UTF-8 sources for CJK catalogs.
Same as parseMarcBinary, but returns per-record results with warnings. Failed records appear with record: null.
| Option | Type | Default | Description |
|---|---|---|---|
encoding |
'utf8' | 'marc8' |
'utf8' |
Character encoding; 'marc8' replaces unsupported Unicode with ? |
maxWarnings |
number |
100 |
Stop collecting warnings after this many (per record) |
Same as serializeMarcBinary, but returns per-record serialization warnings alongside the bytes.
import { parseMarcXml, serializeMarcXml } from 'marc-ts/xml';Accepts <collection>, bare <record> elements, or namespace-prefixed variants. Returns [] for empty input.
Produces a full MARCXML <collection> document with XML declaration and MARC21 namespace.
import { parseMarcJson, serializeMarcJson, serializeMarcJsonString } from 'marc-ts/json';Implements the MARC-in-JSON spec.
Accepts a JSON string (array or single object), a MarcJsonObject[], or a single MarcJsonObject.
import { parseMarcTxt, serializeMarcTxt } from 'marc-ts/txt';Line-oriented format: one field per line, blank lines between records, $ for subfield delimiters, \ for blank indicators.
Reserved characters are escaped: $ → {dollar}, { → {lcub}, } → {rcub}, \ → {bsol}.
import {
title,
titleProper,
author,
edition,
publisher,
publicationDate,
isbn,
issn,
lccn,
subjects,
seriesStatement,
} from 'marc-ts';| Function | Source field | Returns |
|---|---|---|
title(record) |
245 $a$b | Full title with subtitle |
titleProper(record) |
245 $a | Main title only |
author(record) |
100/110 $a | Main author/creator |
edition(record) |
250 $a | Edition statement |
publisher(record) |
260/264 $b | Publisher name |
publicationDate(record) |
260/264 $c | Publication date |
isbn(record) |
020 $a | string[] of ISBNs |
issn(record) |
022 $a | ISSN |
lccn(record) |
010 $a | Library of Congress Control Number |
subjects(record) |
6XX $a | string[] of subject headings |
seriesStatement(record) |
490 $a | Series statement |
import { getField, getFields, getSubfield, getSubfields, getAllSubfields } from 'marc-ts';
import { isControlField, isDataField } from 'marc-ts';const field = getField(record, '245'); // first match or undefined
const fields = getFields(record, '650'); // all matches
if (field && isDataField(field)) {
const a = getSubfield(field, 'a');
const xs = getSubfields(field, 'x');
const all = getAllSubfields(field); // [{ code, value }, ...]
}import { getFieldsByPattern, getFirstFieldByPattern } from 'marc-ts';
const subjects = getFieldsByPattern(record, '6..'); // all 6XX fields. and X each match any single digit.
import { getBySpec, getValuesBySpec, parseMarcSpec } from 'marc-ts';
getValuesBySpec(record, '245$a'); // ['The Catcher in the Rye /']
getValuesBySpec(record, '650$a-c'); // subfield range
getValuesBySpec(record, '300[0]$a'); // first occurrence (0-indexed)
getValuesBySpec(record, '300[#]$a'); // last occurrence
getValuesBySpec(record, '245$a/0-2'); // character range -> 'The'
getValuesBySpec(record, '880^1'); // indicator 1
getValuesBySpec(record, 'LDR/6'); // leader character range
const matches = getBySpec(record, '650$x');
// [{ tag: '650', occurrence: 0, subfieldCode: 'x', value: '...' }, ...]
const ast = parseMarcSpec('245$a/0-2'); // parse without evaluating
// Predicate subspecs ("{...}") filter which occurrences match:
getValuesBySpec(record, '020$c{?020$a}'); // keep $c only where a sibling $a exists
getValuesBySpec(record, '020$z{!020$a}'); // keep $z only where no sibling $a exists
getValuesBySpec(record, '020$c{$q=\\paperback|$q=\\hardcover}'); // OR
getValuesBySpec(record, '008/18{LDR/6=\\a}{LDR/7=\\a|LDR/7=\\c}'); // AND (adjacent subspecs)Implements the field/subfield addressing subset of the MARCspec standard: tags (incl. . wildcards and LDR), subfield codes and ranges ($a-c), character ranges (/1-3, /# for last), 0-indexed field/subfield occurrences ([0], [#] for last), and indicators (^1, ^2). A spec that's syntactically valid but matches nothing returns [].
Also implements comparison/predicate subspecs ({...}), which filter which occurrences of the attached field/subfield/indicator spec are kept:
- Operators:
=(equal),!=(unequal),~(includes/substring),!~(not includes),?(exists),!(not exists).?/!are unary (test the right-hand operand only); the rest compare a left and right operand. - Operands can be a literal string (
\-escaped — reserved characters$ { } ! = ~ ? |must be escaped, and a literal space is written\s), or a nested field/subfield/indicator spec, which is cross-referenced against the record. A nested spec can use abbreviation shorthand, omitting the tag to inherit it from the spec the subspec is attached to (020$c{$a}is shorthand for020$c{020$a}). A same-tag or abbreviated reference with no explicit occurrence index is scoped to the same field occurrence the outer spec is currently on (e.g.020$c{?020$a}checks for a sibling$awithin the same020instance); a different tag, or an explicit index, resolves independently against the whole record. - Omitting the operator defaults it to
?(existence), andleftto the spec's own base (e.g.020$c{020$a}is equivalent to020$c{?020$a}). - Multiple
subTermSets separated by|inside one{...}are OR'd together; adjacent{...}{...}subspecs are AND'd together.
All operations return new objects — originals are never mutated.
import {
appendField,
insertFieldBefore,
insertFieldAfter,
insertGroupedField,
removeFields,
removeField,
addSubfield,
removeSubfield,
replaceSubfield,
} from 'marc-ts';
const r1 = appendField(record, newField);
const r2 = insertFieldBefore(record, '700', newField);
const r3 = insertFieldAfter(record, '245', newField);
const r4 = insertGroupedField(record, newField); // maintains MARC tag order
const r5 = removeFields(record, '650');
const r6 = removeField(record, specificField); // reference equality
const f1 = addSubfield(field, 'b', 'Subtitle');
const f2 = removeSubfield(field, 'x');
const f3 = replaceSubfield(field, 'a', 'New value');import { cloneRecord, recordsEqual, fieldsEqual } from 'marc-ts';
const copy = cloneRecord(record);
recordsEqual(a, b); // strict field order
recordsEqual(a, b, true); // ignore field order
fieldsEqual(field1, field2);import type {
MarcRecord,
ControlField,
DataField,
Subfield,
ParseOptions,
SerializeOptions,
MarcWarning,
MarcWarningType,
} from 'marc-ts';Requires Node.js 20.19 or 22.12+ (driven by Vite 8).
npm test # run tests
npm run build # compile to dist/
npm run type-check # TypeScript check without emit