Skip to content

Transformers

Transformers are the core logic of every channel. Written in TypeScript, they receive an incoming message, manipulate it however you need, and return the result to continue through the pipeline. Because transformers are plain TypeScript functions, you have the full npm ecosystem at your disposal — parse HL7v2 with node-hl7-client, build FHIR resources, convert CSV, reshape JSON, or call any third-party API.

Every transformer exports a single transform function:

export function transform(
msg: IntuMessage,
ctx: IntuContext
): IntuMessage | IntuMessage[] | null;
ParameterTypeDescription
msgIntuMessageThe inbound message with rawData, content, headers, and metadata
ctxIntuContextPipeline context — map variables, logger, channel config

The return type controls how the pipeline handles the transformer output:

ReturnBehaviorUse Case
IntuMessageOne message continues through the pipeline1:1 transformation
IntuMessage[]Each element continues independentlyFan-out / batch splitting
nullMessage is silently droppedFiltering / conditional routing
// 1:1 — transform a single message
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
msg.content = JSON.stringify({ wrapped: JSON.parse(msg.rawData) });
return msg;
}
// 1:many — fan out a FHIR Bundle into individual resources
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage[] {
const bundle = JSON.parse(msg.rawData);
return bundle.entry.map((entry: any) => ({
...msg,
content: JSON.stringify(entry.resource),
}));
}
// Filter — drop messages that don't match criteria
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage | null {
const hl7 = msg.rawData;
if (!hl7.startsWith("MSH|")) return null;
return msg;
}

TypeScript is compiled automatically when the engine starts. During development (npm run dev), the engine watches for file changes and recompiles on save — no manual build step required.

Every scaffolded project includes src/types/intu.d.ts, which declares the IntuMessage and IntuContext interfaces. Your editor provides autocomplete, type checking, and inline documentation out of the box.

interface IntuMessage {
rawData: string;
content: string;
headers: Record<string, string>;
sourceType: string;
channelId: string;
messageId: string;
timestamp: string;
}
interface IntuContext {
logger: IntuLogger;
globalMap: Map<string, any>;
channelMap: Map<string, any>;
responseMap: Map<string, any>;
connectorMap: Map<string, any>;
channelId: string;
channelName: string;
}

Transformers run in a standard Node.js environment. Install any npm package and import it directly:

Terminal window
npm install node-hl7-client fhir fast-csv fast-xml-parser
import { Hl7Message } from "node-hl7-client";
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const hl7 = new Hl7Message(msg.rawData);
const patientName = hl7.get("PID.5");
const mrn = hl7.get("PID.3.1");
msg.content = JSON.stringify({
resourceType: "Patient",
identifier: [{ system: "http://hospital.example/mrn", value: mrn }],
name: [{ family: patientName.split("^")[0], given: [patientName.split("^")[1]] }],
});
return msg;
}
import { Hl7Message } from "node-hl7-client";
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const hl7 = new Hl7Message(msg.rawData);
const pid = {
family: hl7.get("PID.5.1"),
given: hl7.get("PID.5.2"),
dob: hl7.get("PID.7"),
gender: hl7.get("PID.8"),
mrn: hl7.get("PID.3.1"),
ssn: hl7.get("PID.19"),
address: {
line: hl7.get("PID.11.1"),
city: hl7.get("PID.11.3"),
state: hl7.get("PID.11.4"),
postalCode: hl7.get("PID.11.5"),
},
};
const patient = {
resourceType: "Patient",
identifier: [
{ system: "http://hospital.example/mrn", value: pid.mrn },
{ system: "http://hl7.org/fhir/sid/us-ssn", value: pid.ssn },
],
name: [{ family: pid.family, given: [pid.given] }],
birthDate: `${pid.dob.slice(0, 4)}-${pid.dob.slice(4, 6)}-${pid.dob.slice(6, 8)}`,
gender: pid.gender === "M" ? "male" : pid.gender === "F" ? "female" : "unknown",
address: [
{
line: [pid.address.line],
city: pid.address.city,
state: pid.address.state,
postalCode: pid.address.postalCode,
},
],
};
msg.content = JSON.stringify(patient);
return msg;
}
import { parse } from "fast-csv";
export async function transform(
msg: IntuMessage,
ctx: IntuContext
): Promise<IntuMessage[]> {
const rows: any[] = [];
await new Promise<void>((resolve, reject) => {
const stream = parse({ headers: true }).on("data", (row) => rows.push(row)).on("end", resolve).on("error", reject);
stream.write(msg.rawData);
stream.end();
});
return rows.map((row) => ({
...msg,
content: JSON.stringify({
resourceType: "Observation",
status: "final",
code: {
coding: [{ system: "http://loinc.org", code: row.loinc_code, display: row.test_name }],
},
subject: { reference: `Patient/${row.patient_id}` },
valueQuantity: {
value: parseFloat(row.value),
unit: row.unit,
system: "http://unitsofmeasure.org",
code: row.unit,
},
effectiveDateTime: row.collected_date,
}),
}));
}
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const input = JSON.parse(msg.rawData);
msg.content = JSON.stringify({
id: input.patient_id,
fullName: `${input.first_name} ${input.last_name}`,
dob: input.date_of_birth,
facility: input.sending_facility ?? "UNKNOWN",
receivedAt: msg.timestamp,
});
return msg;
}
import { XMLParser } from "fast-xml-parser";
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const parser = new XMLParser({ ignoreAttributes: false });
const doc = parser.parse(msg.rawData);
const patient = doc.ClinicalDocument?.recordTarget?.patientRole?.patient;
msg.content = JSON.stringify({
resourceType: "Patient",
name: [
{
family: patient?.name?.family,
given: [patient?.name?.given],
},
],
gender: patient?.administrativeGenderCode?.["@_code"] === "M" ? "male" : "female",
birthDate: patient?.birthTime?.["@_value"],
});
return msg;
}

