This guide focuses on implementation: parsing EDI, producing JSON you can actually use, validating it, and building a pipeline that’s observable and testable in production.
There are three practical JSON targets. In real systems, you usually implement two of them:
Recommendation: store lossless EDI-as-JSON for traceability and produce canonical JSON for applications and APIs.
You can do this three ways:
Below I’ll show a practical Node.js approach with:
Important: EDI separators aren’t always * and ~. X12 usually declares them in ISA; EDIFACT may declare them in UNA. A robust parser reads them; a minimal parser often assumes them.
For a minimal but working example, we’ll assume:
// edi-parse.js
export function parseEdiToLosslessJson(ediText, opts = {}) {
const elementSep = opts.elementSep ?? '*';
const segmentTerm = opts.segmentTerm ?? '~';
const componentSep = opts.componentSep ?? ':';
// Split segments, trim empties
const rawSegments = ediText
.split(segmentTerm)
.map(s => s.trim())
.filter(Boolean);
const segments = rawSegments.map(seg => {
const parts = seg.split(elementSep);
const tag = parts[0];
const elements = parts.slice(1).map(el => {
// Preserve composite elements as arrays, e.g. "A:B" -> ["A","B"]
if (el.includes(componentSep)) return el.split(componentSep);
return el;
});
return { tag, elements };
});
return {
delimiters: { element: elementSep, segment: segmentTerm, component: componentSep },
segments
};
}
Production note: This is intentionally minimal. For X12 you should detect separators from ISA (fixed-width ISA segment) and for EDIFACT from UNA/UNB to avoid silent parsing errors.
Here’s a simple example transforming an X12 850 purchase order into a developer-friendly object. The pattern is the point: treat EDI as an event stream and build a deterministic state machine for loops.
// x12-850-transform.js
export function transform850(lossless) {
const out = {
documentType: 'X12_850',
purchaseOrderNumber: null,
orderDate: null,
shipTo: null,
billTo: null,
lines: [],
refs: []
};
let currentLine = null;
for (const seg of lossless.segments) {
switch (seg.tag) {
case 'BEG': {
// BEG*00*SA*PO12345**20260217
out.purchaseOrderNumber = seg.elements[2] ?? null;
const yyyymmdd = seg.elements[4];
out.orderDate = yyyymmdd ? toIsoDate(yyyymmdd) : null;
break;
}
case 'REF': {
// REF*IA*123456
out.refs.push({
qualifier: seg.elements[0] ?? null,
value: seg.elements[1] ?? null
});
break;
}
case 'N1': {
// N1*ST*ACME PLANT*92*100
const entityId = seg.elements[0];
const party = {
name: seg.elements[1] ?? null,
idQualifier: seg.elements[2] ?? null,
id: seg.elements[3] ?? null
};
if (entityId === 'ST') out.shipTo = party;
if (entityId === 'BT') out.billTo = party;
break;
}
case 'PO1': {
// PO1*1*10*EA*12.34**BP*ABC-123
if (currentLine) out.lines.push(currentLine);
currentLine = {
lineNumber: seg.elements[0] ?? null,
quantity: seg.elements[1] ? Number(seg.elements[1]) : null,
uom: seg.elements[2] ?? null,
unitPrice: seg.elements[3] ? Number(seg.elements[3]) : null,
buyerPartNumber: null,
vendorPartNumber: null,
description: null
};
// PO1 product IDs often come in qualifier/value pairs
// e.g., **BP*ABC-123*VP*VEND-999
// Elements after unitPrice can vary; walk pairs.
for (let i = 5; i < seg.elements.length; i += 2) {
const qual = seg.elements[i];
const val = seg.elements[i + 1];
if (!qual || !val) continue;
if (qual === 'BP') currentLine.buyerPartNumber = val;
if (qual === 'VP') currentLine.vendorPartNumber = val;
}
break;
}
case 'PID': {
// PID*F****Some description
if (currentLine) {
const desc = seg.elements[4];
if (desc) currentLine.description = desc;
}
break;
}
case 'CTT': {
// End of transaction summary - flush last line
if (currentLine) {
out.lines.push(currentLine);
currentLine = null;
}
break;
}
default:
break;
}
}
// Flush if file ended without CTT
if (currentLine) out.lines.push(currentLine);
return out;
}
function toIsoDate(yyyymmdd) {
const s = String(yyyymmdd);
if (s.length !== 8) return null;
return `${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}`;
}
Implementation tip: Keep transformation logic transaction-specific (850/810/856), but keep your canonical JSON schema stable across partners.
Use ajv (fast JSON Schema validator) to enforce required fields, types, and formats.
// validate.js
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv({ allErrors: true, removeAdditional: false });
addFormats(ajv);
export const poSchema = {
type: 'object',
required: ['documentType', 'purchaseOrderNumber', 'lines'],
properties: {
documentType: { const: 'X12_850' },
purchaseOrderNumber: { type: 'string', minLength: 1 },
orderDate: { type: ['string', 'null'], format: 'date' },
shipTo: {
type: ['object', 'null'],
properties: {
name: { type: ['string', 'null'] },
idQualifier: { type: ['string', 'null'] },
id: { type: ['string', 'null'] }
}
},
lines: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['lineNumber', 'quantity', 'uom'],
properties: {
lineNumber: { type: ['string', 'null'] },
quantity: { type: ['number', 'null'] },
uom: { type: ['string', 'null'] },
unitPrice: { type: ['number', 'null'] },
buyerPartNumber: { type: ['string', 'null'] },
vendorPartNumber: { type: ['string', 'null'] },
description: { type: ['string', 'null'] }
}
}
}
}
};
export function validatePo(po) {
const validate = ajv.compile(poSchema);
const ok = validate(po);
return { ok, errors: validate.errors ?? [] };
}
This example ingests raw EDI and returns canonical JSON (or validation errors).
// server.js (Express example)
import express from 'express';
import { parseEdiToLosslessJson } from './edi-parse.js';
import { transform850 } from './x12-850-transform.js';
import { validatePo } from './validate.js';
import crypto from 'crypto';
const app = express();
app.use(express.text({ type: '*/*', limit: '5mb' }));
app.post('/ingest/edi', (req, res) => {
const correlationId = req.header('x-correlation-id') || crypto.randomUUID();
const edi = req.body;
// 1) parse to lossless
const lossless = parseEdiToLosslessJson(edi);
// 2) (example) transform 850
const po = transform850(lossless);
// 3) validate canonical JSON
const { ok, errors } = validatePo(po);
if (!ok) {
return res.status(422).json({
correlationId,
status: 'rejected',
errors
});
}
// 4) publish downstream (stub)
// await publishToEventBus({ correlationId, document: po })
return res.status(200).json({
correlationId,
status: 'accepted',
document: po
});
});
app.listen(3000, () => console.log('EDI ingest listening on :3000'));
If you’re serious about reducing EDI exceptions, add:
This is exactly where “Product Truth SLAs” become real: you can measure detect/decide/publish for transaction truth.
At minimum:
// example.test.js (Jest)
import fs from 'node:fs';
import { parseEdiToLosslessJson } from './edi-parse.js';
import { transform850 } from './x12-850-transform.js';
test('transforms 850 fixture to canonical JSON', () => {
const edi = fs.readFileSync('./fixtures/850-sample.edi', 'utf8');
const lossless = parseEdiToLosslessJson(edi);
const po = transform850(lossless);
expect(po.purchaseOrderNumber).toBeTruthy();
expect(po.lines.length).toBeGreaterThan(0);
});
If you want to modernize without breaking EDI, the quickest win is a canonical JSON layer with validation, observability, and partner adapters—so EDI becomes a transport, not your application boundary.
Talk with Layer One about adapters, accelerators and using AI to jumpstart your conversion.