Validators
Validators check messages before they reach destinations. They enforce schemas, required fields, data formats, and business rules — catching problems early so invalid data never leaves your pipeline.
Function Signature
Section titled “Function Signature”Every validator exports a single validate function:
export function validate( msg: IntuMessage, ctx: IntuContext): ValidationResult;| Parameter | Type | Description |
|---|---|---|
msg | IntuMessage | The message to validate (after transformation) |
ctx | IntuContext | Pipeline context — map variables, logger, channel config |
ValidationResult
Section titled “ValidationResult”interface ValidationResult { valid: boolean; errors?: string[];}When valid is true, the message continues to its destinations. When valid is false, the message is rejected and routed to error handling — the errors array is attached to the error record for debugging.
// Minimal passing validatorexport function validate(msg: IntuMessage, ctx: IntuContext): ValidationResult { return { valid: true };}// Rejecting a message with reasonsexport function validate(msg: IntuMessage, ctx: IntuContext): ValidationResult { return { valid: false, errors: [ "PID segment missing required MRN (PID.3)", "PV1.2 patient class is empty", ], };}Configuring Validators
Section titled “Configuring Validators”Validators are configured in channel.yaml under the validator key:
validator: entrypoint: src/channels/my-channel/validator.tsExamples
Section titled “Examples”HL7v2 Required Segments
Section titled “HL7v2 Required Segments”Verify that an HL7v2 message contains the segments your downstream system expects:
export function validate(msg: IntuMessage, ctx: IntuContext): ValidationResult { const raw = msg.content; const errors: string[] = [];
const requiredSegments = ["MSH", "PID", "PV1", "EVN"];
for (const seg of requiredSegments) { const pattern = new RegExp(`^${seg}\\|`, "m"); if (!pattern.test(raw)) { errors.push(`Missing required segment: ${seg}`); } }
const mshFields = raw.split("\r")[0]?.split("|") ?? []; if (!mshFields[8]) { errors.push("MSH.9 (Message Type) is empty"); } if (!mshFields[9]) { errors.push("MSH.10 (Message Control ID) is empty"); }
return { valid: errors.length === 0, errors };}FHIR Patient Required Fields
Section titled “FHIR Patient Required Fields”Ensure a FHIR Patient resource contains the minimum fields before sending to a FHIR server:
export function validate(msg: IntuMessage, ctx: IntuContext): ValidationResult { const patient = JSON.parse(msg.content); const errors: string[] = [];
if (patient.resourceType !== "Patient") { errors.push(`Expected resourceType "Patient", got "${patient.resourceType}"`); }
if (!patient.identifier?.length) { errors.push("At least one identifier is required"); }
if (!patient.name?.length) { errors.push("At least one name is required"); } else { const name = patient.name[0]; if (!name.family) errors.push("Patient name.family is required"); if (!name.given?.length) errors.push("Patient name.given is required"); }
if (!patient.birthDate) { errors.push("birthDate is required"); } else if (!/^\d{4}-\d{2}-\d{2}$/.test(patient.birthDate)) { errors.push(`birthDate must be YYYY-MM-DD format, got "${patient.birthDate}"`); }
if (!["male", "female", "other", "unknown"].includes(patient.gender)) { errors.push(`Invalid gender value: "${patient.gender}"`); }
return { valid: errors.length === 0, errors };}JSON Schema Validation
Section titled “JSON Schema Validation”Use a JSON schema library for complex structural validation:
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
const schema = { type: "object", required: ["patient_id", "mrn", "last_name", "first_name"], properties: { patient_id: { type: "string", minLength: 1 }, mrn: { type: "string", pattern: "^MRN-\\d{8}$" }, last_name: { type: "string", minLength: 1 }, first_name: { type: "string", minLength: 1 }, dob: { type: "string", format: "date" }, gender: { type: "string", enum: ["M", "F", "U"] }, }, additionalProperties: false,};
const validateSchema = ajv.compile(schema);
export function validate(msg: IntuMessage, ctx: IntuContext): ValidationResult { const data = JSON.parse(msg.content); const isValid = validateSchema(data);
if (isValid) { return { valid: true }; }
const errors = (validateSchema.errors ?? []).map( (err) => `${err.instancePath || "/"} ${err.message}` );
return { valid: false, errors };}Custom Business Rules
Section titled “Custom Business Rules”Enforce organization-specific rules that go beyond structural validation:
export function validate(msg: IntuMessage, ctx: IntuContext): ValidationResult { const data = JSON.parse(msg.content); const errors: string[] = [];
if (data.admitDate && data.dischargeDate) { if (new Date(data.dischargeDate) < new Date(data.admitDate)) { errors.push("Discharge date cannot be before admit date"); } }
const validDepartments = ctx.globalMap.get("validDepartments") as Set<string> | undefined; if (validDepartments && !validDepartments.has(data.department)) { errors.push(`Unknown department: "${data.department}"`); }
if (data.age !== undefined && (data.age < 0 || data.age > 150)) { errors.push(`Age out of plausible range: ${data.age}`); }
const knownMRNs = ctx.globalMap.get("knownMRNs") as Set<string> | undefined; if (knownMRNs && !knownMRNs.has(data.mrn)) { ctx.logger.warn(`Unrecognized MRN: ${data.mrn} — allowing but flagging`); }
return { valid: errors.length === 0, errors };}FHIR Observation Range Check
Section titled “FHIR Observation Range Check”Validate that observation values fall within clinically plausible ranges:
const labRanges: Record<string, { min: number; max: number; unit: string }> = { "2339-0": { min: 30, max: 500, unit: "mg/dL" }, // Glucose "2160-0": { min: 0.1, max: 20, unit: "mg/dL" }, // Creatinine "6690-2": { min: 1000, max: 50000, unit: "10*3/uL" }, // WBC "718-7": { min: 3, max: 25, unit: "g/dL" }, // Hemoglobin};
export function validate(msg: IntuMessage, ctx: IntuContext): ValidationResult { const obs = JSON.parse(msg.content); const errors: string[] = [];
if (obs.resourceType !== "Observation") { return { valid: false, errors: ["Expected Observation resource"] }; }
const loincCode = obs.code?.coding?.find( (c: any) => c.system === "http://loinc.org" )?.code;
if (!loincCode) { errors.push("Missing LOINC code"); return { valid: false, errors }; }
const range = labRanges[loincCode]; if (range && obs.valueQuantity?.value !== undefined) { const val = obs.valueQuantity.value; if (val < range.min || val > range.max) { errors.push( `Value ${val} ${range.unit} out of plausible range [${range.min}–${range.max}] for LOINC ${loincCode}` ); } }
if (!obs.subject?.reference) { errors.push("Observation.subject.reference is required"); }
if (!obs.effectiveDateTime) { errors.push("Observation.effectiveDateTime is required"); }
return { valid: errors.length === 0, errors };}Validator vs. Filter
Section titled “Validator vs. Filter”Validators and filters both prevent messages from reaching destinations, but they serve different purposes:
| Validator | Filter (return null from transformer) | |
|---|---|---|
| Intent | Reject invalid data | Silently drop unwanted data |
| Feedback | Error record with reasons | No record — message disappears |
| Metrics | Counted as validation errors | Counted as filtered |
| Routing | Error handling / dead-letter queue | Nothing |
Validator — rejecting bad data:
export function validate(msg: IntuMessage, ctx: IntuContext): ValidationResult { const patient = JSON.parse(msg.content); if (!patient.identifier?.length) { return { valid: false, errors: ["Patient must have at least one identifier"] }; } return { valid: true };}Filter — dropping irrelevant messages in a transformer:
export function transform(msg: IntuMessage, ctx: IntuContext): IntuMessage | null { const hl7 = msg.rawData; const messageType = hl7.split("|")[8] ?? ""; if (!messageType.startsWith("ADT^A01")) return null; return msg;}