DEV Community

Tom
Tom

Posted on

Detect Normalized CSV Header Collisions Before Mapping an Order

An order file has both Order ID and order_id. If an adapter trims and normalizes those names before constructing a lookup, the two columns may become the same key. The selected order reference can then depend on which value overwrote the other.

The fix belongs before row mapping: validate the complete header and reject ambiguous normalized names. Do not wait until the warehouse has received an order built from the wrong column.

I'm with YX Fulfillment. Our integration discussions distinguish API and file-based order intake. The example below is an independent file-adapter check, not a claim about a production connector.

Choose a normalization policy that rejects collisions

This example expects an array of header fields already returned by a CSV parser. It does not parse CSV with split(','). It uses an explicitly chosen policy for English schema keys: Unicode NFKC normalization, trim, lowercase, and replacement of whitespace runs with an underscore. Hyphens and other punctuation are preserved.

MDN's normalization reference describes compatibility normalization. It can make full-width letters comparable with their ordinary counterparts. That is useful here, but it can also create a collision. Normalizing two different inputs does not prove they were intended to be interchangeable.

The result is a Map from canonical names to column positions. Duplicate names are rejected before set can replace an existing position. The required order fields must also be present before any row is read.

const assert = require('node:assert/strict');

function indexHeaders(headers) {
  if (!Array.isArray(headers) || headers.length === 0) {
    throw new TypeError('nonempty parsed header array required');
  }
  const columns = new Map();
  headers.forEach((raw, position) => {
    if (typeof raw !== 'string') throw new TypeError('header must be a string');
    const key = raw.normalize('NFKC').trim().toLowerCase().replace(/\s+/gu, '_');
    if (!key) throw new Error(`empty header at column ${position + 1}`);
    if (columns.has(key)) {
      throw new Error(`header collision: ${key} at columns ${columns.get(key) + 1} and ${position + 1}`);
    }
    columns.set(key, position);
  });
  for (const required of ['order_id', 'sku', 'quantity']) {
    if (!columns.has(required)) throw new Error(`missing required header: ${required}`);
  }
  return columns;
}

const header = [' Order ID ', 'SKU', 'Quantity', 'Note'];
const before = [...header];
const index = indexHeaders(header);
assert.deepEqual([...index], [
  ['order_id', 0], ['sku', 1], ['quantity', 2], ['note', 3]
]);
assert.deepEqual(header, before);
const row = ['SHOP-0042', 'MUG-BLUE', '2', 'Use approved packing'];
const candidate = {
  orderId: row[index.get('order_id')],
  sku: row[index.get('sku')],
  quantityText: row[index.get('quantity')]
};
assert.deepEqual(candidate, {
  orderId: 'SHOP-0042', sku: 'MUG-BLUE', quantityText: '2'
});
assert.equal(indexHeaders(['quantity', 'sku', 'order_id']).get('order_id'), 2);
assert.equal(indexHeaders(['order_id', 'sku', 'quantity', '__proto__']).get('__proto__'), 3);
assert.throws(() => indexHeaders(['Order ID', 'order_id', 'sku', 'quantity']), /header collision/);
assert.throws(() => indexHeaders(['order_id', 'sku', 'SKU', 'quantity']), /header collision/);
assert.throws(() => indexHeaders(['order_id', 'sku', 'quantity', 'Note', ' note ']), /header collision/);
assert.throws(() => indexHeaders(['order_id', 'sku', 'quantity', '  ']), /empty header/);
assert.throws(() => indexHeaders(['order_id', 'sku', 'qty']), /missing required header: quantity/);
assert.throws(() => indexHeaders(['order-id', 'sku', 'quantity']), /missing required header: order_id/);
assert.throws(() => indexHeaders(['order_id', 'sku', null]), /header must be a string/);
assert.throws(() => indexHeaders([]), /nonempty parsed header/);
assert.throws(() => indexHeaders('order_id,sku,quantity'), /nonempty parsed header/);

let accepted = { orderId: 'previous accepted order' };
assert.throws(() => {
  const invalid = indexHeaders(['Order ID', 'order_id', 'sku', 'quantity']);
  accepted = { orderId: row[invalid.get('order_id')] };
}, /header collision/);
assert.deepEqual(accepted, { orderId: 'previous accepted order' });
console.log('header mapping checks passed');
Enter fullscreen mode Exit fullscreen mode

Save the block as header-map.cjs and run node header-map.cjs. The checks cover reordered fields, full-width normalization, input preservation, duplicate required and optional names, empty and non-string headers, missing required names and a failed header check that leaves the previous accepted object unchanged. The row values remain strings; the code does not pretend to validate product quantities.

Keep the rejected file useful for correction

Return the collision and its column positions to the sender, together with a reference to the original file. Avoid changing the header silently and then asking the seller to explain a mapped order whose source is no longer clear.

This policy is intentionally narrower than guessing aliases. qty does not automatically mean quantity, and order-id does not automatically mean order_id. If an account needs aliases, define and test them as part of its import contract, including collisions between aliases and canonical names.

Header validation is one boundary. A complete importer also needs a real CSV parser, row-length checks, value validation, account scope and an agreed rule for releasing warehouse work. A Map can safely retain a key named __proto__, but that does not make every later conversion to an ordinary object safe. Keep those later transformations within their own tested contract.

Warehouse workstations with monitors, printers and handling equipment

Warehouse workstations at YX Fulfillment.

Top comments (0)