Map variables let you share state across pipeline stages. They are available on ctx in every transformer:

MapScopeLifetimeUse Case
globalMapAll channelsEngine lifetimeLookup tables, shared config
channelMapSingle channelChannel lifetimeSequence counters, caches
responseMapSingle messageCurrent messageDestination response data
connectorMapSingle connector stepCurrent stepInter-step data passing
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
let seq = (ctx.channelMap.get("sequence") ?? 0) + 1;
ctx.channelMap.set("sequence", seq);
const data = JSON.parse(msg.rawData);
data.sequenceNumber = seq;
const facilityName = ctx.globalMap.get("facilityLookup")?.get(data.facilityCode) ?? "Unknown";
data.facilityName = facilityName;
msg.content = JSON.stringify(data);
return msg;
}

When a channel has multiple destinations, you can customize the output for each one. Place a transformer file alongside the destination reference in channel.yaml:

destinations:
- ref: fhir-server
transformer:
entrypoint: src/channels/my-channel/fhir-transformer.ts
- ref: data-warehouse
transformer:
entrypoint: src/channels/my-channel/warehouse-transformer.ts

Each destination transformer receives the output of the main channel transformer and can reshape it for that specific target:

// fhir-transformer.ts — wrap in a FHIR Bundle for the FHIR server
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const resource = JSON.parse(msg.content);
msg.content = JSON.stringify({
resourceType: "Bundle",
type: "transaction",
entry: [{ resource, request: { method: "PUT", url: `${resource.resourceType}/${resource.id}` } }],
});
return msg;
}
// warehouse-transformer.ts — flatten to a row for the data warehouse
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const patient = JSON.parse(msg.content);
msg.content = JSON.stringify({
mrn: patient.identifier?.[0]?.value,
last_name: patient.name?.[0]?.family,
first_name: patient.name?.[0]?.given?.[0],
dob: patient.birthDate,
gender: patient.gender,
loaded_at: new Date().toISOString(),
});
return msg;
}

Response transformers process the reply from a destination after delivery. They are useful for extracting IDs, logging acknowledgements, or feeding data back through responseMap.

destinations:
- ref: fhir-server
response_transformer:
entrypoint: src/channels/my-channel/response-handler.ts
response-handler.ts
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const response = JSON.parse(msg.rawData);
ctx.responseMap.set("fhirId", response.id);
ctx.responseMap.set("fhirStatus", response.meta?.versionId);
ctx.logger.info(`FHIR resource created: ${response.resourceType}/${response.id}`);
return msg;
}

Preprocessors run before the main transformer. Postprocessors run after all destinations have been written. Both use the same function signature.

transformer:
entrypoint: src/channels/my-channel/transformer.ts
preprocessor: src/channels/my-channel/preprocessor.ts
postprocessor: src/channels/my-channel/postprocessor.ts
// preprocessor.ts — strip BOM and normalize line endings before transformation
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
let raw = msg.rawData;
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
raw = raw.replace(/\r\n/g, "\r");
msg.rawData = raw;
msg.content = raw;
return msg;
}
// postprocessor.ts — record metrics after all destinations complete
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const fhirId = ctx.responseMap.get("fhirId");
ctx.logger.info(`Pipeline complete — FHIR ID: ${fhirId}, message: ${msg.messageId}`);
ctx.channelMap.set("lastProcessedAt", new Date().toISOString());
return msg;
}

Throwing an error inside a transformer stops the pipeline for that message. The message is routed to error handling (dead-letter queue, retry, or alert) based on your channel configuration.

export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const data = JSON.parse(msg.rawData);
if (!data.patient_id) {
throw new Error("Missing required field: patient_id");
}
if (!data.mrn?.match(/^MRN-\d{8}$/)) {
throw new Error(`Invalid MRN format: ${data.mrn}`);
}
msg.content = JSON.stringify(data);
return msg;
}

Shared logic can be extracted into code template libraries and reused across channels. Define templates in a code_templates directory and reference them:

my-project/
├── src/
│ ├── code_templates/
│ │ ├── hl7-helpers.ts
│ │ └── fhir-builders.ts
│ └── channels/
│ ├── channel-a/
│ │ └── transformer.ts ← imports from code_templates
│ └── channel-b/
│ └── transformer.ts ← imports from code_templates
src/code_templates/fhir-builders.ts
export function buildPatient(mrn: string, family: string, given: string) {
return {
resourceType: "Patient",
identifier: [{ system: "http://hospital.example/mrn", value: mrn }],
name: [{ family, given: [given] }],
};
}
export function buildObservation(
patientRef: string,
loincCode: string,
value: number,
unit: string
) {
return {
resourceType: "Observation",
status: "final",
code: { coding: [{ system: "http://loinc.org", code: loincCode }] },
subject: { reference: patientRef },
valueQuantity: { value, unit, system: "http://unitsofmeasure.org", code: unit },
};
}
src/channels/channel-a/transformer.ts
import { buildPatient } from "../../code_templates/fhir-builders";
import { Hl7Message } from "node-hl7-client";
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const hl7 = new Hl7Message(msg.rawData);
const patient = buildPatient(
hl7.get("PID.3.1"),
hl7.get("PID.5.1"),
hl7.get("PID.5.2")
);
msg.content = JSON.stringify(patient);
return msg;
}