@rethinkhealth/hl7v2-util-visit
v0.4.2
Published
An AST visitor for HL7v2 messages
Maintainers
Readme
@rethinkhealth/hl7v2-util-visit
Introduction
This package provides a lightweight, type-safe visitor pattern for traversing HL7v2 AST trees. Built on top of the battle-tested unist-util-visit-parents, it adds HL7v2-specific context while delegating core traversal to a proven implementation.
What is this?
hl7v2-util-visit enables you to walk through any HL7v2 AST tree — from the root message down to individual subcomponents — with full context about where you are in the hierarchy. The visitor pattern:
- Works from any starting node (Root, Segment, Field, Component, etc.)
- Tracks ancestors from traversal root to current node
- Provides visit info (index, sequence, depth, metadata)
- Efficient O(n) traversal — Pre-computed index map for O(1) lookups
- Battle-tested core — Delegates traversal to
unist-util-visit-parents
When should I use this?
Use hl7v2-util-visit when you need to:
- Validate HL7v2 message structure
- Transform or annotate AST nodes
- Extract specific fields with context
- Analyze message patterns across the tree
- Implement custom processing rules that need parent/ancestor awareness
Install
npm install @rethinkhealth/hl7v2-util-visitUse
import { visit, EXIT, SKIP } from '@rethinkhealth/hl7v2-util-visit';
import { parse } from '@rethinkhealth/hl7v2-parser';
const message = parse('MSH|^~\\&|...\rPID|...');
// Visit all segments
visit(message, 'segment', (node, ancestors, info) => {
console.log(`Segment: ${info.metadata?.header} at depth ${info.depth}`);
});
// => Segment: MSH at depth 2
// => Segment: PID at depth 2
// Find fields with parent context
visit(message, 'field', (node, ancestors, info) => {
const segment = ancestors.find(n => n.type === 'segment');
console.log(`Field ${info.sequence} in segment`);
});
// Skip processing of sensitive segments
visit(message, (node, ancestors, info) => {
if (node.type === 'segment' && info.metadata?.header === 'NTE') {
return SKIP; // Skip NTE segment children
}
});API
visit(tree, visitor)
visit(tree, test, visitor)
Visit nodes in an HL7v2 AST tree.
Parameters
tree(Nodes) — Tree to traverse (can be any node type, not just Root)test(string | Partial<Nodes> | Test) — Optional filter:string: Match nodes by type (e.g.,'segment')Partial<Nodes>: Match nodes with matching properties (e.g.,{ name: 'PATIENT_GROUP' })Test: Custom function(node, ancestors) => boolean
visitor(Visitor) — Function called for each matching node
Returns
void
Important: Test vs Visitor Functions
If you pass a function as the second argument, it is always treated as a Visitor, never as a Test.
// WRONG - testFn will be treated as a visitor, not a test
visit(ast, (node) => node.type === 'segment', ...); // Missing visitor!
// CORRECT - Explicit 3-argument form
visit(ast, (node) => node.type === 'segment', (node, ancestors, info) => {
console.log('Visiting segment');
});
// CORRECT - Use string or object for simple tests
visit(ast, 'segment', (node, ancestors, info) => {
console.log('Visiting segment');
});Visitor Function
type Visitor<T extends Nodes = Nodes> = (
node: T,
ancestors: Nodes[],
info: VisitInfo
) => VisitorResult;The visitor receives:
node— Current AST nodeancestors— Array of ancestor nodes from root to parent (not including current node)info— Visit information with index, sequence, depth, and metadata
The visitor can return:
undefinedorvoid— Continue traversal normallySKIP— Skip children of current nodeEXIT— Stop traversal immediately
VisitInfo
interface VisitInfo {
/** 0-based index among siblings */
index: number;
/** 1-based sequence (HL7v2 convention). For segment-header: 0 */
sequence: number;
/** 1-based depth in tree (root = 1) */
depth: number;
/** Metadata (e.g., { header: "MSH" } or { name: "PATIENT" }) */
metadata: Record<string, unknown> | undefined;
}Important: index and sequence represent the node's position in the tree, not its position among filtered results. For example:
// Structure: MSH segment with fields at positions 1, 2, 3, 4
// Filter matches only field 3
visit(ast, (n) => n.type === 'field' && hasContent(n), (node, ancestors, info) => {
console.log(info.sequence); // => 3 (position in segment, not "1st match")
});This is correct because HL7v2 paths like PID.3 refer to tree positions, not filtered positions.
Automatic Metadata Extraction
The metadata field is populated automatically with common metadata:
| Node Type | Metadata Key | Description |
|-----------|--------------|-------------|
| segment | header | Segment identifier (e.g., "MSH", "PID") |
| group | name | Group name (e.g., "PATIENT_GROUP") |
Exports
import {
visit, // Main traversal function
EXIT, // Return to stop traversal
SKIP, // Return to skip children
} from '@rethinkhealth/hl7v2-util-visit';
import type {
VisitInfo, // { index, sequence, depth, metadata }
Visitor, // (node, ancestors, info) => VisitorResult
VisitorResult, // Return type from visitor
Test, // Filter predicate
Predicate, // (node, ancestors) => boolean
} from '@rethinkhealth/hl7v2-util-visit';Examples
Filter by Node Type
visit(ast, 'segment', (node, ancestors, info) => {
console.log(`Found segment: ${info.metadata?.header}`);
});Filter by Properties
visit(ast, { name: 'PATIENT_GROUP' }, (node, ancestors, info) => {
console.log('Inside PATIENT_GROUP');
});Custom Test Function
// Visit fields in MSH segment only
visit(
ast,
(node, ancestors) => {
const parent = ancestors.at(-1);
return node.type === 'field' && parent?.type === 'segment';
},
(node, ancestors, info) => {
console.log(`Field at sequence ${info.sequence}`);
}
);Access Parent and Ancestors
visit(ast, 'component', (node, ancestors, info) => {
// Get immediate parent
const parent = ancestors.at(-1);
// Find closest segment ancestor
const segment = ancestors.findLast(n => n.type === 'segment');
console.log(`Component at depth ${info.depth}`);
});Control Flow: Skip Children
visit(ast, (node, ancestors, info) => {
if (node.type === 'segment' && info.metadata?.header === 'OBX') {
return SKIP; // Don't process OBX segment children
}
});Control Flow: Exit Early
import { EXIT } from '@rethinkhealth/hl7v2-util-visit';
let found = false;
visit(ast, 'field', (node, ancestors, info) => {
if (/* some condition */) {
found = true;
return EXIT; // Stop traversal completely
}
});Start from Any Node
import { s, f, c } from '@rethinkhealth/hl7v2-builder';
// Create a standalone segment
const segment = s('PID', f(c('value1')), f(c('value2')));
// Traverse from segment (not root)
visit(segment, 'field', (node, ancestors, info) => {
console.log(`Field at sequence ${info.sequence}`);
});Track Nesting Levels
visit(ast, (node, ancestors, info) => {
const indent = ' '.repeat(info.depth - 1);
console.log(`${indent}${node.type} [${info.sequence}]`);
});
// Output:
// root [1]
// segment [1]
// segment-header [0]
// field [1]
// field-repetition [1]
// component [1]Group Hierarchy Navigation
visit(ast, 'segment', (node, ancestors, info) => {
// Get all parent groups
const groups = ancestors
.filter(n => n.type === 'group')
.map(n => (n as any).name)
.filter((name): name is string => typeof name === 'string');
console.log(`${info.metadata?.header} is in groups: ${groups.join(' > ')}`);
});
// => PID is in groups: PATIENT_GROUPReal-World Use Cases
Validate Required Fields
function validateRequiredFields(ast: Root): string[] {
const errors: string[] = [];
visit(ast, 'segment', (node, ancestors, info) => {
const segment = node as Segment;
const header = info.metadata?.header;
// MSH segment must have at least 12 fields
if (header === 'MSH' && segment.children.length < 12) {
errors.push(`MSH segment missing required fields`);
}
// PID segment must have patient ID (PID.3)
if (header === 'PID') {
const patientId = segment.children[3];
if (!patientId || patientId.children.length === 0) {
errors.push('PID segment missing required Patient ID (PID.3)');
}
}
});
return errors;
}Extract Specific Data with Context
interface PatientName {
name: string;
sequence: number;
inGroup?: string;
}
function extractPatientNames(ast: Root): PatientName[] {
const names: PatientName[] = [];
visit(ast, 'segment', (node, ancestors, info) => {
if (info.metadata?.header !== 'PID') return;
const segment = node as Segment;
const nameField = segment.children[5];
if (nameField?.children[0]?.children[0]) {
const nameComponent = nameField.children[0].children[0];
const name = (nameComponent.children[0] as Subcomponent)?.value || '';
const groupAncestor = ancestors.find(n => n.type === 'group');
names.push({
name,
sequence: info.sequence,
inGroup: groupAncestor ? (groupAncestor as any).name : undefined,
});
}
});
return names;
}Message Structure Analysis
interface MessageStructure {
segmentCount: number;
groupCount: number;
maxDepth: number;
segmentTypes: Record<string, number>;
}
function analyzeStructure(ast: Root): MessageStructure {
const structure: MessageStructure = {
segmentCount: 0,
groupCount: 0,
maxDepth: 0,
segmentTypes: {},
};
visit(ast, (node, ancestors, info) => {
structure.maxDepth = Math.max(structure.maxDepth, info.depth);
if (node.type === 'segment') {
structure.segmentCount++;
const header = info.metadata?.header as string;
if (header) {
structure.segmentTypes[header] = (structure.segmentTypes[header] || 0) + 1;
}
}
if (node.type === 'group') {
structure.groupCount++;
}
});
return structure;
}Find First Match and Exit
function findFirstObservation(ast: Root, targetCode: string): string | null {
let result: string | null = null;
visit(ast, 'segment', (node, ancestors, info) => {
if (info.metadata?.header !== 'OBX') return;
const segment = node as Segment;
const identifierField = segment.children[3];
const code = identifierField?.children[0]?.children[0]?.children[0]?.value;
if (code === targetCode) {
const valueField = segment.children[5];
result = valueField?.children[0]?.children[0]?.children[0]?.value || null;
return EXIT; // Stop traversal
}
});
return result;
}Architecture
This library is a thin wrapper around unist-util-visit-parents that adds HL7v2-specific features:
- Core Traversal: Delegates to
unist-util-visit-parents(battle-tested with 50M+ weekly downloads) - Index Optimization: Pre-computes node indices for O(1) lookups (avoids O(n²)
indexOfcalls) - HL7v2 Context: Adds
VisitInfowith sequence numbers, depth tracking, and metadata extraction - Domain Conventions: Implements HL7v2-specific indexing (segment-header sequence = 0, fields = 1,2,3...)
This design gives us:
- ✅ Proven traversal logic (EXIT/SKIP handling, edge cases)
- ✅ Mutation support (inherited from unist)
- ✅ Reverse traversal support (pass
reverse: trueto underlying library) - ✅ Small codebase (~120 lines vs previous ~180 lines)
- ✅ Ecosystem compatibility (works with other unist utilities)
Performance Characteristics
- O(n) traversal — Single pass through all nodes
- O(n) index pre-computation — One-time upfront cost
- O(1) index lookups — Avoids O(n²)
indexOf()anti-pattern - O(d) ancestor construction where d = depth (typically < 10 for HL7v2)
- Minimal allocations — Metadata extracted once per node
Types
export type {
VisitInfo, // { index, sequence, depth, metadata }
Visitor, // (node, ancestors, info) => VisitorResult
VisitorResult, // void | false | 'skip' | ActionTuple
Test, // string | Partial | predicate | null
Predicate, // (node, ancestors) => boolean
} from '@rethinkhealth/hl7v2-util-visit';Contributing
We welcome contributions! Please see our Contributing Guide for more details.
Code of Conduct
To ensure a welcoming and positive environment, we have a Code of Conduct that all contributors and participants are expected to adhere to.
License
Copyright 2025 Rethink Health, SUARL. All rights reserved.
This program is licensed to you under the terms of the MIT License. See the LICENSE file for details.
