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 body (parsed payload), transport-specific blocks (http, tcp, etc.), 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 (mutate msg.body or return partial { body })
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const parsed = typeof msg.body === "string" ? JSON.parse(msg.body) : msg.body;
return { body: JSON.stringify({ wrapped: parsed }) };
}
// 1:many — fan out a FHIR Bundle into individual resources
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage[] {
const bundle = msg.body as { entry?: Array<{ resource?: unknown }> };
return (bundle.entry ?? []).map((entry) => ({
...msg,
body: entry.resource,
}));
}
// Filter — drop messages that don't match criteria
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage | null {
const raw = typeof msg.body === "string" ? msg.body : "";
if (!raw.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 {
body: unknown; // Parsed payload (string or object)
transport?: string;
contentType?: string;
sourceCharset?: string;
metadata?: Record<string, unknown>;
http?: IntuHTTP; // Transport-specific metadata
tcp?: IntuTCP;
kafka?: IntuKafka;
file?: IntuFile;
// ... other transport blocks
}
interface IntuContext {
channelId: string;
messageId: string;
correlationId: string;
globalMap: Record<string, unknown>;
channelMap: Record<string, unknown>;
responseMap: Record<string, unknown>;
connectorMap?: Record<string, unknown>;
}

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 raw = typeof msg.body === "string" ? msg.body : "";
const hl7 = new Hl7Message(raw);
const patientName = hl7.get("PID.5");
const mrn = hl7.get("PID.3.1");
return {
body: {
resourceType: "Patient",
identifier: [{ system: "http://hospital.example/mrn", value: mrn }],
name: [{ family: patientName.split("^")[0], given: [patientName.split("^")[1]] }],
},
};
}
import { Hl7Message } from "node-hl7-client";
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const raw = typeof msg.body === "string" ? msg.body : "";
const hl7 = new Hl7Message(raw);
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,
},
],
};
return { body: patient };
}
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(typeof msg.body === "string" ? msg.body : "");
stream.end();
});
return rows.map((row) => ({
...msg,
body: {
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 = (typeof msg.body === "object" && msg.body !== null) ? msg.body as Record<string, unknown> : {};
return {
body: {
id: input.patient_id,
fullName: `${input.first_name} ${input.last_name}`,
dob: input.date_of_birth,
facility: input.sending_facility ?? "UNKNOWN",
receivedAt: new Date().toISOString(),
},
};
}
import { XMLParser } from "fast-xml-parser";
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const parser = new XMLParser({ ignoreAttributes: false });
const raw = typeof msg.body === "string" ? msg.body : "";
const doc = parser.parse(raw);
const patient = doc.ClinicalDocument?.recordTarget?.patientRole?.patient;
return {
body: {
resourceType: "Patient",
name: [
{
family: patient?.name?.family,
given: [patient?.name?.given],
},
],
gender: patient?.administrativeGenderCode?.["@_code"] === "M" ? "male" : "female",
birthDate: patient?.birthTime?.["@_value"],
},
};
}

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 = (typeof msg.body === "object" && msg.body !== null) ? { ...(msg.body as object) } : {};
data.sequenceNumber = seq;
const facilityName = (ctx.globalMap as Record<string, unknown>).facilityLookup &&
(ctx.globalMap as Record<string, unknown>).facilityLookup instanceof Map
? ((ctx.globalMap as Record<string, unknown>).facilityLookup as Map<string, string>).get(data.facilityCode) ?? "Unknown"
: "Unknown";
data.facilityName = facilityName;
return { body: data };
}

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: src/channels/my-channel/fhir-transformer.ts
- ref: data-warehouse
transformer: 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 = typeof msg.body === "string" ? JSON.parse(msg.body) : msg.body;
return {
body: {
resourceType: "Bundle",
type: "transaction",
entry: [{ resource, request: { method: "PUT", url: `${(resource as { resourceType?: string; id?: string }).resourceType}/${(resource as { resourceType?: string; id?: string }).id}` } }],
},
};
}
// warehouse-transformer.ts — flatten to a row for the data warehouse
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const patient = (typeof msg.body === "object" && msg.body !== null) ? msg.body as Record<string, unknown> : {};
return {
body: {
mrn: (patient.identifier as Array<{ value?: string }>)?.[0]?.value,
last_name: (patient.name as Array<{ family?: string }>)?.[0]?.family,
first_name: (patient.name as Array<{ given?: string[] }>)?.[0]?.given?.[0],
dob: patient.birthDate,
gender: patient.gender,
loaded_at: new Date().toISOString(),
},
};
}

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 = (typeof msg.body === "object" && msg.body !== null) ? msg.body as Record<string, unknown> : {};
(ctx.responseMap as Record<string, unknown>).fhirId = response.id;
(ctx.responseMap as Record<string, unknown>).fhirStatus = (response.meta as Record<string, unknown>)?.versionId;
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 = typeof msg.body === "string" ? msg.body : "";
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
raw = raw.replace(/\r\n/g, "\r");
return { ...msg, body: raw };
}
// postprocessor.ts — record metrics after all destinations complete
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage {
const fhirId = (ctx.responseMap as Record<string, unknown>).fhirId;
(ctx.channelMap as Record<string, unknown>).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 = (typeof msg.body === "object" && msg.body !== null) ? msg.body as Record<string, unknown> : {};
if (!data.patient_id) {
throw new Error("Missing required field: patient_id");
}
if (typeof data.mrn !== "string" || !data.mrn.match(/^MRN-\d{8}$/)) {
throw new Error(`Invalid MRN format: ${data.mrn}`);
}
return { body: data };
}

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 raw = typeof msg.body === "string" ? msg.body : "";
const hl7 = new Hl7Message(raw);
const patient = buildPatient(
hl7.get("PID.3.1"),
hl7.get("PID.5.1"),
hl7.get("PID.5.2")
);
return { body: patient };
}